Skip to content

Security: README regex example allows unintended origin matching #408

@ekreloff

Description

@ekreloff

Summary

The README's documented regex example for the origin option (/example\.com$/) matches unintended origins like evil-example.com, creating a CORS bypass when developers follow the documented pattern.

Reproduction

const cors = require('cors');

// Pattern from README line: "For example the pattern /example\.com$/ will reflect
// any request that is coming from an origin ending with 'example.com'."
const app = express();
app.use(cors({ origin: /example\.com$/ }));

Expected: Only example.com and its subdomains are allowed.
Actual: Any domain ending in example.com is allowed, including attacker-controlled domains.

https://example.com       → allowed ✓ (intended)
https://sub.example.com   → allowed ✓ (intended)
https://evil-example.com  → allowed ✓ (UNINTENDED)
https://notexample.com    → allowed ✓ (UNINTENDED)

The attacker registers evil-example.com → gets full CORS access to the API.

Root Cause

The regex /example\.com$/ only anchors the end ($) but not the beginning. Since browser Origin headers include the scheme (https://evil-example.com), the substring example.com matches anywhere after the scheme.

Interestingly, the test file (test/test.js:184) uses the correct pattern:

// From test file — properly anchored
{ origin: /:\/\/(.+\.)?example.com$/ }

This correctly rejects evil-example.com because :// anchors after the scheme, and (.+\.)? requires an optional subdomain preceded by a dot.

Impact

  • Severity: Medium — CORS origin bypass via domain registration
  • Scope: Any application that follows the README's regex example
  • Likelihood: High — the README example is the first thing developers copy when configuring regex-based origin matching
  • With credentials: true: Attacker's site can make authenticated cross-origin requests and read responses

Suggested Fix

Option 1 (minimal): Update the README example to use the pattern from the test file:

- For example the pattern `/example\.com$/` will reflect any request
- that is coming from an origin ending with "example.com".
+ For example the pattern `/:\/\/(.+\.)?example\.com$/` will reflect
+ any request from "example.com" or its subdomains.

Option 2 (better): Add a security note to the origin configuration docs:

> **⚠️ Security note:** When using RegExp origins, always anchor both the scheme
> and domain boundary to prevent unintended matches. `/example\.com$/` matches
> `evil-example.com` — use `/:\/\/(.+\.)?example\.com$/` instead.

Option 3 (best): Add a runtime console.warn when a RegExp origin lacks scheme anchoring:

if (allowedOrigin instanceof RegExp && !allowedOrigin.source.includes(':\\/\\/')) {
  console.warn('cors: RegExp origin %s may match unintended domains. ' +
    'Consider anchoring after the scheme (e.g., /:\\/\\/(.+\\.)?example\\.com$/)', allowedOrigin);
}

Verification

// Insecure (current README example)
/example\.com$/.test('https://evil-example.com')  // true — bypass!

// Secure (current test file pattern)
/:\/\/(.+\.)?example\.com$/.test('https://evil-example.com')  // false — blocked
/:\/\/(.+\.)?example\.com$/.test('https://sub.example.com')   // true — allowed
/:\/\/(.+\.)?example\.com$/.test('https://example.com')       // true — allowed

Environment

  • cors version: latest (reviewed from master branch)
  • Node.js: N/A (documentation issue)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions