/** * Unit test for the viewer-request router. `node infra/cloudfront/router.test.mjs`. * * The function file cannot use module syntax — CloudFront's runtime has no * `export` — so it is read and evaluated rather than imported. `aws cloudfront * test-function` is the authoritative check because it runs the real runtime; * this one runs in a second, catches the branch mistakes, and costs nothing. */ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); const src = readFileSync(join(here, 'router.js'), 'utf8'); const handler = new Function(`${src}; return handler;`)(); const req = (uri, querystring = {}) => ({ request: { uri, querystring } }); const CASES = [ // [uri, querystring, expected] — expected is {uri} for a rewrite/passthrough // or {status, location} for a redirect. ['/', {}, { uri: '/index.html' }], ['/about/', {}, { uri: '/about/index.html' }], ['/practice/construction/', {}, { uri: '/practice/construction/index.html' }], ['/contact/received/', {}, { uri: '/contact/received/index.html' }], ['/about', {}, { status: 301, location: '/about/' }], ['/practice/energy', {}, { status: 301, location: '/practice/energy/' }], // Files are untouched — every one of these is a real object in dist/. ['/robots.txt', {}, { uri: '/robots.txt' }], ['/sitemap-index.xml', {}, { uri: '/sitemap-index.xml' }], ['/404.html', {}, { uri: '/404.html' }], ['/favicon.ico', {}, { uri: '/favicon.ico' }], ['/pouya-lajevardi-bio.pdf', {}, { uri: '/pouya-lajevardi-bio.pdf' }], ['/_astro/schema.Cm5su60K.css', {}, { uri: '/_astro/schema.Cm5su60K.css' }], ['/og/mediation.jpg', {}, { uri: '/og/mediation.jpg' }], // The query string survives the redirect, normalised to `name=value`. [ '/fees', { utm_source: { value: 'linkedin' }, ref: { value: '' } }, { status: 301, location: '/fees/?utm_source=linkedin&ref=' }, ], /* ⚠️ THE OPEN-REDIRECT CASES. CloudFront forwards duplicate leading slashes verbatim (it collapses dot-segments but not `//`), so without normalisation `//evil.example.com/x` produced `Location: //evil.example.com/x/` — a network-path reference that sends the viewer to another host from this domain's own URL. The backslash form defeats a `startsWith('//')` guard, because the URL Standard maps `\` to `/` in special schemes. Both must stay same-origin, and both must keep a SINGLE leading slash. */ [ '//evil.example.com/x', {}, { status: 301, location: '/evil.example.com/x/' }, ], [ '///evil.example.com/x', {}, { status: 301, location: '/evil.example.com/x/' }, ], [ '/\\evil.example.com/x', {}, { status: 301, location: '/evil.example.com/x/' }, ], /* ⚠️ A NORMALISED PATH IS REDIRECTED, NOT REWRITTEN — this asserted a 200 for one revision, which closed the redirect and opened an unbounded family of duplicate URLs for every page on the site. */ [ '//evil.example.com/x/', {}, { status: 301, location: '/evil.example.com/x/' }, ], ['//about/', {}, { status: 301, location: '/about/' }], ['///about/', {}, { status: 301, location: '/about/' }], ['/\\about/', {}, { status: 301, location: '/about/' }], /* A file is normalised too. This branch returned `request` untouched for one revision, so `//robots.txt` reached S3 with the doubled slash and 404'd. */ ['//robots.txt', {}, { status: 301, location: '/robots.txt' }], ['/\\robots.txt', {}, { status: 301, location: '/robots.txt' }], /* An interior `//` is left alone on purpose: it is a key that does not exist, so it resolves to the 404 page. Only the leading run is a security question. */ ['/a//b/', {}, { uri: '/a//b/index.html' }], /* Header-injection surface: CR, LF, space and the delimiters browsers disagree about are stripped rather than re-encoded — an already-encoded value must not be encoded twice. `%20` therefore passes through untouched. */ [ '/fees', { q: { value: 'a b"> ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, ); } if (pass + failures.length !== CASES.length) { throw new Error(`case count ${pass + failures.length} != ${CASES.length}`); } console.log(`router: ${pass} of ${CASES.length} cases pass`); for (const f of failures) console.error(' FAIL ' + f); if (failures.length > 0) process.exit(1);