Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Vulnerability Research

Fix CORS Wildcard Parsing Off-by-One: Origin Allowlist Bypass

CORS off-by-one bugs let attackers bypass your origin allowlist using evil-yourcompany.com or unanchored regex. Exact fixes for Node.js, Python, Java — substring, regex & port patterns.

Offensive360 Security Research Team — min read
CORS cors wildcard cors off-by-one fix cors wildcard parsing off-by-one Access-Control-Allow-Origin origin allowlist bypass web security API security CWE-942 cors misconfiguration

A CORS “off-by-one” parsing bug is a class of vulnerability where the origin validation logic on your server is almost correct — but an edge case in string matching, regex anchoring, or port comparison allows an attacker-controlled origin to slip through the allowlist check. The result is a CORS misconfiguration that lets any website under the attacker’s control make credentialed cross-origin requests to your API and read the responses.

These bugs are particularly dangerous because they appear to pass code review — the intent of the check is clearly correct, but the implementation has a subtle flaw. This guide explains each variant, shows exactly how to trigger it, and provides the precise fix for each pattern.


Why CORS Parsing Bugs Matter

When your API serves authenticated data, CORS is the browser’s enforcement mechanism preventing malicious websites from silently reading that data using your users’ session cookies. Your Access-Control-Allow-Origin allowlist tells browsers which origins are permitted to read API responses.

If the allowlist check is bypassed, an attacker can:

  1. Host a page on a domain that passes your flawed validation (e.g., evil-yourcompany.com)
  2. Have the victim visit that page while logged into your application
  3. Use JavaScript on the attacker’s page to make credentialed cross-origin requests to your API
  4. Read the API responses — account data, session tokens, sensitive records

The CORS wildcard (*) is the well-known misconfiguration. The off-by-one parsing bug is the subtler, harder-to-detect variant that achieves the same effect while appearing to implement proper origin validation.


Bug Pattern 1: Substring Match Without Boundary Check

The most common CORS parsing off-by-one error is using indexOf(), includes(), or contains() to check for the domain name — without anchoring to domain boundaries.

Vulnerable Code

// VULNERABLE — indexOf check passes for any domain containing "yourcompany.com"
function isAllowedOrigin(origin) {
  return origin.indexOf('yourcompany.com') !== -1;
}

// Attacker registers: evil-yourcompany.com
// isAllowedOrigin('https://evil-yourcompany.com') → true
// Also passes: https://yourcompany.com.attacker.io
# VULNERABLE — same substring bug in Python
def is_allowed_origin(origin):
    return 'yourcompany.com' in origin
    # Passes: https://notyourcompany.com
    # Passes: https://yourcompany.com.evil.io
// VULNERABLE — Java contains() check
boolean isAllowedOrigin(String origin) {
    return origin.contains("yourcompany.com");
    // Passes: https://yourcompany.com.attacker.io
}

Why This Is an Off-by-One

The developer intends to match only yourcompany.com and its subdomains. The substring check succeeds whenever yourcompany.com appears anywhere in the string — including as a suffix of another domain, or followed by arbitrary characters.

The “off by one” refers to missing the boundary check: the domain evil-yourcompany.com satisfies the check because yourcompany.com appears at position 5 (off by the 5 characters evil-). Similarly, yourcompany.com.evil.io passes because the target string appears at position 0 but is followed by .evil.io.

The Fix: URL Parsing + Exact Hostname Matching

Parse the origin as a URL and check the hostname against an explicit list or with a proper boundary-aware suffix check:

// SECURE — parse the URL and check hostname boundaries
function isAllowedOrigin(origin) {
  if (!origin) return false;
  try {
    const url = new URL(origin);
    // Must be HTTPS
    if (url.protocol !== 'https:') return false;
    // Exact match OR subdomain match with dot-boundary
    const { hostname } = url;
    return (
      hostname === 'yourcompany.com' ||
      hostname.endsWith('.yourcompany.com') // dot ensures boundary: "evil-yourcompany.com" fails
    );
  } catch {
    return false; // Invalid URL — reject
  }
}

// Test cases:
// https://yourcompany.com          → ✅ allowed
// https://app.yourcompany.com      → ✅ allowed
// https://evil-yourcompany.com     → ❌ rejected (no dot before yourcompany.com)
// https://yourcompany.com.evil.io  → ❌ rejected (hostname doesn't end with .yourcompany.com)
// https://notyourcompany.com       → ❌ rejected
# SECURE — Python equivalent using urllib.parse
from urllib.parse import urlparse

def is_allowed_origin(origin: str) -> bool:
    if not origin:
        return False
    try:
        parsed = urlparse(origin)
        if parsed.scheme != 'https':
            return False
        hostname = parsed.hostname or ''
        return (
            hostname == 'yourcompany.com' or
            hostname.endswith('.yourcompany.com')  # Dot boundary enforced by endswith
        )
    except Exception:
        return False
// SECURE — Java with URI parsing
import java.net.URI;

boolean isAllowedOrigin(String origin) {
    if (origin == null || origin.isEmpty()) return false;
    try {
        URI uri = new URI(origin);
        if (!"https".equals(uri.getScheme())) return false;
        String host = uri.getHost();
        if (host == null) return false;
        return host.equals("yourcompany.com") 
            || host.endsWith(".yourcompany.com"); // endsWith with dot prefix is the fix
    } catch (Exception e) {
        return false;
    }
}

Bug Pattern 2: Unanchored Regex

The second common off-by-one pattern uses a regex that matches the right string but is not fully anchored, allowing unexpected characters before or after the match.

Vulnerable Code

# VULNERABLE — re.search() finds the pattern anywhere in the string
import re

def is_allowed_origin(origin):
    if re.search(r'yourcompany\.com', origin):
        return True
    return False
# Passes: https://notyourcompany.com  (contains "yourcompany.com")
# Passes: https://yourcompany.com.evil.io  (contains "yourcompany.com")
// VULNERABLE — unanchored regex test
const ALLOWED_PATTERN = /yourcompany\.com/;

function isAllowedOrigin(origin) {
  return ALLOWED_PATTERN.test(origin);
  // Passes: https://evil-yourcompany.com
  // Passes: https://yourcompany.com.attacker.io
}

The Fix: Anchored Full-Match Regex

Use re.fullmatch() in Python or ^...$ anchors in JavaScript, and ensure the pattern covers the full origin including scheme and port:

# SECURE — anchored regex with full-string match
import re

# Pattern: https:// + optional subdomain + yourcompany.com + optional :port
ALLOWED_ORIGIN_RE = re.compile(
    r'^https://([a-zA-Z0-9][a-zA-Z0-9\-]*\.)?yourcompany\.com(:\d+)?$'
)

def is_allowed_origin(origin: str) -> bool:
    if not origin:
        return False
    return bool(ALLOWED_ORIGIN_RE.fullmatch(origin))

# Test cases:
# https://yourcompany.com             → ✅
# https://app.yourcompany.com         → ✅
# https://yourcompany.com:8443        → ✅ (explicit port, if needed)
# https://evil-yourcompany.com        → ❌ (no dot before yourcompany)
# https://yourcompany.com.evil.io     → ❌ (pattern anchored at end)
# http://yourcompany.com              → ❌ (http rejected)
// SECURE — anchored regex in JavaScript
const ALLOWED_ORIGIN_RE = /^https:\/\/([a-zA-Z0-9][a-zA-Z0-9-]*\.)?yourcompany\.com(:\d+)?$/;

function isAllowedOrigin(origin) {
  if (!origin) return false;
  return ALLOWED_ORIGIN_RE.test(origin);
}

Key anchoring rules:

  • Always use ^ (start) and $ (end) anchors in JavaScript regex
  • Always use re.fullmatch() instead of re.search() or re.match() in Python
    • re.match() anchors the start but not the end — re.match(r'yourcompany\.com', 'yourcompany.com.evil.io') matches!
  • Escape the dot in the domain: yourcompany\.com (not yourcompany.com which matches any character)

Bug Pattern 3: Port-Ignoring Comparison

A subtler off-by-one variant compares only the hostname portion of the origin, ignoring the port. This matters because Access-Control-Allow-Origin must match the full origin (scheme + host + port). Two origins with different ports are different origins — but if your check only compares hostnames, they appear identical.

Vulnerable Code

// VULNERABLE — only compares hostname, not port
const ALLOWED_ORIGINS = ['https://app.yourcompany.com'];

function isAllowedOrigin(origin) {
  try {
    const incomingHost = new URL(origin).hostname;
    return ALLOWED_ORIGINS.some(allowed => new URL(allowed).hostname === incomingHost);
  } catch {
    return false;
  }
}

// Attacker registers a server at app.yourcompany.com on port 4000
// (e.g., via a development machine connected to the same internal network,
//  or by registering an IP that resolves to app.yourcompany.com on a different port)
// isAllowedOrigin('https://app.yourcompany.com:4000') → true (hostname matches)
// But the actual origin https://app.yourcompany.com:4000 is NOT the same as https://app.yourcompany.com

In multi-tenant or complex network environments, this can allow requests from origins that share a hostname but run on different ports — a meaningful security boundary in some architectures.

The Fix: Compare the Full Origin String

The simplest and most reliable fix is to compare the full origin string — not its parsed components:

// SECURE — exact full-origin string comparison
const ALLOWED_ORIGINS = new Set([
  'https://app.yourcompany.com',
  'https://yourcompany.com',
  // Note: 'https://app.yourcompany.com:443' is same as 'https://app.yourcompany.com'
  // Browsers normalize the default port away — do not include :443 or :80
]);

function isAllowedOrigin(origin) {
  if (!origin) return false;
  return ALLOWED_ORIGINS.has(origin); // Exact string match — O(1), no parsing bugs
}

Using a Set for exact string lookup is the most correct approach: it is O(1), has no parsing ambiguity, and cannot be confused by port differences, scheme differences, or trailing characters.


Bug Pattern 4: Null Origin Handling

A less-common but critical off-by-one involves how servers handle the literal string "null" as an origin. Browsers send Origin: null for requests from:

  • file:// pages (local HTML files)
  • Sandboxed iframes with no allow-same-origin attribute
  • Redirected cross-origin requests in some browsers

Vulnerable Code

// VULNERABLE — reflects "null" origin back if not checked
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin) {
    res.setHeader('Access-Control-Allow-Origin', origin); // "null" is reflected back
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  next();
});

// Attacker creates a sandboxed iframe:
// <iframe sandbox="allow-scripts" src="attacker-page.html">
// From inside the iframe, Origin: null is sent
// Server reflects: Access-Control-Allow-Origin: null
// Browser allows the credentialed cross-origin request

The Fix: Validate Against an Allowlist That Excludes Null

The "null" origin should never appear in a legitimate allowlist:

// SECURE — allowlist explicitly rejects "null" and non-https origins
const ALLOWED_ORIGINS = new Set([
  'https://app.yourcompany.com',
  'https://yourcompany.com',
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  // If origin is "null", missing, or not in allowlist — no CORS headers sent
  next();
});

Comprehensive Secure CORS Middleware

Here is a production-ready CORS middleware combining all the fixes above:

Node.js / Express

const ALLOWED_ORIGINS = new Set([
  'https://app.yourcompany.com',
  'https://yourcompany.com',
  'https://staging.yourcompany.com',
]);

function corsMiddleware(req, res, next) {
  const origin = req.headers.origin;

  // Only set CORS headers if origin is in the allowlist
  // (exact match — no substring checks, no regex with unanchored patterns)
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin'); // Required for CDN/proxy caching correctness
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
    res.setHeader(
      'Access-Control-Allow-Headers',
      'Content-Type, Authorization, X-Requested-With, X-API-Key'
    );
    res.setHeader('Access-Control-Max-Age', '86400'); // Cache preflight for 24h
  }

  // Handle preflight
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }

  next();
}

app.use(corsMiddleware);

Python / Flask

from flask import Flask, request
from urllib.parse import urlparse

app = Flask(__name__)

ALLOWED_ORIGINS = frozenset([
    'https://app.yourcompany.com',
    'https://yourcompany.com',
    'https://staging.yourcompany.com',
])

@app.after_request
def add_cors_headers(response):
    origin = request.headers.get('Origin', '')
    
    # Exact set lookup — no substring matching, no unanchored regex
    if origin in ALLOWED_ORIGINS:
        response.headers['Access-Control-Allow-Origin'] = origin
        response.headers['Vary'] = 'Origin'
        response.headers['Access-Control-Allow-Credentials'] = 'true'
        response.headers['Access-Control-Allow-Methods'] = \
            'GET, POST, PUT, PATCH, DELETE, OPTIONS'
        response.headers['Access-Control-Allow-Headers'] = \
            'Content-Type, Authorization, X-Requested-With'
    
    return response

Java / Spring Boot

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // Explicit list — Spring uses exact matching, no substring bugs
    config.setAllowedOrigins(List.of(
        "https://app.yourcompany.com",
        "https://yourcompany.com",
        "https://staging.yourcompany.com"
    ));

    config.setAllowedMethods(List.of(
        "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
    ));

    config.setAllowedHeaders(List.of(
        "Content-Type", "Authorization", "X-Requested-With", "X-API-Key"
    ));

    config.setAllowCredentials(true);
    config.setMaxAge(86400L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

Testing for CORS Parsing Off-by-One Bugs

Use these curl commands to test your CORS validation logic for each bug pattern:

# Pattern 1 test: suffix domain that contains your domain name
curl -si -H "Origin: https://evil-yourcompany.com" \
     https://api.yoursite.com/user/profile \
     | grep -i "access-control"
# Expected: no Access-Control headers (origin rejected)

# Pattern 1 test: your domain as a subdomain of an attacker domain
curl -si -H "Origin: https://yourcompany.com.attacker.io" \
     https://api.yoursite.com/user/profile \
     | grep -i "access-control"
# Expected: no Access-Control headers

# Pattern 3 test: correct hostname but wrong port
curl -si -H "Origin: https://app.yourcompany.com:4000" \
     https://api.yoursite.com/user/profile \
     | grep -i "access-control"
# Expected: no Access-Control headers (if port 4000 is not in your allowlist)

# Pattern 4 test: null origin
curl -si -H "Origin: null" \
     https://api.yoursite.com/user/profile \
     | grep -i "access-control"
# Expected: no Access-Control headers

# Legitimate origin test:
curl -si -H "Origin: https://app.yourcompany.com" \
     https://api.yoursite.com/user/profile \
     | grep -i "access-control"
# Expected: Access-Control-Allow-Origin: https://app.yourcompany.com
#           Vary: Origin

If any of the first four tests return Access-Control-Allow-Origin headers, your validation logic has a parsing off-by-one bug.


How SAST Tools Detect These Patterns

Static application security testing tools that model string operations can detect some of these patterns:

  • Substring-based allowlist check — a SAST tool can identify calls to indexOf(), includes(), or contains() used in an origin validation context and flag them as potentially insufficient
  • Unanchored regex — tools that model regex structure can detect patterns that lack ^/$ anchors in origin validation functions
  • Reflected origin without validation — the most detectable pattern: any code that copies request.headers.origin directly into Access-Control-Allow-Origin without passing through a validation function

In Offensive360, CORS parsing vulnerabilities map to the CORSAllowOriginWildcard and CORSReflectedOrigin rules in the Knowledge Base, covering Node.js, Python, Java, PHP, Go, and C# codebases.

The off-by-one parsing patterns specifically require taint analysis of string operations — the scanner must understand that origin.indexOf('yourcompany.com') !== -1 does not constitute safe origin validation, even though a regex check or set lookup would. This is a capability present in enterprise-grade SAST tools like Offensive360 but absent from pattern-only scanners.


Summary: CORS Origin Validation Rules

Bug PatternVulnerable CodeBypass ExampleFix
Substring matchorigin.includes('yourcompany.com')evil-yourcompany.comURL parse + hostname.endsWith('.yourcompany.com')
Unanchored regexre.search(r'yourcompany\.com', origin)yourcompany.com.evil.iore.fullmatch() with anchored pattern
Hostname-only comparenew URL(o1).hostname === new URL(o2).hostnameapp.yourcompany.com:4000Exact string match on full origin
Null origin reflectedAny code that reflects Origin: nullSandboxed iframeReject null; use explicit allowlist only

The safest CORS origin validation is the simplest: an exact string comparison against a static set of allowed origins. Any parsing logic — substring checks, regex, URL component extraction — introduces edge cases that can be exploited.

Use new Set(['https://app.yourcompany.com']) and .has(origin). No parsing, no edge cases, no off-by-one bugs.


Offensive360 SAST detects CORS wildcard misconfigurations, reflected-origin patterns, and unanchored origin validation logic across Node.js, Python, Java, PHP, Go, and C# codebases. Scan your API server code or browse the CORS vulnerability reference for remediation details.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.