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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions