feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
Five items of Pouya's production run, 2026-09-01.
Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.
Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.
Four launch blockers found by reading the running system:
- handler.mjs wrote pk/sk; the live table's key is submissionId with no
sort key, so every submission would have failed validation silently
- the Lambda invoke permission is scoped to the old route path
- 22 of 23 pages 403 without the router function
- there was no 404 page; src/pages/404.astro adds it
Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.
Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca1c2524e1
commit
bd282aa47d
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Applies the three distribution changes the site needs, as one reviewable
|
||||
* transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it.
|
||||
*
|
||||
* 1. FunctionAssociations on the default behaviour -> `router.js`, viewer
|
||||
* request. Without it 22 of 23 pages return S3's AccessDenied XML.
|
||||
* 2. CustomErrorResponses: 404 -> /404.html with response code 404.
|
||||
* `docs/04` requires a genuine 404 status; `docs/06` calls a 200 here
|
||||
* "the single most common misconfiguration in this stack".
|
||||
* 3. A `/api/*` cache behaviour on a new origin pointing at the HTTP API, so
|
||||
* the intake form's same-origin POST reaches the handler.
|
||||
*
|
||||
* ⚠️ DRY RUN BY DEFAULT. It prints what it would change and exits 0 without
|
||||
* calling `update-distribution`. `--apply` is the only thing that writes, and it
|
||||
* sends the `IfMatch` ETag it read, so a concurrent console edit fails the call
|
||||
* rather than being overwritten.
|
||||
*
|
||||
* ⚠️ IDEMPOTENT ON PURPOSE. Every change is checked for before it is made, so a
|
||||
* re-run after a partial failure completes the rest instead of adding a second
|
||||
* `/api/*` behaviour. Re-running a runbook step is the normal case, not the
|
||||
* exception.
|
||||
*
|
||||
* ⚠️ NO MANAGED POLICY ID IS WRITTEN IN THIS FILE. They are resolved by name
|
||||
* from the account at run time — `CLAUDE.md`'s rule that a pin is verified
|
||||
* against the registry rather than recalled applies to an AWS identifier just
|
||||
* as much as to an npm version, and a wrong cache-policy id here would ship a
|
||||
* cached POST endpoint.
|
||||
*
|
||||
* usage:
|
||||
* node infra/cloudfront/configure.mjs --dist <id> --api-domain <host> [--function-arn <arn>]
|
||||
* node infra/cloudfront/configure.mjs ... --apply
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i === -1 ? undefined : args[i + 1];
|
||||
};
|
||||
const APPLY = args.includes('--apply');
|
||||
|
||||
const DIST = flag('dist');
|
||||
const API_DOMAIN = flag('api-domain');
|
||||
const FUNCTION_ARN = flag('function-arn');
|
||||
|
||||
if (!DIST || !API_DOMAIN) {
|
||||
console.error(
|
||||
'usage: node infra/cloudfront/configure.mjs --dist <distribution-id> ' +
|
||||
'--api-domain <api-id>.execute-api.<region>.amazonaws.com ' +
|
||||
'[--function-arn <router-function-arn>] [--apply]',
|
||||
);
|
||||
console.error('Values come from AGENTS.md §7.');
|
||||
process.exit(2);
|
||||
}
|
||||
if (/^https?:/.test(API_DOMAIN) || API_DOMAIN.includes('/')) {
|
||||
console.error(
|
||||
`--api-domain must be a bare hostname, not a URL: got ${API_DOMAIN}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* stderr is NEVER suppressed and the exit status is always read — the AWS CLI
|
||||
reports an expired session, a missing permission and a typo'd id all on
|
||||
stderr with a non-zero status, and swallowing that is how "it failed" becomes
|
||||
"it found nothing" (CLAUDE.md, from AGENTS.md Q22). */
|
||||
const aws = (argv) => {
|
||||
const out = execFileSync('aws', argv, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return out.trim() === '' ? null : JSON.parse(out);
|
||||
};
|
||||
|
||||
const ORIGIN_ID = 'intake-api';
|
||||
const PATH_PATTERN = '/api/*';
|
||||
const ERROR_PAGE = '/404.html';
|
||||
|
||||
function managedId(kind, name) {
|
||||
const listCmd = {
|
||||
cache: ['list-cache-policies', 'CachePolicyList', 'CachePolicy'],
|
||||
origreq: [
|
||||
'list-origin-request-policies',
|
||||
'OriginRequestPolicyList',
|
||||
'OriginRequestPolicy',
|
||||
],
|
||||
}[kind];
|
||||
const res = aws([
|
||||
'cloudfront',
|
||||
listCmd[0],
|
||||
'--type',
|
||||
'managed',
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
const items = res?.[listCmd[1]]?.Items ?? [];
|
||||
const hit = items.find(
|
||||
(i) => i[listCmd[2]][`${listCmd[2]}Config`].Name === name,
|
||||
);
|
||||
if (!hit) {
|
||||
throw new Error(
|
||||
`no managed ${kind} policy named ${name} — ${items.length} listed. ` +
|
||||
'Do not substitute an id from memory.',
|
||||
);
|
||||
}
|
||||
return hit[listCmd[2]].Id;
|
||||
}
|
||||
|
||||
const cachingDisabled = managedId('cache', 'Managed-CachingDisabled');
|
||||
/* AllViewerExceptHostHeader, and the exception is the whole reason: API Gateway
|
||||
routes on the Host header, so forwarding the viewer's `adr.smlcompany.ca`
|
||||
makes every request a 403 from the API. It forwards everything else, which is
|
||||
what carries `Origin` and `Referer` — the handler's CSRF control reads both,
|
||||
so a policy that dropped them would turn every real submission into a 403. */
|
||||
const allViewerExceptHost = managedId(
|
||||
'origreq',
|
||||
'Managed-AllViewerExceptHostHeader',
|
||||
);
|
||||
console.log(`resolved Managed-CachingDisabled = ${cachingDisabled}`);
|
||||
console.log(
|
||||
`resolved Managed-AllViewerExceptHostHeader = ${allViewerExceptHost}`,
|
||||
);
|
||||
|
||||
const current = aws([
|
||||
'cloudfront',
|
||||
'get-distribution-config',
|
||||
'--id',
|
||||
DIST,
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
const etag = current.ETag;
|
||||
const cfg = current.DistributionConfig;
|
||||
if (!etag || !cfg) throw new Error('could not read the distribution config');
|
||||
|
||||
const changes = [];
|
||||
|
||||
/* ---- 1. viewer-request function on the default behaviour ---------------- */
|
||||
if (FUNCTION_ARN) {
|
||||
const fa = cfg.DefaultCacheBehavior.FunctionAssociations ?? { Quantity: 0 };
|
||||
const existing = (fa.Items ?? []).filter(
|
||||
(i) => i.EventType === 'viewer-request',
|
||||
);
|
||||
if (existing.length === 1 && existing[0].FunctionARN === FUNCTION_ARN) {
|
||||
console.log(
|
||||
'· default behaviour already runs this function on viewer-request',
|
||||
);
|
||||
} else {
|
||||
const items = (fa.Items ?? []).filter(
|
||||
(i) => i.EventType !== 'viewer-request',
|
||||
);
|
||||
items.push({ EventType: 'viewer-request', FunctionARN: FUNCTION_ARN });
|
||||
cfg.DefaultCacheBehavior.FunctionAssociations = {
|
||||
Quantity: items.length,
|
||||
Items: items,
|
||||
};
|
||||
changes.push(
|
||||
`DefaultCacheBehavior.FunctionAssociations viewer-request -> ${FUNCTION_ARN}` +
|
||||
(existing.length ? ` (replacing ${existing[0].FunctionARN})` : ''),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('· no --function-arn given, leaving FunctionAssociations alone');
|
||||
}
|
||||
|
||||
/* ---- 2. custom error response ------------------------------------------- */
|
||||
/* ⚠️ ONLY 404 IS MAPPED, NOT 403, AND THAT IS DELIBERATE. Mapping 403 as well
|
||||
would swallow two different real failures: a broken bucket policy or OAC
|
||||
would render as "page not found" on every URL at once, and the intake
|
||||
handler's Origin refusal (a 403 from the API origin) would come back as a 404
|
||||
page. Custom error responses are distribution-wide — they cannot be scoped to
|
||||
one behaviour — so the fix for missing keys is on the S3 side instead:
|
||||
granting the OAC principal `s3:ListBucket` makes S3 answer 404 NoSuchKey
|
||||
rather than 403 AccessDenied. Runbook Part 1 does that first, and its
|
||||
verification step is what proves this mapping is reached. */
|
||||
const cer = cfg.CustomErrorResponses ?? { Quantity: 0, Items: [] };
|
||||
const cerItems = cer.Items ?? [];
|
||||
const has404 = cerItems.some(
|
||||
(i) =>
|
||||
i.ErrorCode === 404 &&
|
||||
i.ResponsePagePath === ERROR_PAGE &&
|
||||
String(i.ResponseCode) === '404',
|
||||
);
|
||||
if (has404) {
|
||||
console.log('· 404 -> /404.html (404) already configured');
|
||||
} else {
|
||||
/* Report a REPLACEMENT as a replacement. This branch filters out any existing
|
||||
404 mapping, so on a distribution that maps 404 to a different page the
|
||||
operator would otherwise be told a mapping was "added" while one was
|
||||
silently changed — and Part 3 tells them to carry on when the change count is
|
||||
lower than expected. The function-association branch above already names
|
||||
what it replaces; this one did not. */
|
||||
const replaced = cerItems.find((i) => i.ErrorCode === 404);
|
||||
const items = cerItems.filter((i) => i.ErrorCode !== 404);
|
||||
items.push({
|
||||
ErrorCode: 404,
|
||||
ResponsePagePath: ERROR_PAGE,
|
||||
ResponseCode: '404',
|
||||
/* Short, not zero. A 404 is cheap to re-fetch and this is the value that
|
||||
decides how long a genuinely-missing URL keeps 404ing after the page it
|
||||
should have been is deployed. */
|
||||
ErrorCachingMinTTL: 10,
|
||||
});
|
||||
cfg.CustomErrorResponses = { Quantity: items.length, Items: items };
|
||||
changes.push(
|
||||
replaced
|
||||
? `CustomErrorResponses 404 -> ${ERROR_PAGE} with status 404 (REPLACING ` +
|
||||
`${replaced.ResponsePagePath} with status ${replaced.ResponseCode})`
|
||||
: `CustomErrorResponses += 404 -> ${ERROR_PAGE} with status 404`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 3. the /api/* origin and behaviour --------------------------------- */
|
||||
const origins = cfg.Origins.Items ?? [];
|
||||
if (origins.some((o) => o.Id === ORIGIN_ID)) {
|
||||
console.log(`· origin ${ORIGIN_ID} already present`);
|
||||
} else {
|
||||
origins.push({
|
||||
Id: ORIGIN_ID,
|
||||
DomainName: API_DOMAIN,
|
||||
OriginPath: '',
|
||||
CustomHeaders: { Quantity: 0 },
|
||||
CustomOriginConfig: {
|
||||
HTTPPort: 80,
|
||||
HTTPSPort: 443,
|
||||
/* https-only to the origin. The API is public over TLS and there is no
|
||||
reason for a leg of this in plaintext. */
|
||||
OriginProtocolPolicy: 'https-only',
|
||||
OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] },
|
||||
OriginReadTimeout: 30,
|
||||
OriginKeepaliveTimeout: 5,
|
||||
},
|
||||
ConnectionAttempts: 3,
|
||||
ConnectionTimeout: 10,
|
||||
OriginShield: { Enabled: false },
|
||||
});
|
||||
cfg.Origins = { Quantity: origins.length, Items: origins };
|
||||
changes.push(
|
||||
`Origins += ${ORIGIN_ID} -> ${API_DOMAIN} (https-only, TLSv1.2)`,
|
||||
);
|
||||
}
|
||||
|
||||
const behaviours = cfg.CacheBehaviors?.Items ?? [];
|
||||
if (behaviours.some((b) => b.PathPattern === PATH_PATTERN)) {
|
||||
console.log(`· cache behaviour ${PATH_PATTERN} already present`);
|
||||
} else {
|
||||
behaviours.push({
|
||||
PathPattern: PATH_PATTERN,
|
||||
TargetOriginId: ORIGIN_ID,
|
||||
ViewerProtocolPolicy: 'https-only',
|
||||
/* POST is the one that matters; the rest are here because CloudFront only
|
||||
offers the three fixed method sets and this is the set containing POST. */
|
||||
AllowedMethods: {
|
||||
Quantity: 7,
|
||||
Items: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'],
|
||||
CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] },
|
||||
},
|
||||
CachePolicyId: cachingDisabled,
|
||||
OriginRequestPolicyId: allViewerExceptHost,
|
||||
Compress: false,
|
||||
SmoothStreaming: false,
|
||||
FieldLevelEncryptionId: '',
|
||||
/* NO FUNCTION ASSOCIATION, AND THE OMISSION IS LOAD-BEARING. `router.js`
|
||||
would 301 `/api/intake` to `/api/intake/`, and a 301 turns a POST into a
|
||||
GET — the submission body would be dropped with a 200 at the end of it.
|
||||
`infra/cloudfront/router.test.mjs` carries that case as documentation. */
|
||||
FunctionAssociations: { Quantity: 0 },
|
||||
LambdaFunctionAssociations: { Quantity: 0 },
|
||||
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
|
||||
});
|
||||
cfg.CacheBehaviors = { Quantity: behaviours.length, Items: behaviours };
|
||||
changes.push(
|
||||
`CacheBehaviors += ${PATH_PATTERN} -> ${ORIGIN_ID}, CachingDisabled, AllViewerExceptHostHeader, POST allowed`,
|
||||
);
|
||||
}
|
||||
|
||||
/* CloudFront matches cache behaviours in order and the FIRST match wins, so a
|
||||
`/api/*` behaviour placed after a hypothetical `/*` one would never be
|
||||
reached. There is no `/*` behaviour today — the default behaviour is the
|
||||
catch-all and is not part of this list — but assert it rather than assume it. */
|
||||
const catchAll = (cfg.CacheBehaviors?.Items ?? []).findIndex(
|
||||
(b) => b.PathPattern === '*' || b.PathPattern === '/*',
|
||||
);
|
||||
const apiIndex = (cfg.CacheBehaviors?.Items ?? []).findIndex(
|
||||
(b) => b.PathPattern === PATH_PATTERN,
|
||||
);
|
||||
if (catchAll !== -1 && catchAll < apiIndex) {
|
||||
throw new Error(
|
||||
`a catch-all behaviour at index ${catchAll} precedes ${PATH_PATTERN} at ${apiIndex} — ` +
|
||||
'the API behaviour would never match. Reorder before applying.',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (changes.length === 0) {
|
||||
console.log(
|
||||
'NOTHING TO CHANGE — the distribution already carries all three.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(
|
||||
`${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`,
|
||||
);
|
||||
for (const c of changes) console.log(` + ${c}`);
|
||||
console.log('');
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const res = aws([
|
||||
'cloudfront',
|
||||
'update-distribution',
|
||||
'--id',
|
||||
DIST,
|
||||
'--if-match',
|
||||
etag,
|
||||
'--distribution-config',
|
||||
JSON.stringify(cfg),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
console.log(
|
||||
`APPLIED. Status=${res.Distribution.Status} ETag=${res.ETag}\n` +
|
||||
'CloudFront takes a few minutes to deploy. Wait for Deployed, then run the ' +
|
||||
"runbook's verification block:\n" +
|
||||
` aws cloudfront wait distribution-deployed --id ${DIST}`,
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* CloudFront Function, VIEWER REQUEST, on the default cache behaviour only.
|
||||
*
|
||||
* ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets
|
||||
* `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is
|
||||
* `<dir>/index.html`. CloudFront forwards the viewer path to the S3 REST origin
|
||||
* unchanged, S3 has no key `about/`, and the request fails. Measured on the live
|
||||
* distribution 2026-09-01, before this function existed: `/about/` and
|
||||
* `/definitely-not-a-page/` both returned **403 with an 111-byte
|
||||
* `application/xml` body** — S3's AccessDenied, served raw to the reader. Only
|
||||
* `/` worked, via the distribution's default root object. That is 22 of the 23
|
||||
* pages.
|
||||
*
|
||||
* ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path
|
||||
* `/api/intake` has no extension and no trailing slash, so the redirect branch
|
||||
* below would answer a form POST with a 301 — and a 301 turns a POST into a GET,
|
||||
* which would lose the submission body silently. The association is per
|
||||
* behaviour and `/api/*` gets none.
|
||||
*
|
||||
* Two rules, and the second is a `docs/04` requirement rather than a nicety:
|
||||
*
|
||||
* /about/ -> rewrite to /about/index.html (the origin has that key)
|
||||
* /about -> 301 to /about/ (one canonical URL per page)
|
||||
*
|
||||
* Anything with a file extension in its last segment is left alone —
|
||||
* `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`,
|
||||
* `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself.
|
||||
*
|
||||
* Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no
|
||||
* arrow functions, no `String.prototype.endsWith`, no template literals. The
|
||||
* runtime supports more than this; a viewer-request function runs on every
|
||||
* request to the site and is the wrong place to be clever.
|
||||
*/
|
||||
/* The header-injection surface, and nothing else: C0 controls, DEL, space, and
|
||||
WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it
|
||||
changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into
|
||||
a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them
|
||||
raw and `|` is routine in tracking values. Strip rather than encode — these
|
||||
values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */
|
||||
function safe(part) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, '');
|
||||
}
|
||||
|
||||
function handler(event) {
|
||||
var request = event.request;
|
||||
|
||||
/* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are
|
||||
collapsed because CloudFront forwards duplicate slashes verbatim (it resolves
|
||||
dot-segments; it does not collapse `//`) and `Location: //host/x` is a
|
||||
network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does
|
||||
the same, because the URL Standard maps it to `/` in special schemes.
|
||||
Redirect rather than rewrite, or `//about/` serves the About page at a second
|
||||
URL with a 200. Only the leading run: an interior `//` is a key that does not
|
||||
exist. */
|
||||
var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/');
|
||||
var normalised = uri !== request.uri;
|
||||
var lastSlash = uri.lastIndexOf('/');
|
||||
var lastSegment = uri.substring(lastSlash + 1);
|
||||
|
||||
// A file, not a route.
|
||||
if (lastSegment.indexOf('.') !== -1) {
|
||||
if (normalised) return moved(uri, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
// A directory-style route: hand the origin the key it actually holds.
|
||||
if (lastSegment === '') {
|
||||
if (normalised) return moved(uri, request);
|
||||
request.uri = uri + 'index.html';
|
||||
return request;
|
||||
}
|
||||
|
||||
/* Extensionless and no trailing slash. Redirect rather than rewrite, so the
|
||||
page has ONE address: serving it at both would put two indexable URLs on the
|
||||
same content, which `docs/04` treats as its primary concern. */
|
||||
return moved(uri + '/', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 301 to a path on this origin, carrying the query string. `location` is always
|
||||
* built from an already-normalised path, which is what keeps it same-origin.
|
||||
*/
|
||||
function moved(path, request) {
|
||||
var qs = '';
|
||||
var names = Object.keys(request.querystring);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var name = names[i];
|
||||
var value = request.querystring[name];
|
||||
if (value.multiValue) {
|
||||
for (var j = 0; j < value.multiValue.length; j++) {
|
||||
qs +=
|
||||
(qs === '' ? '' : '&') +
|
||||
safe(name) +
|
||||
'=' +
|
||||
safe(value.multiValue[j].value);
|
||||
}
|
||||
} else {
|
||||
/* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather
|
||||
than the function guessing which the viewer meant. */
|
||||
qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
statusCode: 301,
|
||||
statusDescription: 'Moved Permanently',
|
||||
headers: {
|
||||
location: { value: path + (qs === '' ? '' : '?' + qs) },
|
||||
'cache-control': { value: 'public, max-age=0, must-revalidate' },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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"><x' }, utm: { value: 'a%20b' } },
|
||||
{ status: 301, location: '/fees/?q=abx&utm=a%20b' },
|
||||
],
|
||||
[
|
||||
'/fees',
|
||||
{ evil: { value: 'x\r\nSet-Cookie: a=b' } },
|
||||
{ status: 301, location: '/fees/?evil=xSet-Cookie:a=b' },
|
||||
],
|
||||
/* `#` changes the STRUCTURE of the Location — without stripping it, `&b=y`
|
||||
lands in a fragment and the parameter is silently lost. */
|
||||
[
|
||||
'/fees',
|
||||
{ a: { value: 'x#&b=y' } },
|
||||
{ status: 301, location: '/fees/?a=x&b=y' },
|
||||
],
|
||||
/* ⚠️ AND THESE MUST SURVIVE. `| ^ ` { }` are not in WHATWG's query
|
||||
percent-encode set, so a browser sends them raw — and `|` is routine in
|
||||
ad-platform tracking values. One revision of `safe()` stripped all of them,
|
||||
silently corrupting exactly the campaign links the 301 exists to preserve. */
|
||||
[
|
||||
'/fees',
|
||||
{ utm_content: { value: 'banner|top' }, k: { value: 'a{b}c^d`e' } },
|
||||
{ status: 301, location: '/fees/?utm_content=banner|top&k=a{b}c^d`e' },
|
||||
],
|
||||
// multiValue, which no case exercised before.
|
||||
[
|
||||
'/fees',
|
||||
{ tag: { value: 'a', multiValue: [{ value: 'a' }, { value: 'b' }] } },
|
||||
{ status: 301, location: '/fees/?tag=a&tag=b' },
|
||||
],
|
||||
/* /api/intake must NEVER be redirected — a 301 turns a POST into a GET and
|
||||
the submission body is gone. This function is not associated with the
|
||||
/api/* behaviour, so this case documents WHY the association matters: if it
|
||||
ever were associated, this is the damage. */
|
||||
['/api/intake', {}, { status: 301, location: '/api/intake/' }],
|
||||
];
|
||||
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
for (const [uri, qs, expected] of CASES) {
|
||||
const out = handler(req(uri, qs));
|
||||
let actual;
|
||||
if (out.statusCode) {
|
||||
actual = { status: out.statusCode, location: out.headers.location.value };
|
||||
} else {
|
||||
actual = { uri: out.uri };
|
||||
}
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) pass += 1;
|
||||
else
|
||||
failures.push(
|
||||
`${uri} -> ${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);
|
||||
Reference in New Issue
Block a user