hush/vendor/github.com/orchard9/go-chassis/chassis/middleware.go
jx12n 4d9a26498e hush: one-time secret links the server cannot read
Paste a secret, get a link, send it. The first person to open it and press
Reveal sees the secret; the link dies at that moment. The recipient needs a
browser and nothing else — no account, no client, no installed tooling.

The server cannot read what it stores. AES-256-GCM happens in the browser and
the key lives in the URL fragment, which browsers never transmit, so hushd
holds ciphertext and no key material. That is a property of where the key sits
rather than a promise about our conduct, which is why there is deliberately no
endpoint accepting a plaintext secret and no server-side-encryption fallback:
two guarantees behind one URL would be worse than one honest guarantee.

Three decisions carry the design:

  * GET /s/{id} touches NO storage, not even to check existence. Slack, Teams,
    WhatsApp, iMessage and Outlook Safe Links all fetch a URL before a human
    sees it, so destroying on GET would destroy most secrets in transit and the
    recipient's "already used" would be indistinguishable from interception.
    Only POST /reveal consumes. Bot user-agent detection is an arms race;
    removing the side effect from GET is not. Pinned by
    TestGettingTheRevealPageNeverConsumesTheSecret.
  * Destruction is one Redis GETDEL, which is atomic. GET-then-DEL has a window
    where two simultaneous readers both win, and for a one-time secret that
    window is the product. The store contract demands atomicity and the same
    concurrency test runs against both implementations.
  * Missing, already-revealed, expired and evicted are ONE indistinguishable
    410. Separating them would confirm to a prober that a given link was real.

The secret id IS the capability, so secret.ID is a struct whose every
accidental path — %v, %s, String(), slog, json.Marshal — emits a redacted
handle or refuses, and the raw value needs an explicit Value(). The first
version tried to prevent leaks by implementing no String() at all; its own test
caught that Go's fmt prints unexported fields anyway, so forbidding the method
had removed the control rather than the leak.

Operationally: structured JSON on stdout in the fleet's wire format, which
Vector already collects with no annotation; six hush_* metrics on the chassis
registry with no id, IP or path in any label; five alert rules wired into
vmalert. The public Ingress enumerates /, /s/ and /api/ so /metrics, /healthz
and /readyz share the port but are unreachable from the internet — no
basic-auth middleware to maintain and get wrong.

Dependencies are vendored because go-chassis is private: the Woodpecker test
step and the in-cluster Kaniko build both run -mod=vendor with GOPROXY=off and
hold no git credential.

cmd/hush-mcp is a stdio MCP server doing the same client-side crypto locally,
so using hush from an agent preserves the same guarantee as using it from a
browser.
2026-09-03 00:08:38 -06:00

245 lines
8.6 KiB
Go

package chassis
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/orchard9/go-chassis/logging"
)
// chain composes edge middleware so mw[0] is outermost (runs first).
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
// statusRecorder captures the response status for logging/metrics and survives
// double WriteHeader; it forwards Flush so SSE/streaming handlers still work.
type statusRecorder struct {
http.ResponseWriter
status int
wrote bool
// streamed is set by Context.Stream. It keeps a connection an operator
// held open for twenty minutes out of the request-latency histogram.
streamed bool
}
func (r *statusRecorder) WriteHeader(code int) {
if r.wrote {
return
}
r.status = code
r.wrote = true
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wrote {
r.WriteHeader(http.StatusOK)
}
return r.ResponseWriter.Write(b)
}
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap exposes the wrapped writer to http.ResponseController, which is how a
// streaming handler clears the server's WriteTimeout for its own connection.
// Without it ResponseController stops at this wrapper and every deadline call
// returns ErrNotSupported — the response is then cut mid-stream at
// RequestTimeout+socketHeadroom with no error the handler can report.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func newRequestID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b[:])
}
type traceIDKey struct{}
// TraceIDFrom returns the request's trace id, or false outside a chassis-handled
// request. shared/httpclient uses it to propagate traceparent downstream.
func TraceIDFrom(ctx context.Context) (string, bool) {
id, ok := ctx.Value(traceIDKey{}).(string)
return id, ok
}
// traceIDFromHeader extracts the 32-hex trace-id from a W3C traceparent header
// ("00-<32hex>-<16hex>-<flags>"); on absence/malformation it generates one.
func traceIDFromHeader(traceparent string) string {
parts := strings.Split(traceparent, "-")
if len(parts) == 4 && len(parts[1]) == 32 && parts[1] != strings.Repeat("0", 32) {
if _, err := hex.DecodeString(parts[1]); err == nil {
return parts[1]
}
}
return newRequestID()
}
// instrument is the core edge middleware: it assigns/propagates a request_id,
// builds the request-scoped logger, applies the per-request timeout, counts
// in-flight requests, RECOVERS panics (so the access log + metrics observe the
// real 500), and emits one access line + RED metrics. The route label is the
// matched ServeMux pattern (bounded cardinality), never the raw path.
func (a *App) instrument(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = newRequestID()
}
w.Header().Set("X-Request-Id", id)
// Derive the trace id from W3C traceparent (or generate one) so logs +
// downstream calls correlate. trace_id is a regular log field, NEVER a
// stream field (high cardinality).
trace := traceIDFromHeader(r.Header.Get("traceparent"))
w.Header().Set("X-Trace-Id", trace)
log := a.log.With("request_id", id, "trace_id", trace)
ctx := context.WithValue(r.Context(), traceIDKey{}, trace)
ctx = logging.Into(ctx, log)
if a.cfg.RequestTimeout > 0 {
// A deadline cannot be removed from a derived context, so the
// pre-timeout one is carried alongside for Context.Stream: a
// server-sent-events response is open for minutes by design and
// must stay bound to client disconnect and drain, not to the
// per-request budget every other route wants.
ctx = context.WithValue(ctx, untimedKey{}, ctx)
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, a.cfg.RequestTimeout)
defer cancel()
}
r2 := r.WithContext(ctx)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
a.metrics.inflight.Inc()
defer a.metrics.inflight.Dec()
start := time.Now()
func() {
defer func() {
if rv := recover(); rv != nil {
log.Error("panic.recovered", "category", "panic",
"error_type", "panic", "error_msg", fmt.Sprint(rv),
"stack", string(debug.Stack()))
if !rec.wrote {
rec.Header().Set("Content-Type", "application/json; charset=utf-8")
rec.WriteHeader(http.StatusInternalServerError)
// Same envelope as writeError, request_id included. A panic
// is precisely when a user needs an id to quote, so this is
// the worst response to leave it out of.
_, _ = fmt.Fprintf(rec,
`{"error":{"code":"internal","message":"internal error","request_id":%q}}`, id)
}
}
}()
next.ServeHTTP(rec, r2)
}()
route := routeLabel(r2.Pattern)
dur := time.Since(start)
log.Debug("http.request",
"http_method", r.Method, "http_route", route, "http_path", r.URL.Path,
"http_status", rec.status, "http_duration_ms", dur.Milliseconds(),
"http_streamed", rec.streamed)
// A stream's elapsed time is how long an operator left a tab open, not
// how long the service took to answer. Feeding it to the latency
// histogram fired HighRequestLatency the first time somebody opened the
// console and would poison every latency panel for the whole service,
// so a streamed response is counted but not timed.
if rec.streamed {
a.metrics.countOnly(r.Method, route, rec.status)
return
}
a.metrics.observe(r.Method, route, rec.status, dur)
})
}
// routeLabel reduces a matched ServeMux pattern ("GET /v1/x/{id}") to its path
// shape ("/v1/x/{id}") for a bounded metrics/log label — never the raw path.
func routeLabel(pattern string) string {
if pattern == "" {
return "other"
}
if i := strings.IndexByte(pattern, ' '); i >= 0 { // drop the "METHOD " prefix
pattern = pattern[i+1:]
}
return pattern
}
// secureHeaders sets conservative response headers for a JSON API (no inline
// content, no framing). HSTS is emitted only in prod, where TLS terminates.
func (a *App) secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
if a.cfg.Env == "prod" {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
// cors echoes the request Origin when it is on the allowlist and answers
// preflight. A platform serves more than one browser surface (admin, tenant,
// operator), so the allowlist is a set and the response echoes the matched
// origin rather than a single fixed value — echoing a fixed origin breaks every
// UI but one, and echoing the request unchecked is an open CORS policy.
func (a *App) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// The response body depends on Origin whenever an allowlist is
// configured, so Vary is set even on a non-match — otherwise a shared
// cache can serve an allowed origin's response to a denied one.
if len(a.cfg.AllowOrigins) > 0 {
h.Add("Vary", "Origin")
}
if origin := r.Header.Get("Origin"); origin != "" && a.originAllowed(origin) {
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id, Idempotency-Key")
// Without this the browser can send X-Request-Id but cannot READ the
// one we echo back, so a cross-origin SPA has no id to show the user
// next to an error toast. Allow-Headers governs the request; only
// Expose-Headers governs what JS may read off the response.
h.Set("Access-Control-Expose-Headers", "X-Request-Id, X-Trace-Id")
h.Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// originAllowed reports whether origin is on the configured allowlist. Matching
// is exact: no suffix or wildcard matching, because "endswith example.com"
// also matches "evil-example.com".
func (a *App) originAllowed(origin string) bool {
for _, allowed := range a.cfg.AllowOrigins {
if allowed == origin {
return true
}
}
return false
}