Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 63 additions & 57 deletions src/lib/server/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,39 +99,56 @@ export const verify_request_source =
* - No wildcards in IPv6 addresses
* - Port wildcards must be `:*` exactly
*
* Note: Patterns are normalized via URL constructor. IPv4-mapped IPv6 addresses
* like `[::ffff:127.0.0.1]` will be normalized to `[::ffff:7f00:1]`. IPv6 zone
* identifiers (e.g., `%eth0`) are not supported.
*
* @throws {Error} If pattern format is invalid
*/
const origin_pattern_to_regexp = (pattern: string): RegExp => {
// Parse pattern with support for IPv6 addresses in brackets
const parts = /^(https?:\/\/)(\[[^\]]+\]|[^:/]+)(:\*|:\d+)?(\/.*)?$/.exec(pattern);
if (!parts) {
// Quick validation: no paths, query strings, or fragments allowed
const protocol_idx = pattern.indexOf('://');
if (protocol_idx === -1) {
throw new Error(`Invalid origin pattern: ${pattern}`);
}
const after_protocol = pattern.slice(protocol_idx + 3);
if (/[/?#]/.test(after_protocol)) {
throw new Error(`Paths not allowed in origin patterns: ${pattern}`);
}

const protocol = parts[1];
const hostname = parts[2];
const port = parts[3] ?? '';
const path = parts[4] ?? '';
// Check for wildcards in IPv6 before URL parsing (URL rejects these with unhelpful error)
const ipv6_match = /^\[([^\]]+)\]/.exec(after_protocol);
if (ipv6_match?.[1]?.includes('*')) {
throw new Error(`Wildcards not allowed in IPv6 addresses: ${pattern}`);
}

// These should always exist if the regex matched, but check defensively
if (!protocol || !hostname) {
throw new Error(`Failed to parse origin pattern: ${pattern}`);
// Handle port wildcard - must be at the end
let port_wildcard = false;
let parse_pattern = pattern;
if (pattern.endsWith(':*')) {
port_wildcard = true;
parse_pattern = pattern.slice(0, -2);
}

// Origins cannot have paths
if (path) {
throw new Error(`Paths not allowed in origin patterns: ${pattern}`);
// Parse with URL constructor for robust handling of protocol, hostname, port, IPv6
let url: URL;
try {
url = new URL(parse_pattern);
} catch {
throw new Error(`Invalid origin pattern: ${pattern}`);
}

// IPv6 addresses cannot contain wildcards
if (hostname.startsWith('[') && hostname.includes('*')) {
throw new Error(`Wildcards not allowed in IPv6 addresses: ${pattern}`);
// Validate protocol is http or https
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`Invalid origin pattern: ${pattern}`);
}

const hostname = url.hostname;
const is_ipv6 = hostname.startsWith('[');

// For regular hostnames, wildcards must be complete labels
if (!hostname.startsWith('[')) {
const labels = hostname.split('.');
for (const label of labels) {
if (!is_ipv6) {
for (const label of hostname.split('.')) {
if (label.includes('*') && label !== '*') {
throw new Error(
`Wildcards must be complete labels (e.g., *.example.com, not *example.com): ${pattern}`,
Expand All @@ -140,39 +157,38 @@ const origin_pattern_to_regexp = (pattern: string): RegExp => {
}
}

// Port wildcards must be exactly :*
if (port.includes('*') && port !== ':*') {
throw new Error(`Invalid port wildcard: ${pattern}`);
}

// Build regex pattern
let regex_pattern = '^';
regex_pattern += escape_regexp(protocol);
let regex_pattern = '^' + escape_regexp(url.protocol) + '//';

// Handle hostname
if (hostname.startsWith('[')) {
// IPv6 address - escape brackets and contents
if (is_ipv6) {
// IPv6 address - URL.hostname includes brackets
regex_pattern += escape_regexp(hostname);
} else {
// Regular hostname - process wildcards
const labels = hostname.split('.');
const regex_labels = labels.map((label) => {
if (label === '*') {
// Match exactly one label (no dots, colons, or slashes)
return '[^./:]+';
} else {
return escape_regexp(label);
}
});
regex_pattern += regex_labels.join('\\.');
regex_pattern += labels
.map((label) => (label === '*' ? '[^./:]+' : escape_regexp(label)))
.join('\\.');
}

// Handle port
if (port === ':*') {
if (port_wildcard) {
// Optional port (matches both with and without port)
regex_pattern += '(:\\d+)?';
} else {
regex_pattern += escape_regexp(port);
// URL normalizes default ports (80 for HTTP, 443 for HTTPS) away,
// so check original pattern for explicit port when url.port is empty
let port = url.port;
if (!port) {
const port_match = /:(\d+)$/.exec(parse_pattern);
if (port_match?.[1]) {
port = port_match[1];
}
}
if (port) {
regex_pattern += ':' + escape_regexp(port);
}
}

regex_pattern += '$';
Expand All @@ -182,27 +198,17 @@ const origin_pattern_to_regexp = (pattern: string): RegExp => {
};

/**
* Efficiently extracts the origin from a referer URL, removing the path.
* Extracts the origin from a referer URL, removing the path, query string, and fragment.
*
* @param referer - The referer URL (e.g., `https://example.com/path/to/page`)
* @param referer - The referer URL (e.g., `https://example.com/path?query#hash`)
* @returns The origin part (e.g., `https://example.com`)
*/
const extract_origin_from_referer = (referer: string): string => {
// Extract origin from referer by finding the third slash
// Format: protocol://host[:port]/path...
let slash_count = 0;
let origin_end = -1;

for (let i = 0; i < referer.length; i++) {
if (referer[i] === '/') {
slash_count++;
if (slash_count === 3) {
origin_end = i;
break;
}
}
try {
return new URL(referer).origin;
} catch {
// If URL parsing fails, return the original string
// (it will likely fail pattern matching anyway)
return referer;
}

// If we found the third slash, extract origin; otherwise use the whole referer
return origin_end !== -1 ? referer.substring(0, origin_end) : referer;
};
77 changes: 47 additions & 30 deletions src/test/server/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,22 +191,26 @@ describe('pattern_to_regexp', () => {
});

test('matches IPv4-mapped IPv6 addresses', () => {
// Note: URL constructor normalizes IPv4-mapped addresses to hex format
// [::ffff:127.0.0.1] becomes [::ffff:7f00:1]
test_pattern(
'http://[::ffff:127.0.0.1]:3000',
['http://[::ffff:127.0.0.1]:3000'],
'http://[::ffff:7f00:1]:3000',
['http://[::ffff:7f00:1]:3000'],
[
'http://[::ffff:127.0.0.1]',
'http://[::ffff:127.0.0.1]:3001',
'http://[::ffff:7f00:1]',
'http://[::ffff:7f00:1]:3001',
'http://127.0.0.1:3000', // Regular IPv4 should not match
],
);
});

test('matches IPv4-mapped IPv6 without port', () => {
// Note: URL constructor normalizes IPv4-mapped addresses to hex format
// [::ffff:192.168.1.1] becomes [::ffff:c0a8:101]
test_pattern(
'https://[::ffff:192.168.1.1]',
['https://[::ffff:192.168.1.1]'],
['https://[::ffff:192.168.1.1]:443', 'https://192.168.1.1', 'http://[::ffff:192.168.1.1]'],
'https://[::ffff:c0a8:101]',
['https://[::ffff:c0a8:101]'],
['https://[::ffff:c0a8:101]:443', 'https://192.168.1.1', 'http://[::ffff:c0a8:101]'],
);
});
});
Expand Down Expand Up @@ -497,15 +501,13 @@ describe('pattern_to_regexp', () => {

describe('edge cases', () => {
test('handles IPv6 addresses', () => {
const patterns = parse_allowed_origins(
'http://[::1]:3000,https://[2001:db8::1],http://[fe80::1%lo0]:8080',
);
expect(patterns).toHaveLength(3);
// Note: Zone identifiers (e.g., %lo0) are not supported by URL constructor
const patterns = parse_allowed_origins('http://[::1]:3000,https://[2001:db8::1]');
expect(patterns).toHaveLength(2);

// Test various IPv6 formats
expect(should_allow_origin('http://[::1]:3000', patterns)).toBe(true);
expect(should_allow_origin('https://[2001:db8::1]', patterns)).toBe(true);
expect(should_allow_origin('http://[fe80::1%lo0]:8080', patterns)).toBe(true);

// Should not match without brackets
expect(should_allow_origin('http://::1:3000', patterns)).toBe(false);
Expand All @@ -519,12 +521,8 @@ describe('pattern_to_regexp', () => {
['https://[2001:db8:0:0:8a2e:370:7334]'], // Different representation should not match exactly
);

// Test zone identifiers
test_pattern(
'http://[fe80::1%eth0]:8080',
['http://[fe80::1%eth0]:8080'],
['http://[fe80::1]:8080', 'http://[fe80::1%eth1]:8080'],
);
// Note: Zone identifiers (e.g., %eth0) are not supported by URL constructor
// If you need zone identifiers, use the literal normalized form
});

test('handles IPv6 edge cases', () => {
Expand All @@ -536,21 +534,18 @@ describe('pattern_to_regexp', () => {
);

// IPv4-mapped with wildcard port
// Note: URL normalizes [::ffff:127.0.0.1] to [::ffff:7f00:1]
test_pattern(
'http://[::ffff:127.0.0.1]:*',
[
'http://[::ffff:127.0.0.1]',
'http://[::ffff:127.0.0.1]:3000',
'http://[::ffff:127.0.0.1]:8080',
],
['http://[::ffff:127.0.0.2]:3000', 'https://[::ffff:127.0.0.1]:3000'],
'http://[::ffff:7f00:1]:*',
['http://[::ffff:7f00:1]', 'http://[::ffff:7f00:1]:3000', 'http://[::ffff:7f00:1]:8080'],
['http://[::ffff:7f00:2]:3000', 'https://[::ffff:7f00:1]:3000'],
);

// Very long valid IPv6 address
// Very long valid IPv6 address (URL may normalize this)
test_pattern(
'https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:443',
['https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:443'],
['https://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]'],
'https://[2001:db8:85a3::8a2e:370:7334]:443',
['https://[2001:db8:85a3::8a2e:370:7334]:443'],
['https://[2001:db8:85a3::8a2e:370:7334]'],
);
});

Expand Down Expand Up @@ -779,6 +774,19 @@ describe('verify_request_source middleware', () => {
'forbidden referer',
);
});

test('blocks referers with null origin (opaque origins)', async () => {
// data: URLs and sandboxed iframes have origin 'null'
// new URL('data:text/html,...').origin returns 'null'
// This should be blocked since 'null' won't match any valid pattern
await test_middleware_blocks(
middleware,
{
referer: 'data:text/html,<h1>test</h1>',
},
'forbidden referer',
);
});
});

describe('direct access (no headers)', () => {
Expand Down Expand Up @@ -949,7 +957,7 @@ describe('integration scenarios', () => {
});

describe('normalize_origin', () => {
test('handles URL normalization edge cases', () => {
test('handles explicit default port 443 for HTTPS', () => {
const patterns = parse_allowed_origins('https://example.com:443');

// The pattern explicitly includes :443
Expand All @@ -958,6 +966,15 @@ describe('normalize_origin', () => {
expect(should_allow_origin('https://example.com', patterns)).toBe(false);
});

test('handles explicit default port 80 for HTTP', () => {
const patterns = parse_allowed_origins('http://example.com:80');

// The pattern explicitly includes :80
expect(should_allow_origin('http://example.com:80', patterns)).toBe(true);
// Without the port, it won't match (we don't normalize)
expect(should_allow_origin('http://example.com', patterns)).toBe(false);
});

test('preserves non-standard ports', () => {
const patterns = parse_allowed_origins('https://example.com:8443');

Expand Down