From b6098c28befd556e83a71072e7560a8daf3226b6 Mon Sep 17 00:00:00 2001 From: jx12n Date: Thu, 3 Sep 2026 01:01:28 -0600 Subject: [PATCH] document the CSP the pages override; allowlist the build context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ARCHITECTURE.md records why internal/web.render replaces the chassis JSON-API policy with a per-response nonce policy, and the failure each of the three decisions prevents: a header-only policy because two policies on one response intersect, a nonce instead of 'unsafe-inline' because the guarantee is that only the reviewed same-document script reaches the fragment key, and a fresh url-alphabet value because a reused nonce is worth 'unsafe-inline' to anyone who waits for the next load and + or / would make enforcement depend on entity decoding. README.md now names the buttons the page actually renders and says outright that there is no lifetime picker — the server's 24h default applies and ttl_seconds is where a caller chooses. base.html drops the opacity transition; nothing animates opacity. .dockerignore is an allowlist, because the build stage COPYs only go.mod, go.sum, vendor/, cmd/ and internal/. A blocklist forgets the file nobody predicted, and for this service that file is a secret. .gitignore grows the same protection for the working tree. --- .dockerignore | 14 ++++++++++++++ .gitignore | 18 ++++++++++++++++++ README.md | 8 ++++++-- docs/ARCHITECTURE.md | 31 +++++++++++++++++++++++++++++++ internal/web/templates/base.html | 2 +- 5 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..77bc8ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Allowlist, not a blocklist. The build stage COPYs exactly go.mod, go.sum, +# vendor/, cmd/ and internal/ — so anything else in the context is bytes Kaniko +# uploads for nothing, and a `.env` or key dropped in the tree is one a build +# layer can never capture. A blocklist forgets the file nobody predicted. +* +!go.mod +!go.sum +!vendor +!cmd +!internal + +# Kaniko reads this before the context is filtered; allowlisted so the two +# cannot disagree. +!Dockerfile diff --git a/.gitignore b/.gitignore index bae48bd..10b8e15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,20 @@ .build/ *.tmp + +# Secrets: never tracked, never in a build context +.env +.env.* +!.env.example +.envault/ +*.pem +*.key +*.p12 +*.pfx +credentials.json +service-account*.json + +# Editor and OS noise +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/README.md b/README.md index 74d960a..d3171ed 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,13 @@ wrong tool — use a channel with identity. ### In a browser 1. Open . -2. Paste the secret, pick a lifetime, press **Create link**. +2. Paste the secret, press **create a secret**. 3. Copy the link and send it however you like. -4. The recipient opens it, presses **Reveal**, and reads it once. +4. The recipient opens it, presses **reveal the secret**, and reads it once. + +There is no lifetime picker: the page offers one action, and the server applies +its default TTL (24 hours). `ttl_seconds` on the API is where a caller that +cares chooses. ### Why there is a button diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cb0214e..33de0f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -132,6 +132,37 @@ the panic recovery envelope, RED metrics, secure headers, the two-phase drain, and `/healthz`, `/readyz`, `/metrics`. hush contributes handlers, a store, a rate limiter and templates — not a framework. +### The pages override the chassis CSP + +The chassis policy is written for a JSON API: `default-src 'none'; +frame-ancestors 'none'`. The two pages are HTML with inline script and inline +style, so `internal/web.render` replaces that header with a per-response +nonce policy: + +``` +default-src 'none'; script-src 'nonce-'; style-src 'nonce-'; +connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none' +``` + +Three decisions, each with a failure it prevents: + +- **A header, and only a header.** Two policies delivered on one response + intersect, so a permissive `` cannot re-enable what the header forbids. + Shipping both is how the pages ended up with their own crypto and their own + `fetch` blocked while the `` read as permitted. `frame-ancestors` is + also ignored outright in ``, so it exists only as a header. +- **A nonce, not `'unsafe-inline'`.** The guarantee is that nothing but this + reviewed same-document script can reach the key in the fragment; + `'unsafe-inline'` would extend that permission to anything an injection got + onto the page. +- **Fresh per response, url-alphabet base64.** A reused nonce is worth + `'unsafe-inline'` to an attacker who can wait for the next load, and `+` or + `/` in the value would be escaped to character references inside the HTML + attribute, making what the browser enforces depend on entity decoding. The + nonce is fixed-length, so it adds no id-correlated variation to the reveal + page — `TestTheRevealPageDoesNotDiscloseWhetherASecretExists` compares the + page with it masked and asserts constant length. + The public Ingress routes `/` (exact), `/s/` and `/api/` only. `/metrics`, `/healthz` and `/readyz` share the port but are unreachable from the internet; vmagent scrapes the pod IP directly. This is why there is no metrics basic-auth diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index dc04e4f..8c9cff6 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -58,7 +58,7 @@ textarea:focus,button:focus-visible{outline:0;border-color:var(--line-lit);box-s button{ margin-top:14px;padding:11px 16px;border:1px solid transparent; background:var(--fg);color:#08090a;font-weight:600;font-size:14px; - cursor:pointer;transition:background .12s,border-color .12s,color .12s,opacity .12s; + cursor:pointer;transition:background .12s,border-color .12s,color .12s; } button:hover:not(:disabled){background:#fff} button:active:not(:disabled){transform:translateY(.5px)}