internal/store/sqlite.go:1177,1192,1221,1245 — the enrollment_tokens.token column holds the raw UUID token. ConsumeToken does WHERE token = ? against the raw string. Compare with operator_api_keys.key_hash, which is SHA-256 hex (constructed in internal/api/middleware.go:51-53).
internal/api/mobile_bundle.go:62-66 sets only Content-Type: application/yaml. The Web-UI sibling at internal/web/handlers.go:1316-1321 sets Cache-Control: no-store, Pragma: no-cache, Expires: 0, X-Content-Type-Options: nosniff — and has a test asserting it. The API path was missed.
internal/web/session.go and internal/web/oidc.go set HttpOnly and SameSite=Lax on every cookie but never Secure. A single plaintext request to the origin (operator on a LAN, mistyped URL, HTTP→HTTPS not strictly enforced, reverse proxy misconfiguration) discloses the session.
internal/web/operators.go:251 — after handleOperatorCreateAPIKey mints a fresh 32-byte bearer token, the redirect points the operator's browser at: /ui/operators/?new_key=&key_name= The raw API key ends up: in the browser's URL history in the Referer header on every cross-origin asset the detail page loads (any third-party SVG/CSS/JS resource the layout pulls in) in any reverse-proxy or load-balancer access log on the path (nginx default combined log captures the query string) in any structured …
internal/pki/resolver.go:36-64 constructs a CAManager with the plaintext ed25519.PrivateKey after unwrapping via the master key; internal/pki/ca.go:13-16 stores it. Callers at internal/api/enroll.go:116, internal/api/updates.go:297, and internal/api/mobile_bundle.go:40 use the manager for one Sign() and drop the reference on function return — but the underlying slice contents are not wiped before release. The keystore package's contract (internal/keystore/keystore.go doc: "Callers MUST zeroise the returned plaintext DEK as soon as it is no longer needed") is not …
Every /ui/* POST / PUT / PATCH / DELETE route processes the request as soon as the session cookie validates. SameSite=Lax on the session cookie prevents most cross-site form submits but does not protect: top-level form-submit navigations from third-party pages (some browsers still send Lax cookies on top-level POSTs) same-registrable-domain attackers (sibling-subdomain XSS, subdomain takeover) the GET /ui/logout route, which a third-party <img src="…/ui/logout"> can force-trigger The admin UI signs …
None of the response paths in internal/web/ or internal/api/ set the standard browser-security headers. grep for Content-Security-Policy, X-Frame-Options, Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy returns zero matches across the codebase.
internal/configgen/generator.go:86,108,119 interpolates the operator-supplied ListenHost and TunDevice fields raw into a text/template that produces the agent's config.yml. internal/web/advanced.go:20-35 accepts both with only strings.TrimSpace — no character or shape validation.
internal/api/audit.go:12 — handleGetAuditLog does no admin check. The route is bearer-auth gated only; any operator API key returns the full audit log via store.ListAuditEntries (up to limit=1000). This includes cross-tenant actor names, host/CA/operator IDs, action timestamps, and masked-IP entries from rate-limit refusals — enough surface for a tenant to enumerate the server's activity, infer staffing patterns, or identify high-value targets.
The /api/v1/* route surface trusts the bearer token alone for authorisation on most endpoints. The codebase itself admits this at internal/api/hosts.go:384: "API trusts the bearer token for authorisation; per-CA ownership is enforced only in the Web layer." The Web UI gates state-changing routes through loadAccessibleCA (internal/web/cas.go); CA-management endpoints in internal/api/cas.go ALSO have proper canAccessCA gates. The gap is on the host, network, firewall, mobile-bundle, and most operator endpoints. Combined with …