document the CSP the pages override; allowlist the build context
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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.
This commit is contained in:
jx12n 2026-09-03 01:01:28 -06:00
parent ac52fbe0b9
commit b6098c28be
5 changed files with 70 additions and 3 deletions

14
.dockerignore Normal file
View File

@ -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

18
.gitignore vendored
View File

@ -1,2 +1,20 @@
.build/ .build/
*.tmp *.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

View File

@ -40,9 +40,13 @@ wrong tool — use a channel with identity.
### In a browser ### In a browser
1. Open <https://hush.threesix.ai>. 1. Open <https://hush.threesix.ai>.
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. 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 ### Why there is a button

View File

@ -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 and `/healthz`, `/readyz`, `/metrics`. hush contributes handlers, a store, a
rate limiter and templates — not a framework. 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-<r>'; style-src 'nonce-<r>';
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 `<meta>` 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 `<meta>` read as permitted. `frame-ancestors` is
also ignored outright in `<meta>`, 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`, The public Ingress routes `/` (exact), `/s/` and `/api/` only. `/metrics`,
`/healthz` and `/readyz` share the port but are unreachable from the internet; `/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 vmagent scrapes the pod IP directly. This is why there is no metrics basic-auth

View File

@ -58,7 +58,7 @@ textarea:focus,button:focus-visible{outline:0;border-color:var(--line-lit);box-s
button{ button{
margin-top:14px;padding:11px 16px;border:1px solid transparent; margin-top:14px;padding:11px 16px;border:1px solid transparent;
background:var(--fg);color:#08090a;font-weight:600;font-size:14px; 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:hover:not(:disabled){background:#fff}
button:active:not(:disabled){transform:translateY(.5px)} button:active:not(:disabled){transform:translateY(.5px)}