All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.
D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.
D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:
* Astro does not hash <script is:inline>, and Starlight ships six per docs
page, so the first build with CSP on had a strict header and a dead theme
switcher. The hashes are now generated into src/config/cspHashes.mjs and
checkCsp.mjs verifies every inline block against its own page's policy.
* Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
script-src is untouched.
* @astrojs/node matched a request to a policy with pathname.includes(), a
substring test: /modules/ was served /docs/modules/building-a-module's
policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
the same _headers.json and matches by equality; test/headers.test.mjs starts
the server and reads the responses, because nothing that reads dist/ can see
this.
D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.
checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.
npm run verify is green: fourteen steps, both test suites, all eleven checks.
Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
6.8 KiB
JavaScript
146 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* serve.mjs — the production entry point (PLAN.md §6, D48).
|
|
*
|
|
* `npm start` runs `applyBrand.mjs` and then this, instead of `dist/server/entry.mjs`
|
|
* directly. It is a thin wrapper around the adapter's own handler and exists for two
|
|
* reasons, one of them a bug in a dependency.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* 1. THE ADAPTER SERVES THE WRONG PAGE'S CONTENT-SECURITY-POLICY
|
|
* ---------------------------------------------------------------------------------------
|
|
* `@astrojs/node`'s `staticHeaders` writes one policy per prerendered route into
|
|
* `dist/_headers.json` and looks the right one up per request. The lookup, in
|
|
* `dist/serve-static.js`, is:
|
|
*
|
|
* headersMap.find((header) => header.pathname.includes(baselessPathname))
|
|
*
|
|
* `String.includes` — a SUBSTRING test, not equality, taking the first match. So:
|
|
*
|
|
* - `/modules/` matches the record for `/docs/modules/building-a-module`,
|
|
* - `/architecture/` matches `/docs/architecture/...`,
|
|
* - and `/`, which is a substring of every path in the file, matches whichever record
|
|
* happens to be first — here `/404`.
|
|
*
|
|
* Every prerendered page was therefore served some other page's policy. Because the
|
|
* policies are per-page hash lists, that is not a cosmetic mismatch: the browser refused
|
|
* the page's own stylesheet. `/modules/` and `/architecture/` rendered unstyled sections
|
|
* with `Refused to apply inline style` in a console, and the homepage only looked fine
|
|
* because it happens to share a hash with the 404 page.
|
|
*
|
|
* Astro's static-header machinery is otherwise exactly what §6 wants, so this replaces the
|
|
* lookup rather than the mechanism: the same `_headers.json`, matched by pathname
|
|
* EQUALITY. The workaround is deliberately small and obvious so it can be deleted whole
|
|
* when the upstream `find` is fixed — the check for that is whether `/modules/` and
|
|
* `/docs/modules/building-a-module` are served different policies.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* 2. THE HEADERS THAT ARE NOT CSP
|
|
* ---------------------------------------------------------------------------------------
|
|
* A few security headers have nothing to do with Astro and no other place to live. They
|
|
* are set here rather than written into an operator's reverse-proxy configuration (D48,
|
|
* again): the container should be correct on its own, and a proxy someone else configures
|
|
* is a promise this repository cannot check.
|
|
*
|
|
* The two routes that render per request — `/beta` and `/brand/*` — have no entry in
|
|
* `_headers.json`, because nothing prerendered them. They get `frame-ancestors 'none'` on
|
|
* its own, which is the one directive a `<meta>` CSP cannot express and therefore the one
|
|
* thing Astro's per-page meta tag leaves them missing.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.join(here, '..');
|
|
|
|
const port = Number(process.env.PORT ?? 4321);
|
|
const host = process.env.HOST ?? '0.0.0.0';
|
|
|
|
/* ---------------------------------------------------------------------------------------
|
|
The policies, matched exactly
|
|
--------------------------------------------------------------------------------------- */
|
|
|
|
const normalise = (pathname) => {
|
|
const clean = pathname.split('?')[0].split('#')[0];
|
|
const trimmed = clean.replace(/\/+$/, '');
|
|
return trimmed === '' ? '/' : trimmed;
|
|
};
|
|
|
|
const policies = new Map();
|
|
const headersFile = path.join(root, 'dist', '_headers.json');
|
|
|
|
if (fs.existsSync(headersFile)) {
|
|
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
|
|
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
|
|
if (csp) policies.set(normalise(record.pathname), csp.value);
|
|
}
|
|
} else {
|
|
// Not fatal: the site still serves, with the per-page <meta> policy Astro also emits.
|
|
// Loud, because a deployment silently losing its response-header CSP is exactly what §6
|
|
// is trying to prevent.
|
|
console.error(
|
|
'[serve] dist/_headers.json is missing — pages will be served without a CSP response\n' +
|
|
' header. Check that astro.config.mjs still sets `staticHeaders: true`.'
|
|
);
|
|
}
|
|
|
|
const FRAME_ONLY = "frame-ancestors 'none'";
|
|
|
|
/**
|
|
* Headers with no page-by-page component. Each is the browser default made explicit, and
|
|
* each closes something the CSP does not:
|
|
*
|
|
* - `X-Content-Type-Options` stops a browser guessing that a .txt is HTML.
|
|
* - `Referrer-Policy` keeps the path of the page a reader came from out of requests to
|
|
* other origins — there are none today (D9), and this is what keeps that true if a
|
|
* link is ever followed off-site.
|
|
* - `X-Frame-Options` says again, for anything too old to honour `frame-ancestors`.
|
|
* - `Permissions-Policy` turns off hardware this site has no reason to ask for. A
|
|
* marketing page requesting a camera should be impossible, not merely unlikely.
|
|
*/
|
|
const STATIC_HEADERS = {
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
'X-Frame-Options': 'DENY',
|
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
};
|
|
|
|
/* ---------------------------------------------------------------------------------------
|
|
The server
|
|
--------------------------------------------------------------------------------------- */
|
|
|
|
// The adapter's entry starts its own listener on import unless this is set.
|
|
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
|
|
|
|
// `pathToFileURL`, not the bare path: on Windows an absolute path starts with a drive
|
|
// letter, and Node's ESM loader reads `c:` as an unsupported URL scheme.
|
|
const { handler } = await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry.mjs')).href);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const policy = policies.get(normalise(req.url ?? '/'));
|
|
|
|
for (const [key, value] of Object.entries(STATIC_HEADERS)) res.setHeader(key, value);
|
|
res.setHeader('Content-Security-Policy', policy ?? FRAME_ONLY);
|
|
|
|
/**
|
|
* The adapter will set its own (wrong) `Content-Security-Policy` from inside the static
|
|
* handler, overwriting what was just set. Rather than race it, every later attempt to
|
|
* set that one header is ignored — the correct value is already on the response, and
|
|
* this request's policy cannot change halfway through serving it.
|
|
*/
|
|
const setHeader = res.setHeader.bind(res);
|
|
res.setHeader = (name, value) => {
|
|
if (String(name).toLowerCase() === 'content-security-policy') return res;
|
|
return setHeader(name, value);
|
|
};
|
|
|
|
handler(req, res);
|
|
});
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`[serve] listening on http://${host}:${port} — ${policies.size} prerendered policies`);
|
|
});
|