hush/internal/web/web.go
jx12n d7cd57f330
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
serve the MCP install instructions at /mcp
Using hush from an agent needed a clone and docs/MCP.md. It now needs one
command, and the instructions are served by the deployment itself.

`go install github.com/orchard9/hush/cmd/hush-mcp@latest` is the whole
install: cmd/hush-mcp imports only the standard library, so module graph
pruning never reaches the private go-chassis dependency cmd/hushd needs.
Verified against an empty module cache and the public proxy, then create ->
reveal end to end against production with the resulting binary.

The page carries the per-client configuration for Claude Code, Codex CLI,
Gemini CLI, VS Code, Claude Desktop, Cursor and omp. Each command was run
against the installed client rather than copied from documentation, which is
how the differences on it are there at all: VS Code's wrapper key is
`servers`, not `mcpServers`; gemini defaults to project scope, not user;
Claude Code rejects `--env` immediately before the server name.

The shared browser crypto moves from base.html into templates/crypto.html,
which the two pages that encrypt parse and this one does not. An empty
`{{define}}` cannot replace a non-empty one — text/template reads an empty
body as no definition — so the shell holds the call and the partial holds the
code, and the docs page ships no script at all.

Three things this exposed, fixed here:

- The public Ingress enumerates paths, so a handler without one 404s at the
  edge while working in `make dev`. The Ingress is now its own manifest:
  hush.yaml pins a `:bootstrap` image that does not exist, so re-applying it
  to publish a path would roll the workload onto an unpullable image.
  `make deploy-ingress` applies the route alone.
- release.sh guarded HEAD against `@{upstream}`, which is the GitHub mirror
  here, while Kaniko clones Gitea. A commit pushed to one and not the other
  would have built the previous commit silently. It now fetches and compares
  the branch that actually gets built.
- smoke.sh checks that /mcp serves the install command, so a stale rollout or
  an unexecutable template fails the release instead of being found later.
  Confirmed it fails: against production before this deploy it reported 404.
2026-09-05 14:03:34 -06:00

189 lines
7.0 KiB
Go

// Package web serves hush's pages. All of them are static: they read no
// storage, so a link previewer fetching any of them cannot destroy a secret.
package web
import (
"crypto/rand"
"embed"
"encoding/base64"
"fmt"
"html/template"
"net/http"
"strconv"
)
//go:embed templates/*.html
var files embed.FS
// Pages renders the create, reveal and MCP pages. Templates are embedded, so
// the container carries no template directory to go missing at runtime.
type Pages struct {
create *template.Template
reveal *template.Template
mcp *template.Template
}
// 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
}
// 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
// request: a template error is a build defect and should not wait for traffic
// to surface.
//
// The two pages that encrypt parse crypto.html; the MCP page does not, and
// defines the shell's "crypto" block empty instead. An empty definition cannot
// REPLACE a non-empty one — text/template treats an empty body as no
// definition — so the shell holds the call and the partial holds the code.
func New() (*Pages, error) {
create, err := template.ParseFS(files,
"templates/base.html", "templates/crypto.html", "templates/create.html")
if err != nil {
return nil, fmt.Errorf("parse create template: %w", err)
}
reveal, err := template.ParseFS(files,
"templates/base.html", "templates/crypto.html", "templates/reveal.html")
if err != nil {
return nil, fmt.Errorf("parse reveal template: %w", err)
}
mcp, err := template.ParseFS(files, "templates/base.html", "templates/mcp.html")
if err != nil {
return nil, fmt.Errorf("parse mcp template: %w", err)
}
return &Pages{create: create, reveal: reveal, mcp: mcp}, nil
}
// Create writes the create page.
func (p *Pages) Create(w http.ResponseWriter, d Data) error {
return render(w, p.create, d)
}
// Reveal writes the reveal page.
//
// The secret id is NOT passed in and is NOT interpolated into the HTML. The
// page reads it from location.pathname in the browser, alongside the key it
// reads from location.hash. That keeps the template free of any value that
// could be reflected, and means this handler needs no escaping decisions about
// a capability.
func (p *Pages) Reveal(w http.ResponseWriter, d Data) error {
return render(w, p.reveal, d)
}
// MCP writes the page documenting the MCP server: how to install it, how to
// register it with a client, and what the two tools do. It is prose only — the
// template overrides the shell's script blocks to nothing, so this page ships
// no JavaScript at all.
func (p *Pages) MCP(w http.ResponseWriter, d Data) error {
return render(w, p.mcp, 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 any 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 everywhere: a cached create page is harmless, but a cached
// reveal page in a shared proxy would be a copy of a one-time URL.
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.
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
}