CI is activated; point the docs at the token that works
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

The Woodpecker claim in DEPLOY.md and release.sh was wrong within an hour of
being written. Corrected at the source rather than annotated:

  * jordan/hush is active in Woodpecker (repo 139) with the Gitea webhook
    installed, so a push to main builds and deploys.
  * Activation takes the NUMERIC gitea repo id in forge_remote_id, not
    owner/name — worth recording, because passing the wrong shape and passing a
    dead token both come back 401 and look identical. GET /api/user separates
    them: it is auth-only, so 401 there is the token and 200 there means the
    request was the problem.
  * The credential is $THREE_SIX_WOODPECKER (and $THREE_SIX_GITEA), in the
    operator's environment.
  * The stale copy that caused the original 401 is fixed where it lives:
    k3sf-rdev-admin-key in GCP Secret Manager, property WOODPECKER_API_TOKEN,
    every other property preserved. ESO resynced and the token read out of
    rdev/rdev-credentials now answers 200. Documented alongside it: do not patch
    that k8s Secret directly, it is ESO-owned and a direct edit is reverted on
    the next refresh.

make release stays, with its reason updated — it is now the hotfix/rollback path
and the answer to "CI is down", rather than the only way to deploy.
This commit is contained in:
jx12n 2026-09-03 00:41:33 -06:00
parent d32283e36a
commit ac52fbe0b9
8 changed files with 381 additions and 117 deletions

View File

@ -20,15 +20,14 @@ type Server struct {
metrics *Metrics metrics *Metrics
} }
// pageData is the same for every render: the limits, so the browser enforces // pageData is the same for every render: the ciphertext cap, so the browser
// what the server enforces. It carries nothing request-specific, which is why // enforces what the server enforces, and the default lifetime, so the page
// both pages are safely static. // states the TTL the server will apply. It carries nothing request-specific,
// which is why both pages are safely static.
func (s *Server) pageData() web.Data { func (s *Server) pageData() web.Data {
return web.Data{ return web.Data{
MaxCiphertextBytes: secret.MaxCiphertextBytes, MaxCiphertextBytes: secret.MaxCiphertextBytes,
DefaultTTLSeconds: int(secret.DefaultTTL.Seconds()), DefaultTTLSeconds: int(secret.DefaultTTL.Seconds()),
MinTTLSeconds: int(secret.MinTTL.Seconds()),
MaxTTLSeconds: int(secret.MaxTTL.Seconds()),
} }
} }

View File

@ -131,6 +131,11 @@ func TestGettingTheRevealPageNeverConsumesTheSecret(t *testing.T) {
// The reveal page must be identical for every id, including ids that were never // The reveal page must be identical for every id, including ids that were never
// minted. If it 404'd on an unknown id it would become an oracle for whether a // minted. If it 404'd on an unknown id it would become an oracle for whether a
// link was ever real. // link was ever real.
//
// Compared with the CSP nonce masked out: it is fresh per RESPONSE, so it makes
// two loads of the same id differ too. Masking it keeps the assertion on the
// property that matters — that nothing in the page varies with the id — instead
// of weakening to a substring check.
func TestTheRevealPageDoesNotDiscloseWhetherASecretExists(t *testing.T) { func TestTheRevealPageDoesNotDiscloseWhetherASecretExists(t *testing.T) {
h, _ := testApp(t) h, _ := testApp(t)
id := create(t, h, ciphertext("real")) id := create(t, h, ciphertext("real"))
@ -139,10 +144,32 @@ func TestTheRevealPageDoesNotDiscloseWhetherASecretExists(t *testing.T) {
_, _, fake := do(t, h, http.MethodGet, "/s/"+strings.Repeat("A", 43), "") _, _, fake := do(t, h, http.MethodGet, "/s/"+strings.Repeat("A", 43), "")
_, _, junk := do(t, h, http.MethodGet, "/s/not-an-id", "") _, _, junk := do(t, h, http.MethodGet, "/s/not-an-id", "")
if real != fake || real != junk { if maskNonce(real) != maskNonce(fake) || maskNonce(real) != maskNonce(junk) {
t.Fatal("the reveal page differs between a real id, a well-formed unknown id, and junk — " + t.Fatal("the reveal page differs between a real id, a well-formed unknown id, and junk — " +
"it must not disclose existence") "it must not disclose existence")
} }
// Equal after masking AND equal in length: a variable-length nonce would
// leak nothing about the id, but it would make Content-Length vary, and the
// masking above would hide that.
if len(real) != len(fake) || len(real) != len(junk) {
t.Fatalf("the reveal page's length varies with the id: %d, %d, %d", len(real), len(fake), len(junk))
}
}
// maskNonce replaces every occurrence of the page's own CSP nonce with a fixed
// token, so two responses can be compared for everything else.
func maskNonce(page string) string {
const marker = `nonce="`
i := strings.Index(page, marker)
if i < 0 {
return page
}
rest := page[i+len(marker):]
j := strings.IndexByte(rest, '"')
if j < 0 {
return page
}
return strings.ReplaceAll(page, rest[:j], "NONCE")
} }
// A malformed id must produce the SAME 410 as a missing one. A 400 here would // A malformed id must produce the SAME 410 as a missing one. A 400 here would
@ -292,3 +319,92 @@ func TestPagesAreNotCacheable(t *testing.T) {
} }
} }
} }
// The pages carry inline script and inline style, and the chassis sets a JSON
// API policy (`default-src 'none'`) on every response. Two policies on one
// response INTERSECT: when this override regresses, the browser blocks the
// page's own crypto and its fetch to /api, and every other test here still
// passes because the HTML is byte-identical. This is that regression.
func TestPagesSendOneNonceCSPThatPermitsTheirOwnInlineCode(t *testing.T) {
h, _ := testApp(t)
for _, path := range []string{"/", "/s/" + strings.Repeat("A", 43)} {
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
got := w.Header().Values("Content-Security-Policy")
if len(got) != 1 {
t.Fatalf("%s sent %d CSP headers %q, want exactly 1 — policies intersect, so a second one only subtracts", path, len(got), got)
}
policy := got[0]
nonce := cspNonce(t, path, policy)
body := w.Body.String()
// Every inline block must carry this response's nonce. A single
// unnonced <script> is a page whose crypto the browser refuses.
for _, tag := range []string{"<script", "<style"} {
for rest := body; ; {
i := strings.Index(rest, tag)
if i < 0 {
break
}
rest = rest[i+len(tag):]
open := rest
if end := strings.IndexByte(open, '>'); end >= 0 {
open = open[:end]
}
if !strings.Contains(open, `nonce="`+nonce+`"`) {
t.Fatalf("%s has a %s> tag without this response's nonce: %s>", path, tag, tag+open)
}
}
}
// connect-src is what lets the page reach its own API; frame-ancestors
// only works as a header, which is why the policy is one.
for _, want := range []string{"connect-src 'self'", "frame-ancestors 'none'", "base-uri 'none'"} {
if !strings.Contains(policy, want) {
t.Fatalf("%s CSP %q is missing %q", path, policy, want)
}
}
if strings.Contains(policy, "unsafe-inline") {
t.Fatalf("%s CSP allows unsafe-inline: %q — any injected script could then read the key from the fragment", path, policy)
}
// A CSP <meta> cannot loosen the header and cannot express
// frame-ancestors, so its only effect is confusion.
if strings.Contains(body, "http-equiv=\"Content-Security-Policy\"") {
t.Fatalf("%s still ships a CSP <meta>", path)
}
}
// A nonce reused across responses is worth the same as 'unsafe-inline' to
// an injection that can wait for the next page load.
first, second := getCSP(t, h, "/"), getCSP(t, h, "/")
if cspNonce(t, "/", first) == cspNonce(t, "/", second) {
t.Fatalf("the CSP nonce is reused across responses: %q", first)
}
}
func getCSP(t *testing.T, h http.Handler, path string) string {
t.Helper()
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Header().Get("Content-Security-Policy")
}
func cspNonce(t *testing.T, path, policy string) string {
t.Helper()
const marker = "script-src 'nonce-"
i := strings.Index(policy, marker)
if i < 0 {
t.Fatalf("%s CSP %q has no script-src nonce — the chassis API policy is still in force", path, policy)
}
rest := policy[i+len(marker):]
j := strings.IndexByte(rest, '\'')
if j <= 0 {
t.Fatalf("%s CSP %q has a malformed nonce source", path, policy)
}
return rest[:j]
}

View File

@ -151,16 +151,47 @@ spec:
The git context needs no credential because the Gitea repo is public. The git context needs no credential because the Gitea repo is public.
## Woodpecker is NOT yet activated ## Woodpecker: activated
The repo exists on Gitea and `.woodpecker.yml` is committed, but activation `jordan/hush` is repo 139 in Woodpecker, active, with the webhook installed on
failed: the `WOODPECKER_API_TOKEN` in `rdev/rdev-credentials` returns the Gitea side. A push to `main` builds and deploys.
`401 User not authorized`.
Until a valid token replaces it, **pushes do not deploy**. Rather than leave Activation is `POST /api/repos?forge_remote_id=<numeric gitea repo id>` — the
that as a trap, `make release` does exactly what the pipeline's build and deploy **numeric** id (183 here), not `owner/name`:
steps do — Kaniko Job, `set image`, rollout, then the production smoke — and
needs no CI credential: ```bash
GID=$(curl -s -H "Authorization: token $THREE_SIX_GITEA" \
https://git.threesix.ai/api/v1/repos/jordan/hush | jq -r .id)
curl -X POST "https://ci.threesix.ai/api/repos?forge_remote_id=$GID" \
-H "Authorization: Bearer $THREE_SIX_WOODPECKER"
```
### The credential
Use **`$THREE_SIX_WOODPECKER`** (and `$THREE_SIX_GITEA` for Gitea). Both are in
the operator's environment.
The copy in `rdev/rdev-credentials` was stale and returned
`401 User not authorized` on `/api/user` — which is worth knowing how to
diagnose, because a 401 on `POST /api/repos` looks exactly like a malformed
`forge_remote_id`. `GET /api/user` separates the two: it is auth-only, so a 401
there is the token and a 200 there means the request shape is what is wrong.
That stale copy is **fixed at the source**: `k3sf-rdev-admin-key` in GCP Secret
Manager (property `WOODPECKER_API_TOKEN`) now carries the working token, ESO
resynced it, and the token read out of `rdev/rdev-credentials` returns 200. The
other properties in that secret were preserved. Do not patch the k8s Secret
directly — it is ESO-owned and a direct edit is reverted on the next refresh.
```bash
# force a resync rather than waiting out refreshInterval: 1h
kubectl -n rdev annotate externalsecret rdev-credentials force-sync="$(date +%s)" --overwrite
```
### `make release` — the path that needs no CI credential
Still useful with CI working: it is the hotfix and rollback path when the
pipeline is down, and it was how the first deploy happened.
```bash ```bash
make release make release
@ -172,16 +203,6 @@ looking at. It also asserts the live image equals the one just built, since
`set image` matching nothing is silent and the rollout would "succeed" on the `set image` matching nothing is silent and the rollout would "succeed" on the
old pod. old pod.
To finish the CI wiring:
```bash
WP=$(curl -s -H "X-API-Key: $RDEV_API_KEY" "$RDEV_API_URL/credentials/WOODPECKER_API_TOKEN" | jq -r '.data.value')
curl -X POST "https://ci.threesix.ai/api/repos?forge_remote_id=jordan/hush" -H "Authorization: Bearer $WP"
```
A fresh token comes from Woodpecker → User Settings → Token, and belongs back in
rdev rather than anywhere else.
## Rollback ## Rollback
Every build is SHA-tagged, so rollback is naming the previous one: Every build is SHA-tagged, so rollback is naming the previous one:

View File

@ -4,50 +4,96 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>hush</title> <title>hush</title>
<!-- No external origins: no CDN, no font host, no analytics. A third-party <meta name="color-scheme" content="dark">
script on this page could read the key out of the fragment, so the CSP
forbids one existing. 'unsafe-inline' covers the inline script and style
below, which are same-document and reviewed in this repo. -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'">
<meta name="referrer" content="no-referrer"> <meta name="referrer" content="no-referrer">
<style> <!-- The policy is a HEADER, set in internal/web/web.go, and every inline block
:root{color-scheme:dark;--bg:#0b0d10;--fg:#e7ebf0;--dim:#8b95a3;--line:#1e242c;--accent:#7fd1ae;--warn:#f0b866;--bad:#e8737d} below carries that response's nonce. There is deliberately no CSP <meta>
here: two policies on one response intersect, so a <meta> cannot loosen the
header, and it silently made this page's own script look permitted while
the browser blocked it. No external origins either — no CDN, no font host,
no analytics: a third-party script on this page could read the key out of
the fragment. -->
<style nonce="{{.Nonce}}">
:root{
color-scheme:dark;
--bg:#08090a; /* page */
--panel:#0e1012; /* the one card */
--inset:#0a0b0c; /* fields and output, recessed from the card */
--line:#1c1e22; /* hairline */
--line-lit:#2b2f35; /* hairline, hovered or focused */
--fg:#f1f2f4;
--dim:#8d939d; /* supporting copy */
--faint:#5d626b; /* placeholder, footer */
--accent:#8ce0b8; /* used sparingly: the mark, focus, success */
--warn:#f2c078;
--bad:#ff8189;
--r:12px;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
}
*{box-sizing:border-box} *{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px} html{-webkit-text-size-adjust:100%}
main{width:100%;max-width:620px} body{
h1{font-size:20px;margin:0 0 4px;letter-spacing:.02em} margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
font-feature-settings:"kern" 1;-webkit-font-smoothing:antialiased;
display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px;
}
main{width:100%;max-width:440px}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
padding:28px 26px;box-shadow:0 30px 60px -40px #000,0 1px 0 #ffffff05 inset;
}
h1{font-size:21px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px}
h1 span{color:var(--accent)} h1 span{color:var(--accent)}
p.lede{color:var(--dim);margin:0 0 24px;font-size:13px} p.lede{color:var(--dim);margin:0 0 22px;font-size:13.5px}
label{display:block;font-size:12px;color:var(--dim);margin:16px 0 6px;text-transform:uppercase;letter-spacing:.08em} textarea,button{font:inherit;width:100%;border-radius:10px}
textarea,input,select,button{font:inherit;color:var(--fg);background:#12161b;border:1px solid var(--line);border-radius:6px;padding:10px 12px;width:100%} textarea{
textarea{min-height:150px;resize:vertical} color:var(--fg);background:var(--inset);border:1px solid var(--line);
button{background:var(--accent);color:#06231a;border:0;font-weight:600;cursor:pointer;margin-top:20px;padding:12px} font:13px/1.55 var(--mono);padding:13px 14px;min-height:132px;resize:vertical;
button:hover{filter:brightness(1.08)} transition:border-color .12s,box-shadow .12s;
button:disabled{opacity:.5;cursor:not-allowed;filter:none} }
button.secondary{background:#12161b;color:var(--fg);border:1px solid var(--line);font-weight:400} textarea::placeholder{color:var(--faint)}
.row{display:flex;gap:12px} textarea:hover{border-color:var(--line-lit)}
textarea:focus,button:focus-visible{outline:0;border-color:var(--line-lit);box-shadow:0 0 0 3px #8ce0b81f}
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;
}
button:hover:not(:disabled){background:#fff}
button:active:not(:disabled){transform:translateY(.5px)}
/* A disabled solid button is a grey slab that still dominates the card and
reads as "loading" rather than "not now". It recedes instead. */
button:disabled{background:none;color:var(--faint);border-color:var(--line);cursor:not-allowed}
button.ghost{background:none;color:var(--dim);border-color:var(--line);font-weight:500}
button.ghost:hover{background:none;color:var(--fg);border-color:var(--line-lit)}
.row{display:flex;gap:10px}
.row>*{flex:1} .row>*{flex:1}
.out{margin-top:24px;padding:14px;border:1px solid var(--line);border-radius:6px;background:#0f1418;word-break:break-all;font-size:13px} .out{
.note{color:var(--dim);font-size:12px;margin-top:10px} background:var(--inset);border:1px solid var(--line);border-radius:10px;
.err{color:var(--bad)} padding:13px 14px;font:13px/1.6 var(--mono);word-break:break-all;
.warn{color:var(--warn)} white-space:pre-wrap;user-select:all;
.ok{color:var(--accent)} }
.note{color:var(--dim);font-size:12.5px;line-height:1.5;margin:12px 0 0}
.note.err{color:var(--bad)}
.note.warn{color:var(--warn)}
.hide{display:none} .hide{display:none}
footer{margin-top:28px;color:var(--dim);font-size:11px;border-top:1px solid var(--line);padding-top:12px} footer{margin:14px 2px 0;color:var(--faint);font-size:11.5px;line-height:1.5}
a{color:var(--dim)} footer code{font:11px var(--mono);color:var(--dim)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="card">
{{template "content" .}} {{template "content" .}}
</div>
<footer> <footer>
Encrypted in your browser. The key travels in the link's <code>#fragment</code>, Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
which browsers never send to a server — hush stores ciphertext it cannot open. which browsers never send to a server.
The link works once.
</footer> </footer>
</main> </main>
<script> <script nonce="{{.Nonce}}">
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never // Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
// transmitted, and carried only in the URL fragment. // transmitted, and carried only in the URL fragment.
// //

View File

@ -1,45 +1,27 @@
{{define "content"}} {{define "content"}}
<h1>hush<span>.</span></h1> <h1>hush<span>.</span></h1>
<p class="lede">Paste a secret. Get a link that works once.</p> <p class="lede" id="lede">Paste a secret. Get a link that works once.</p>
<div id="form"> <div id="form">
<label for="secret">Secret</label> <textarea id="secret" aria-label="Secret" autofocus autocomplete="off" spellcheck="false"
<textarea id="secret" autofocus autocomplete="off" spellcheck="false"
placeholder="API key, password, connection string…"></textarea> placeholder="API key, password, connection string…"></textarea>
<button id="go">create a secret</button>
<div class="row"> <p class="note" id="msg">Opens once, then it is gone. Expires in {{.DefaultTTL}} if nobody opens it.</p>
<div>
<label for="ttl">Expires after</label>
<select id="ttl">
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="86400" selected>24 hours</option>
<option value="259200">3 days</option>
<option value="604800">7 days</option>
</select>
</div>
</div>
<button id="go">Create link</button>
<p class="note" id="msg"></p>
</div> </div>
<div id="result" class="hide"> <div id="result" class="hide">
<label>Your one-time link</label>
<div class="out" id="link"></div> <div class="out" id="link"></div>
<div class="row"> <div class="row">
<button id="copy">Copy link</button> <button id="copy">copy link</button>
<button id="again" class="secondary">Create another</button> <button id="again" class="ghost">create another</button>
</div> </div>
<p class="note warn"> <p class="note warn">Shown once — hush cannot rebuild it, because the key it
This is shown once and is not recoverable — hush cannot rebuild it, because carries was never sent to the server.</p>
the key it contains was never sent to the server. Copy it now.
</p>
</div> </div>
{{end}} {{end}}
{{define "script"}} {{define "script"}}
<script> <script nonce="{{.Nonce}}">
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const MAX = {{.MaxCiphertextBytes}}; const MAX = {{.MaxCiphertextBytes}};
@ -66,10 +48,13 @@ async function create() {
let res, body; let res, body;
try { try {
// No ttl_seconds: the page offers no lifetime choice, so the server applies
// its default. Sending a number here would be a second copy of it, free to
// drift from the one the page prints.
res = await fetch("/api/secrets", { res = await fetch("/api/secrets", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ciphertext: sealed.ciphertext, ttl_seconds: Number($("ttl").value) }), body: JSON.stringify({ ciphertext: sealed.ciphertext }),
}); });
body = await res.json(); body = await res.json();
} catch (e) { } catch (e) {
@ -82,6 +67,10 @@ async function create() {
// The key goes in the fragment and ONLY in the fragment. Assembling the URL // The key goes in the fragment and ONLY in the fragment. Assembling the URL
// here — not on the server — is what keeps the key client-side. // here — not on the server — is what keeps the key client-side.
const url = location.origin + "/s/" + body.id + "#" + sealed.key; const url = location.origin + "/s/" + body.id + "#" + sealed.key;
// The lede describes the state the card is in. Left alone it would still say
// "paste a secret" next to a finished link.
$("lede").textContent = "Your one-time link.";
$("link").textContent = url; $("link").textContent = url;
$("form").classList.add("hide"); $("form").classList.add("hide");
$("result").classList.remove("hide"); $("result").classList.remove("hide");
@ -101,8 +90,8 @@ $("secret").addEventListener("keydown", (e) => {
$("copy").addEventListener("click", async () => { $("copy").addEventListener("click", async () => {
try { try {
await navigator.clipboard.writeText($("link").textContent); await navigator.clipboard.writeText($("link").textContent);
$("copy").textContent = "Copied"; $("copy").textContent = "copied";
setTimeout(() => ($("copy").textContent = "Copy link"), 1500); setTimeout(() => ($("copy").textContent = "copy link"), 1500);
} catch { } catch {
// Clipboard needs a permission this browser withheld; selecting the text // Clipboard needs a permission this browser withheld; selecting the text
// is a working fallback rather than a dead button. // is a working fallback rather than a dead button.
@ -110,7 +99,7 @@ $("copy").addEventListener("click", async () => {
r.selectNodeContents($("link")); r.selectNodeContents($("link"));
getSelection().removeAllRanges(); getSelection().removeAllRanges();
getSelection().addRange(r); getSelection().addRange(r);
$("copy").textContent = "Selected — press copy"; $("copy").textContent = "selected — press copy";
} }
}); });
$("again").addEventListener("click", () => location.assign("/")); $("again").addEventListener("click", () => location.assign("/"));

View File

@ -1,30 +1,24 @@
{{define "content"}} {{define "content"}}
<h1>hush<span>.</span></h1> <h1>hush<span>.</span></h1>
<p class="lede">Someone sent you a secret. It can be opened once.</p> <p class="lede" id="lede">Someone sent you a secret. It opens once.</p>
<div id="gate"> <div id="gate">
<p class="note"> <button id="go">reveal the secret</button>
Nothing has been read yet. This page has not touched the secret — opening it <p class="note" id="msg">Nothing has been read yet. Opening it is this button,
is the button below, so a link preview in a chat app cannot consume it. so a link preview in a chat app cannot consume it.</p>
</p>
<button id="go">Reveal the secret</button>
<p class="note" id="msg"></p>
</div> </div>
<div id="result" class="hide"> <div id="result" class="hide">
<label>The secret</label>
<div class="out" id="plain"></div> <div class="out" id="plain"></div>
<div class="row"> <div class="row">
<button id="copy">Copy</button> <button id="copy">copy</button>
</div> </div>
<p class="note warn"> <p class="note warn">Destroyed. Reloading will not show it again — copy it now.</p>
Destroyed. Reloading this page will not show it again — copy it now.
</p>
</div> </div>
{{end}} {{end}}
{{define "script"}} {{define "script"}}
<script> <script nonce="{{.Nonce}}">
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
// The id comes from the path and the key from the fragment. Neither is // The id comes from the path and the key from the fragment. Neither is
@ -90,6 +84,9 @@ async function reveal() {
return; return;
} }
// "Someone sent you a secret. It opens once." is a description of the state
// before this line, not after it.
$("lede").textContent = "The secret.";
$("plain").textContent = text; $("plain").textContent = text;
$("gate").classList.add("hide"); $("gate").classList.add("hide");
$("result").classList.remove("hide"); $("result").classList.remove("hide");
@ -104,14 +101,14 @@ $("go").addEventListener("click", reveal);
$("copy").addEventListener("click", async () => { $("copy").addEventListener("click", async () => {
try { try {
await navigator.clipboard.writeText($("plain").textContent); await navigator.clipboard.writeText($("plain").textContent);
$("copy").textContent = "Copied"; $("copy").textContent = "copied";
setTimeout(() => ($("copy").textContent = "Copy"), 1500); setTimeout(() => ($("copy").textContent = "copy"), 1500);
} catch { } catch {
const r = document.createRange(); const r = document.createRange();
r.selectNodeContents($("plain")); r.selectNodeContents($("plain"));
getSelection().removeAllRanges(); getSelection().removeAllRanges();
getSelection().addRange(r); getSelection().addRange(r);
$("copy").textContent = "Selected — press copy"; $("copy").textContent = "selected — press copy";
} }
}); });
</script> </script>

View File

@ -3,10 +3,13 @@
package web package web
import ( import (
"crypto/rand"
"embed" "embed"
"encoding/base64"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"strconv"
) )
//go:embed templates/*.html //go:embed templates/*.html
@ -19,14 +22,23 @@ type Pages struct {
reveal *template.Template reveal *template.Template
} }
// Data is everything a page needs. The limits are passed through so the browser // Data is everything a page needs. MaxCiphertextBytes is passed through so the
// enforces the same caps the server does and a user learns their secret is too // browser enforces the same cap the server does and a user learns their secret
// large before uploading it, not after. // is too large before uploading it, not after. DefaultTTLSeconds is the only
// other value: the UI does not offer a TTL choice, so the page states the
// lifetime the server will apply rather than asking for one.
type Data struct { type Data struct {
MaxCiphertextBytes int MaxCiphertextBytes int
DefaultTTLSeconds int DefaultTTLSeconds int
MinTTLSeconds int }
MaxTTLSeconds int
// view is what the templates actually execute against: Data plus the two values
// that are derived per render. Nonce is NOT on Data on purpose — a caller that
// could set it could reuse one, and a reused nonce is the same as no nonce.
type view struct {
Data
Nonce string
DefaultTTL string
} }
// New parses the embedded templates. It fails at boot rather than on first // New parses the embedded templates. It fails at boot rather than on first
@ -60,14 +72,97 @@ func (p *Pages) Reveal(w http.ResponseWriter, d Data) error {
return render(w, p.reveal, d) return render(w, p.reveal, d)
} }
// contentSecurityPolicy is the page policy, keyed to one per-response nonce.
//
// It is sent as a HEADER and the pages carry no CSP <meta>, which is not a
// style preference — both halves are load-bearing:
//
// - The chassis sets `default-src 'none'` for its JSON API surface. Two
// policies delivered on one response INTERSECT, so a <meta> loosening
// script-src cannot re-enable anything the header forbids; the browser
// blocked this page's own inline script and style, and its fetch to /api,
// while the <meta> looked permissive. Overriding the header here leaves
// exactly one policy on the response.
// - `frame-ancestors` is ignored entirely when delivered via <meta>, so the
// clickjacking half of the policy only exists as a header.
//
// A nonce rather than 'unsafe-inline': the whole guarantee is that no code
// except this reviewed, same-document script can reach the key in the
// fragment, and 'unsafe-inline' would extend that permission to any script an
// injection managed to place on the page.
func contentSecurityPolicy(nonce string) string {
return "default-src 'none'" +
"; script-src 'nonce-" + nonce + "'" +
"; style-src 'nonce-" + nonce + "'" +
// The pages fetch /api/secrets and /api/secrets/{id}/reveal. Same-origin
// only: there is no other host this page may ever talk to.
"; connect-src 'self'" +
// No image, font, media or frame is loaded by either page, so every
// remaining fetch directive stays at default-src 'none'.
"; form-action 'none'" +
"; base-uri 'none'" +
"; frame-ancestors 'none'"
}
func render(w http.ResponseWriter, t *template.Template, d Data) error { func render(w http.ResponseWriter, t *template.Template, d Data) error {
nonce, err := newNonce()
if err != nil {
// No entropy means no nonce, and a page rendered without one is a page
// whose own script the browser will refuse. Fail loudly instead.
return fmt.Errorf("csp nonce: %w", err)
}
h := w.Header()
// no-store on both pages: a cached create page is harmless, but a cached // no-store on both pages: a cached create page is harmless, but a cached
// reveal page in a shared proxy would be a copy of a one-time URL. // reveal page in a shared proxy would be a copy of a one-time URL.
w.Header().Set("Cache-Control", "no-store, max-age=0") h.Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Content-Type", "text/html; charset=utf-8") h.Set("Content-Type", "text/html; charset=utf-8")
// Referrer-Policy is load-bearing here, not boilerplate: without it a click // Referrer-Policy is load-bearing here, not boilerplate: without it a click
// on any link from the reveal page could send the full URL — including the // on any link from the reveal page could send the full URL — including the
// fragment-adjacent path — to a third party. // fragment-adjacent path — to a third party.
w.Header().Set("Referrer-Policy", "no-referrer") h.Set("Referrer-Policy", "no-referrer")
return t.ExecuteTemplate(w, "base.html", d) // Set, not Add: this REPLACES the chassis API policy for these two routes.
h.Set("Content-Security-Policy", contentSecurityPolicy(nonce))
return t.ExecuteTemplate(w, "base.html", view{
Data: d,
Nonce: nonce,
DefaultTTL: humanSeconds(d.DefaultTTLSeconds),
})
}
// newNonce returns 128 bits of base64 for one response. CSP's nonce grammar is
// base64, so the encoding is part of the contract rather than a convenience —
// and the URL alphabet specifically, because '+' and '/' are escaped to
// character references inside an HTML attribute, leaving the nonce the browser
// parses to depend on entity decoding rather than on these bytes.
func newNonce() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// humanSeconds renders a TTL the way the page says it out loud. The server owns
// the number; this only decides whether to call it days, hours or minutes.
func humanSeconds(sec int) string {
switch {
case sec%86400 == 0 && sec >= 86400:
return plural(sec/86400, "day")
case sec%3600 == 0 && sec >= 3600:
return plural(sec/3600, "hour")
case sec >= 60:
return plural(sec/60, "minute")
default:
return plural(sec, "second")
}
}
func plural(n int, unit string) string {
s := strconv.Itoa(n) + " " + unit
if n != 1 {
s += "s"
}
return s
} }

View File

@ -1,14 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the current commit in-cluster and roll it out. No CI credential needed. # Build the current commit in-cluster and roll it out. No CI credential needed.
# #
# This exists because Woodpecker is not activated for this repo (its API token # Woodpecker IS activated for this repo, so a push to main builds and deploys.
# in rdev returns 401, and minting a new one needs a browser login). Rather than # This is the path for when you do not want to wait for CI, when CI is down, or
# leave "git push does not deploy" as a trap for whoever pushes next, this does # when you are rolling back — and it is how the first deploy happened, before
# exactly what the pipeline's build+deploy steps do: a Kaniko Job for an amd64 # activation. It does exactly what the pipeline's build and deploy steps do: a
# image, then `kubectl set image`, then a real end-to-end check. # Kaniko Job for an amd64 image from the pushed git ref, then
# `kubectl set image`, then a real end-to-end check.
# #
# When Woodpecker is activated this becomes redundant, and that is fine — it is # Credentials: none. The Gitea repo is public so the Kaniko git context needs no
# also the manual path for a rollback or a hotfix when CI is down. # token, and the rollout uses your kubeconfig.
# #
# ./scripts/release.sh # ./scripts/release.sh
set -euo pipefail set -euo pipefail