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)}