CI is activated; point the docs at the token that works
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
d32283e36a
commit
ac52fbe0b9
@ -20,15 +20,14 @@ type Server struct {
|
||||
metrics *Metrics
|
||||
}
|
||||
|
||||
// pageData is the same for every render: the limits, so the browser enforces
|
||||
// what the server enforces. It carries nothing request-specific, which is why
|
||||
// both pages are safely static.
|
||||
// pageData is the same for every render: the ciphertext cap, so the browser
|
||||
// enforces what the server enforces, and the default lifetime, so the page
|
||||
// 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 {
|
||||
return web.Data{
|
||||
MaxCiphertextBytes: secret.MaxCiphertextBytes,
|
||||
DefaultTTLSeconds: int(secret.DefaultTTL.Seconds()),
|
||||
MinTTLSeconds: int(secret.MinTTL.Seconds()),
|
||||
MaxTTLSeconds: int(secret.MaxTTL.Seconds()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -131,6 +131,11 @@ func TestGettingTheRevealPageNeverConsumesTheSecret(t *testing.T) {
|
||||
// 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
|
||||
// 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) {
|
||||
h, _ := testApp(t)
|
||||
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), "")
|
||||
_, _, 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 — " +
|
||||
"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
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -151,16 +151,47 @@ spec:
|
||||
|
||||
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
|
||||
failed: the `WOODPECKER_API_TOKEN` in `rdev/rdev-credentials` returns
|
||||
`401 User not authorized`.
|
||||
`jordan/hush` is repo 139 in Woodpecker, active, with the webhook installed on
|
||||
the Gitea side. A push to `main` builds and deploys.
|
||||
|
||||
Until a valid token replaces it, **pushes do not deploy**. Rather than leave
|
||||
that as a trap, `make release` does exactly what the pipeline's build and deploy
|
||||
steps do — Kaniko Job, `set image`, rollout, then the production smoke — and
|
||||
needs no CI credential:
|
||||
Activation is `POST /api/repos?forge_remote_id=<numeric gitea repo id>` — the
|
||||
**numeric** id (183 here), not `owner/name`:
|
||||
|
||||
```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
|
||||
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
|
||||
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
|
||||
|
||||
Every build is SHA-tagged, so rollback is naming the previous one:
|
||||
|
||||
@ -4,50 +4,96 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hush</title>
|
||||
<!-- No external origins: no CDN, no font host, no analytics. A third-party
|
||||
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="color-scheme" content="dark">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#0b0d10;--fg:#e7ebf0;--dim:#8b95a3;--line:#1e242c;--accent:#7fd1ae;--warn:#f0b866;--bad:#e8737d}
|
||||
<!-- The policy is a HEADER, set in internal/web/web.go, and every inline block
|
||||
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}
|
||||
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}
|
||||
main{width:100%;max-width:620px}
|
||||
h1{font-size:20px;margin:0 0 4px;letter-spacing:.02em}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
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)}
|
||||
p.lede{color:var(--dim);margin:0 0 24px;font-size:13px}
|
||||
label{display:block;font-size:12px;color:var(--dim);margin:16px 0 6px;text-transform:uppercase;letter-spacing:.08em}
|
||||
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{min-height:150px;resize:vertical}
|
||||
button{background:var(--accent);color:#06231a;border:0;font-weight:600;cursor:pointer;margin-top:20px;padding:12px}
|
||||
button:hover{filter:brightness(1.08)}
|
||||
button:disabled{opacity:.5;cursor:not-allowed;filter:none}
|
||||
button.secondary{background:#12161b;color:var(--fg);border:1px solid var(--line);font-weight:400}
|
||||
.row{display:flex;gap:12px}
|
||||
p.lede{color:var(--dim);margin:0 0 22px;font-size:13.5px}
|
||||
textarea,button{font:inherit;width:100%;border-radius:10px}
|
||||
textarea{
|
||||
color:var(--fg);background:var(--inset);border:1px solid var(--line);
|
||||
font:13px/1.55 var(--mono);padding:13px 14px;min-height:132px;resize:vertical;
|
||||
transition:border-color .12s,box-shadow .12s;
|
||||
}
|
||||
textarea::placeholder{color:var(--faint)}
|
||||
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}
|
||||
.out{margin-top:24px;padding:14px;border:1px solid var(--line);border-radius:6px;background:#0f1418;word-break:break-all;font-size:13px}
|
||||
.note{color:var(--dim);font-size:12px;margin-top:10px}
|
||||
.err{color:var(--bad)}
|
||||
.warn{color:var(--warn)}
|
||||
.ok{color:var(--accent)}
|
||||
.out{
|
||||
background:var(--inset);border:1px solid var(--line);border-radius:10px;
|
||||
padding:13px 14px;font:13px/1.6 var(--mono);word-break:break-all;
|
||||
white-space:pre-wrap;user-select:all;
|
||||
}
|
||||
.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}
|
||||
footer{margin-top:28px;color:var(--dim);font-size:11px;border-top:1px solid var(--line);padding-top:12px}
|
||||
a{color:var(--dim)}
|
||||
footer{margin:14px 2px 0;color:var(--faint);font-size:11.5px;line-height:1.5}
|
||||
footer code{font:11px var(--mono);color:var(--dim)}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
<footer>
|
||||
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.
|
||||
The link works once.
|
||||
which browsers never send to a server.
|
||||
</footer>
|
||||
</main>
|
||||
<script>
|
||||
<script nonce="{{.Nonce}}">
|
||||
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
|
||||
// transmitted, and carried only in the URL fragment.
|
||||
//
|
||||
|
||||
@ -1,45 +1,27 @@
|
||||
{{define "content"}}
|
||||
<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">
|
||||
<label for="secret">Secret</label>
|
||||
<textarea id="secret" autofocus autocomplete="off" spellcheck="false"
|
||||
<textarea id="secret" aria-label="Secret" autofocus autocomplete="off" spellcheck="false"
|
||||
placeholder="API key, password, connection string…"></textarea>
|
||||
|
||||
<div class="row">
|
||||
<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>
|
||||
<button id="go">create a secret</button>
|
||||
<p class="note" id="msg">Opens once, then it is gone. Expires in {{.DefaultTTL}} if nobody opens it.</p>
|
||||
</div>
|
||||
|
||||
<div id="result" class="hide">
|
||||
<label>Your one-time link</label>
|
||||
<div class="out" id="link"></div>
|
||||
<div class="row">
|
||||
<button id="copy">Copy link</button>
|
||||
<button id="again" class="secondary">Create another</button>
|
||||
<button id="copy">copy link</button>
|
||||
<button id="again" class="ghost">create another</button>
|
||||
</div>
|
||||
<p class="note warn">
|
||||
This is shown once and is not recoverable — hush cannot rebuild it, because
|
||||
the key it contains was never sent to the server. Copy it now.
|
||||
</p>
|
||||
<p class="note warn">Shown once — hush cannot rebuild it, because the key it
|
||||
carries was never sent to the server.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "script"}}
|
||||
<script>
|
||||
<script nonce="{{.Nonce}}">
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const MAX = {{.MaxCiphertextBytes}};
|
||||
|
||||
@ -66,10 +48,13 @@ async function create() {
|
||||
|
||||
let res, body;
|
||||
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", {
|
||||
method: "POST",
|
||||
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();
|
||||
} catch (e) {
|
||||
@ -82,6 +67,10 @@ async function create() {
|
||||
// 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.
|
||||
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;
|
||||
$("form").classList.add("hide");
|
||||
$("result").classList.remove("hide");
|
||||
@ -101,8 +90,8 @@ $("secret").addEventListener("keydown", (e) => {
|
||||
$("copy").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($("link").textContent);
|
||||
$("copy").textContent = "Copied";
|
||||
setTimeout(() => ($("copy").textContent = "Copy link"), 1500);
|
||||
$("copy").textContent = "copied";
|
||||
setTimeout(() => ($("copy").textContent = "copy link"), 1500);
|
||||
} catch {
|
||||
// Clipboard needs a permission this browser withheld; selecting the text
|
||||
// is a working fallback rather than a dead button.
|
||||
@ -110,7 +99,7 @@ $("copy").addEventListener("click", async () => {
|
||||
r.selectNodeContents($("link"));
|
||||
getSelection().removeAllRanges();
|
||||
getSelection().addRange(r);
|
||||
$("copy").textContent = "Selected — press copy";
|
||||
$("copy").textContent = "selected — press copy";
|
||||
}
|
||||
});
|
||||
$("again").addEventListener("click", () => location.assign("/"));
|
||||
|
||||
@ -1,30 +1,24 @@
|
||||
{{define "content"}}
|
||||
<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">
|
||||
<p class="note">
|
||||
Nothing has been read yet. This page has not touched the secret — opening it
|
||||
is the button below, so a link preview in a chat app cannot consume it.
|
||||
</p>
|
||||
<button id="go">Reveal the secret</button>
|
||||
<p class="note" id="msg"></p>
|
||||
<button id="go">reveal the secret</button>
|
||||
<p class="note" id="msg">Nothing has been read yet. Opening it is this button,
|
||||
so a link preview in a chat app cannot consume it.</p>
|
||||
</div>
|
||||
|
||||
<div id="result" class="hide">
|
||||
<label>The secret</label>
|
||||
<div class="out" id="plain"></div>
|
||||
<div class="row">
|
||||
<button id="copy">Copy</button>
|
||||
<button id="copy">copy</button>
|
||||
</div>
|
||||
<p class="note warn">
|
||||
Destroyed. Reloading this page will not show it again — copy it now.
|
||||
</p>
|
||||
<p class="note warn">Destroyed. Reloading will not show it again — copy it now.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "script"}}
|
||||
<script>
|
||||
<script nonce="{{.Nonce}}">
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// The id comes from the path and the key from the fragment. Neither is
|
||||
@ -90,6 +84,9 @@ async function reveal() {
|
||||
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;
|
||||
$("gate").classList.add("hide");
|
||||
$("result").classList.remove("hide");
|
||||
@ -104,14 +101,14 @@ $("go").addEventListener("click", reveal);
|
||||
$("copy").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($("plain").textContent);
|
||||
$("copy").textContent = "Copied";
|
||||
setTimeout(() => ($("copy").textContent = "Copy"), 1500);
|
||||
$("copy").textContent = "copied";
|
||||
setTimeout(() => ($("copy").textContent = "copy"), 1500);
|
||||
} catch {
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents($("plain"));
|
||||
getSelection().removeAllRanges();
|
||||
getSelection().addRange(r);
|
||||
$("copy").textContent = "Selected — press copy";
|
||||
$("copy").textContent = "selected — press copy";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -3,10 +3,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
@ -19,14 +22,23 @@ type Pages struct {
|
||||
reveal *template.Template
|
||||
}
|
||||
|
||||
// Data is everything a page needs. The limits are passed through so the browser
|
||||
// enforces the same caps the server does and a user learns their secret is too
|
||||
// large before uploading it, not after.
|
||||
// Data is everything a page needs. MaxCiphertextBytes is passed through so the
|
||||
// browser enforces the same cap the server does and a user learns their secret
|
||||
// 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 {
|
||||
MaxCiphertextBytes 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
|
||||
@ -60,14 +72,97 @@ func (p *Pages) Reveal(w http.ResponseWriter, d Data) error {
|
||||
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 {
|
||||
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
|
||||
// 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")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-store, max-age=0")
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
// 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
|
||||
// fragment-adjacent path — to a third party.
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
return t.ExecuteTemplate(w, "base.html", d)
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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
|
||||
# in rdev returns 401, and minting a new one needs a browser login). Rather than
|
||||
# leave "git push does not deploy" as a trap for whoever pushes next, this does
|
||||
# exactly what the pipeline's build+deploy steps do: a Kaniko Job for an amd64
|
||||
# image, then `kubectl set image`, then a real end-to-end check.
|
||||
# Woodpecker IS activated for this repo, so a push to main builds and deploys.
|
||||
# This is the path for when you do not want to wait for CI, when CI is down, or
|
||||
# when you are rolling back — and it is how the first deploy happened, before
|
||||
# activation. It does exactly what the pipeline's build and deploy steps do: a
|
||||
# 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
|
||||
# also the manual path for a rollback or a hotfix when CI is down.
|
||||
# Credentials: none. The Gitea repo is public so the Kaniko git context needs no
|
||||
# token, and the rollout uses your kubeconfig.
|
||||
#
|
||||
# ./scripts/release.sh
|
||||
set -euo pipefail
|
||||
|
||||
Loading…
Reference in New Issue
Block a user