diff --git a/README.md b/README.md index 8148ad8..2112d86 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,21 @@ and connections from this site, plus the approved inline script hash. Other inli JavaScript, embedded frames, plugins, and form submissions are blocked. Inline styles remain allowed for React and animation styles. Blob images remain allowed for local pond textures. -The preview server also sends `X-Content-Type-Options`, `X-Frame-Options`, and -`Referrer-Policy`. These server headers apply to local previews. GitHub Pages -serves the static export through the public Cloudflare edge. -The sampled public responses lack those headers. In particular, public frame -embedding restrictions require a response header at the hosting layer; an HTML -meta policy cannot enforce `frame-ancestors`. See the -[public header evidence and hosting decision](docs/security-headers.md) for the -verified gap, the policy to preserve, and the work that needs a hosting decision. +The local preview sends the full CSP from `scripts/security-policy.mjs` plus +`frame-ancestors 'none'`, `X-Frame-Options: DENY`, +`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. +It runs over HTTP and sends no HSTS header. + +GitHub Pages serves the static export through Cloudflare. On 9 November 2026 at +09:04:41 UTC, fresh public GET and HEAD samples for `/` and `/privacy` returned +`Content-Security-Policy: frame-ancestors 'none'`, `X-Frame-Options: DENY`, +`X-Content-Type-Options: nosniff`, and +`Strict-Transport-Security: max-age=86400`. Both pages retained the full meta CSP. +Production therefore has a response CSP limited to framing plus the full meta +policy, while preview also sends the full policy as a response header. +The QA report did not record production `Referrer-Policy`, so response header parity +with preview's `no-referrer` is unverified. See the [dated public header evidence and policy +differences](docs/security-headers.md) for earlier observations and coverage limits. Pull requests run dependency auditing, lint, unit tests, and the production browser suite. Deployment actions use pinned commits, and only the deployment job receives diff --git a/docs/security-headers.md b/docs/security-headers.md index 328f025..67fdb14 100644 --- a/docs/security-headers.md +++ b/docs/security-headers.md @@ -1,10 +1,80 @@ # Public response headers -Issue [#88](https://github.com/luinbytes/luinbytes.dev/issues/88) remains open for -production hardening. This document records the current limit and the hosting -decision needed to meet its acceptance criteria. It does not change hosting. - -## Verified public behaviour +This document records the preview and production policy differences for +issue [#88](https://github.com/luinbytes/luinbytes.dev/issues/88). +Updated 9 November 2026 after Lu reported enabling Cloudflare protections. +This documentation change does not change browser policy, hosting, or security +configuration, and does not establish that every issue acceptance check is complete. + +## Current production observations + +At 09:04:41 UTC on 9 November 2026, independent QA used browser fetches with +`cache: 'no-store'` against `https://luinbytes.dev/` and +`https://luinbytes.dev/privacy`. All four GET and HEAD responses returned 200 and +`Content-Type: text/html; charset=utf-8`, with these headers: + +| Header | Value on both routes and methods | +| --- | --- | +| Content-Security-Policy | `frame-ancestors 'none'` | +| X-Frame-Options | `DENY` | +| X-Content-Type-Options | `nosniff` | +| Strict-Transport-Security | `max-age=86400` | + +Both pages' meta CSP values exactly matched `scripts/security-policy.mjs` at +[deployed commit 7cfa8a7](https://github.com/luinbytes/luinbytes.dev/blob/7cfa8a7111484e3c3d785e5103b9447439958c57/scripts/security-policy.mjs). +QA also observed the browser block an injected Cloudflare Insights beacon under +`script-src`. The existing policy was not relaxed for that script. +These are QA observations, not a Cloudflare account or rule inventory. + +The first default cache homepage fetch lacked HSTS. Subsequent fresh GET and HEAD +samples included it on both routes. The initial omission did not recur in fresh +requests, but these samples do not prove coverage of all cached responses. + +At 09:07 UTC, QA repeated GET and HEAD for `/404` and +`/header-check-nonexistent-20261109`. The former returned 200 and the latter 404. +All four responses had the same headers listed above, and both GET bodies retained +the exact full meta CSP. The `/404` status remains outside this header task. + +QA also attempted ordinary home and privacy iframes from a controlled local HTTP +page. The browser refused both under `frame-ancestors 'none'`. +At 09:07:05 UTC, default trusted TLS handshakes with hostname validation passed for +the apex and `www`. Both certificates covered the apex and wildcard subdomains, +with validity from 26 October 2026 through 24 January 2027. This establishes current +trust and hostname coverage, not future renewal or an inventory of other subdomains. + +## Preview and production differences + +The preview values below come from `scripts/serve-preview.mjs` and +`scripts/security-policy.mjs` at the same deployed commit. Production values come +from the dated QA observations above. + +| Policy | Local HTTP preview | Sampled production HTTPS | +| --- | --- | --- | +| HTML meta CSP | Full `contentSecurityPolicy` | Same full policy on all four sampled routes | +| CSP response header | Full `contentSecurityPolicy` plus `frame-ancestors 'none'` | Only `frame-ancestors 'none'` | +| X-Frame-Options | `DENY` | `DENY` | +| X-Content-Type-Options | `nosniff` | `nosniff` | +| Referrer-Policy | `no-referrer` | Not recorded by this QA check | +| Strict-Transport-Security | Absent on HTTP preview | `max-age=86400` | + +Production combines the response framing restriction with the full meta policy. +Preview additionally sends the full policy in the response header. These delivery +mechanisms are distinct. A meta CSP cannot enforce `frame-ancestors` and applies +only after the browser encounters it. `frame-src 'none'` restricts frames embedded +by this site, not other sites embedding it. The current production response header +provides the framing restriction that the earlier samples lacked. + +Response header parity with preview's `Referrer-Policy: no-referrer` is unverified. +Do not infer that header's presence or absence from its omission in the QA report. +The repository's home, privacy, and 404 HTML templates also contain +``. That is distinct from an HTTP +response header and was not separately verified in the live QA sample. +Preview browser tests establish preview behaviour, not public header coverage. + +## Historical observations before protections were enabled + +The following table records the earlier state and does not describe the current +home and privacy samples above. On 9 November 2026 at 00:43 UTC, certificate validated HTTPS GET and HEAD requests to `https://luinbytes.dev` returned the following results. No request followed a @@ -30,55 +100,35 @@ curl --silent --show-error --dump-header - --output /dev/null https://luinbytes. curl --silent --show-error --head https://luinbytes.dev/privacy ``` -## What the repository can enforce +## Hosting boundary and remaining verification The Pages workflow uploads static files from `out`. It does not run `scripts/serve-preview.mjs` in production. GitHub Pages has no supported custom -response header configuration. Adding `_headers`, `.htaccess`, or a header to the -local Node server does not configure the current Pages deployment. See +response header configuration. Adding `_headers`, `.htaccess`, or headers to the +local Node server does not configure Pages responses. See [GitHub's response about custom headers](https://github.com/orgs/community/discussions/54257). - -The existing meta CSP remains useful and must stay intact. Its `frame-src 'none'` -blocks frames embedded by this site. It does not block other sites embedding this -site. Browsers require a response header for `frame-ancestors`; adding that directive, -`X-Frame-Options`, `nosniff`, or HSTS to HTML meta tags does not provide those protections. - -The local preview adds the full CSP with `frame-ancestors 'none'`, -`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, and -`Referrer-Policy: no-referrer`. Preview HTTP has no HSTS header. -Browser tests against that server verify preview behaviour, not public headers. - -## Hosting judgement for Lu - -Prefer response header rules on the existing Cloudflare edge if Lu confirms the -zone is controlled by the intended account and that rules cover all public -responses, including cached pages and errors. Cloudflare documents -[response header transforms](https://developers.cloudflare.com/rules/transform/response-header-modification/). -The observed server header supports investigating this path; it does not prove -account access, plan eligibility, or configured rules. No account or DNS change -has been made. - -The intended policy is the unchanged `contentSecurityPolicy` from -`scripts/security-policy.mjs`, plus `frame-ancestors 'none'` as a response CSP. -Also send `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, and -`Referrer-Policy: no-referrer`. Keep the header policy aligned with that source -when the CSP changes. Apply it to successful HTML and error responses and check -asset MIME types before enabling `nosniff`. - -HSTS needs its own coverage check. These apex HTTPS samples do not establish safe -coverage for HTTP redirects, `www`, other subdomains, or certificate renewal. -After those checks, begin with a short apex `max-age` and increase it only after -verification. Do not enable `includeSubDomains` or `preload` without a separate -inventory and decision. See [Cloudflare's HSTS guidance](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/). - -If the existing edge cannot supply these headers, a host with explicit header -support is the alternative. For example, [Cloudflare Pages supports a `_headers` -file](https://developers.cloudflare.com/pages/configuration/headers/). That would -be a hosting migration requiring Lu's decision and deployment verification, not -a file fix for GitHub Pages. For this static portfolio, retaining Pages with the -documented gap is also a valid decision if Lu accepts it. - -After any authorised hosting change, repeat GET and HEAD checks for all four -routes, check the meta CSP is preserved, and verify both pond modes and normal -navigation. Until those public checks pass, do not claim #88 is fixed or close -its production acceptance criteria. +Cloudflare supports [response header transforms](https://developers.cloudflare.com/rules/transform/response-header-modification/), +but the QA observations do not verify the account's configured rules or their scope. + +The earlier recommendation was to configure the full `contentSecurityPolicy` plus +`frame-ancestors 'none'` as a response CSP, alongside `DENY`, `nosniff`, and +`no-referrer`. That remains a proposed alignment, not a description of the current +production response CSP. Keep `scripts/security-policy.mjs` as the source for the +full policy if a future authorised hosting change adopts it. + +The QA samples cover home, privacy, `/404`, and one nonexistent route. They do not +establish asset MIME types, every cached response, raw redirect statuses or +`Location` headers, direct `www` redirect response headers, certificate renewal, +or HTTPS coverage for other subdomains. +Browser entry checks for HTTP apex and `www` ended at HTTPS apex. HTTPS +`www/privacy` ended at apex `/privacy`. The browser had already received HSTS, so +these effective destinations do not prove the raw HTTP redirect chain for a new +client. QA did not disable or bypass security controls. + +The observed HSTS value has neither `includeSubDomains` nor `preload`. Any increase +or broader HSTS scope needs separate coverage verification and Lu's decision. +See [Cloudflare's HSTS guidance](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/). +The trusted TLS checks support current apex and `www` HTTPS coverage within these limits. + +Independent QA reports the scoped runtime checks passed. Documentation still needs review. This documentation records +current evidence and explicit differences without closing #88 or changing hosting.