Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
156 changes: 103 additions & 53 deletions docs/security-headers.md
Original file line number Diff line number Diff line change
@@ -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
`<meta name="referrer" content="no-referrer" />`. 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
Expand All @@ -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.
Loading