diff --git a/.changeset/dry-hornets-change.md b/.changeset/dry-hornets-change.md new file mode 100644 index 000000000000..12d873587ebf --- /dev/null +++ b/.changeset/dry-hornets-change.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': minor +--- + +validate ORIGIN env var at startup diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index b1a40bb19955..fd25fddf50c2 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -9,13 +9,22 @@ import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/nod import { Server } from 'SERVER'; import { manifest, prerendered, base } from 'MANIFEST'; import { env } from 'ENV'; -import { parse_as_bytes } from '../utils.js'; +import { parse_as_bytes, parse_origin } from '../utils.js'; /* global ENV_PREFIX */ const server = new Server(manifest); -const origin = env('ORIGIN', undefined); +const origin = parse_origin(env('ORIGIN', undefined)); + +if (origin === undefined && env('ORIGIN', undefined) !== undefined) { + throw new Error( + `Invalid ORIGIN: '${env('ORIGIN', undefined)}'. ` + + `ORIGIN must be a valid URL with http:// or https:// protocol. ` + + `For example: 'http://localhost:3000' or 'https://my.site'` + ); +} + const xff_depth = parseInt(env('XFF_DEPTH', '1')); const address_header = env('ADDRESS_HEADER', '').toLowerCase(); const protocol_header = env('PROTOCOL_HEADER', '').toLowerCase(); diff --git a/packages/adapter-node/tests/utils.spec.js b/packages/adapter-node/tests/utils.spec.js index 06a495fde9ba..0ffccd315e62 100644 --- a/packages/adapter-node/tests/utils.spec.js +++ b/packages/adapter-node/tests/utils.spec.js @@ -1,5 +1,5 @@ import { expect, test, describe } from 'vitest'; -import { parse_as_bytes } from '../utils.js'; +import { parse_as_bytes, parse_origin } from '../utils.js'; describe('parse_as_bytes', () => { test('parses correctly', () => { @@ -19,3 +19,47 @@ describe('parse_as_bytes', () => { }); }); }); + +describe('parse_origin', () => { + test('valid origins return normalized origin', () => { + const testCases = [ + { input: 'http://localhost:3000', expected: 'http://localhost:3000' }, + { input: 'https://example.com', expected: 'https://example.com' }, + { input: 'http://192.168.1.1:8080', expected: 'http://192.168.1.1:8080' }, + { input: 'https://my-site.com', expected: 'https://my-site.com' }, + { input: 'http://localhost', expected: 'http://localhost' }, + { input: 'https://example.com:443', expected: 'https://example.com' }, + { input: 'http://example.com:80', expected: 'http://example.com' }, + { input: undefined, expected: undefined } + ]; + + testCases.forEach(({ input, expected }) => { + const actual = parse_origin(input); + expect(actual, `Testing input '${input}'`).toBe(expected); + }); + }); + + test('URLs with path/query/hash are normalized to origin', () => { + const testCases = [ + { input: 'http://localhost:3000/path', expected: 'http://localhost:3000' }, + { input: 'http://localhost:3000?query=1', expected: 'http://localhost:3000' }, + { input: 'http://localhost:3000#hash', expected: 'http://localhost:3000' }, + { input: 'https://example.com/path/to/page', expected: 'https://example.com' }, + { input: 'https://example.com:443/path?query=1#hash', expected: 'https://example.com' } + ]; + + testCases.forEach(({ input, expected }) => { + const actual = parse_origin(input); + expect(actual, `Testing input '${input}'`).toBe(expected); + }); + }); + + test('invalid origins return undefined', () => { + const invalidInputs = ['localhost:3000', 'example.com', '', ' ', 'ftp://localhost:3000']; + + invalidInputs.forEach((input) => { + const actual = parse_origin(input); + expect(actual, `Testing input '${input}'`).toBeUndefined(); + }); + }); +}); diff --git a/packages/adapter-node/utils.js b/packages/adapter-node/utils.js index 16769eaa0027..234f6a7e888f 100644 --- a/packages/adapter-node/utils.js +++ b/packages/adapter-node/utils.js @@ -13,3 +13,34 @@ export function parse_as_bytes(value) { }[value[value.length - 1]?.toUpperCase()] ?? 1; return Number(multiplier != 1 ? value.substring(0, value.length - 1) : value) * multiplier; } + +/** + * Parses and validates an origin URL. + * + * @param {string | undefined} value - Origin URL with http:// or https:// protocol + * @returns {string | undefined} The validated origin, or undefined if value is undefined + */ +export function parse_origin(value) { + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + + if (trimmed === '') { + return undefined; + } + + try { + const url = new URL(trimmed); + + // Verify protocol is http or https + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return undefined; + } + + return url.origin; + } catch { + return undefined; + } +}