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:
- Host a page on a domain that passes your flawed validation (e.g.,
evil-yourcompany.com) - Have the victim visit that page while logged into your application
- Use JavaScript on the attacker’s page to make credentialed cross-origin requests to your API
- 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 ofre.search()orre.match()in Pythonre.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(notyourcompany.comwhich 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-originattribute - 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(), orcontains()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.origindirectly intoAccess-Control-Allow-Originwithout 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 Pattern | Vulnerable Code | Bypass Example | Fix |
|---|---|---|---|
| Substring match | origin.includes('yourcompany.com') | evil-yourcompany.com | URL parse + hostname.endsWith('.yourcompany.com') |
| Unanchored regex | re.search(r'yourcompany\.com', origin) | yourcompany.com.evil.io | re.fullmatch() with anchored pattern |
| Hostname-only compare | new URL(o1).hostname === new URL(o2).hostname | app.yourcompany.com:4000 | Exact string match on full origin |
| Null origin reflected | Any code that reflects Origin: null | Sandboxed iframe | Reject 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.