go-bananas/docs/security.md
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

12 KiB

Security

The threat model, what is defended, and what is not.

Contents

Summary

The service holds design tokens and theme history. The sensitive assets are, in order: the OIDC client secret, the session signing secret, and the administrative log.

The highest-impact compromise is an attacker gaining administrator access, which would let them publish a theme and therefore restyle every consuming application. A published theme cannot execute script — it is CSS and JSON — so the ceiling on that compromise is phishing and data exfiltration through a convincing lookalike UI, not code execution in a consumer's page. That ceiling is a real mitigation and is the main reason this design is acceptable for a low-assurance service.

Trust boundaries

  ┌─ untrusted ────────────────────────────────────────────┐
  │  the internet: browsers, crawlers, whoever             │
  └───────────────────────┬────────────────────────────────┘
                          │  /rt/v1 is public and unauthenticated
  ┌─ semi-trusted ────────▼────────────────────────────────┐
  │  consuming applications: they fetch CSS and JSON       │
  │  authenticated operators: they edit and publish        │
  └───────────────────────┬────────────────────────────────┘
                          │
  ┌─ trusted ────────────▼────────────────────────────────┐
  │  the service process, the database file, the kube API │
  └────────────────────────────────────────────────────────┘

The runtime API is untrusted input from the internet that produces trusted output (CSS served to many browsers). That boundary gets the most attention: input validation, output escaping and header hardening.

Authentication

One credential: an OIDC session cookie.

Authorization code flow with PKCE (S256) and a nonce. The transaction state lives in a signed cookie, so any replica can complete the flow and no shared session store is needed. The state the provider echoes back is compared against the value this service minted for that browser and stored in the same cookie, so a callback carrying a foreign code or a replayed state is rejected before the code is exchanged.

There is no bearer-token path. Machine credentials were removed: the runtime API is public by design and the administration API is for people, so a request carrying an Authorization header is treated exactly like one without it.

Development bypass last, and only if it can exist. AUTH_MODE=dev and AUTH_MODE=none are refused by the constructor when the environment is not a development one. This is enforced in code rather than documented, because an accidentally deployed bypass is a total authentication bypass:

if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
    return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
}

config.Validate refuses the same combinations a second time, and the Helm chart refuses skipTLSVerify in production.

Provider misconfiguration fails closed

A user in no configured group gets viewer. A group mapping typo produces read-only access, not an error and not administrator access.

Authorisation

Three roles, checked in the server on every admin route.

viewer editor admin
Read themes, tokens, history Yes Yes Yes
Validate tokens, contrast, palettes Yes Yes Yes
Create, edit, publish, rollback No Yes Yes
Duplicate, import, export No Yes Yes
Delete a theme No No Yes
Users, settings, audit No No Yes
Rebuild the render cache No No Yes

The whole admin subtree is behind one RequireAuthMiddleware, so a route added later is authenticated by default rather than by remembering. Role checks are per route, and an over-permissive one is visible in the route tree rather than buried in a handler.

The UI hides what the caller cannot do, as a convenience. The server enforces it.

Session handling

A signed cookie. See ADR 0003 for the reasoning.

Property Value Why
HttpOnly always Script cannot read it, so an XSS bug cannot exfiltrate a session
Secure outside development Not sent over plain HTTP
SameSite Lax by default Not sent on cross-site POSTs. None requires Secure, enforced
Signature HMAC-SHA256 Constant-time comparison, so a prefix cannot be brute-forced
Expiry 8 hours, checked server-side The cookie's Max-Age is a client hint, not a control
Absent roles default to viewer A cookie carrying no roles carries no privileges

The post-login redirect is restricted to a local path. An absolute returnTo would be an open redirect that also primes a phishing flow: a crafted login link that, after a successful authentication, bounces the user to an attacker's site with a freshly minted session.

Bearer tokens in localStorage are not used, deliberately. A token in localStorage is readable by any script on the page, so one XSS bug becomes a credential theft. An HttpOnly cookie is not script-accessible.

Accepted limitation. A stolen session cannot be revoked before it expires. The window is BANANAS_SESSION_TTL, eight hours by default. Full invalidation means rotating the signing key, which logs out everyone. For a design-token service this is the right trade; if your policy requires per-session revocation, this is the one component to replace.

Generated code

The integration endpoint produces source that a developer pastes into their own application, so it is treated as an output boundary:

  • The slug must name an existing theme, and must match [a-z0-9-] before it reaches a template. The existence check is a precondition, not the security control.
  • Every interpolated value is escaped for the target language. HTML targets use html.EscapeString; JavaScript and TSX targets go through jsString, which quotes the value as a string literal and escapes backslash, quote, newline, tab and the three HTML-significant characters. The JavaScript and TSX paths had no escaping at all, so a quote in a slug produced a snippet that broke out of its literal — an injection into the consuming application, reachable without authentication.
  • An unknown target is a 400 rather than a silent fallthrough.
  • Snippets embed this deployment's absolute URL, so the response is no-store.

Input handling

Input Treatment
SQL Every sortable column and filter field goes through an allow-list. No caller string is ever interpolated
LIKE patterns Escaped, so a user-supplied % is a literal rather than a wildcard
Request bodies Capped at 2 MiB, rejected earlier on Content-Length
JSON Unknown fields rejected, so a client typo is reported rather than ignored. Trailing content rejected
Token values Type-checked against the declared kind. Colours parsed and validated
Slugs Regex-constrained, and checked against the reserved set
Post-login redirect Local paths only

Colour values are a subtle case: they are emitted into CSS that browsers execute as declarations. Validation runs before publication, and a value that cannot be parsed is rejected rather than passed through. Values that parse but are opaque to the engine (oklch(), color(), keywords) are emitted verbatim, which is correct: the browser is the authority on CSS syntax, and the engine is not in a position to second-guess it.

Output handling

  • JSON is produced by encoding/json, which escapes <, > and & by default.
  • The HTML embed snippet interpolates through html.EscapeString, even though the values are operator-controlled.
  • Token documents are data, not templates. Nothing in the service evaluates a token value.

HTTP hardening

On every response:

Header Value
Content-Security-Policy default-src 'self', no unsafe-eval, no wildcard script source
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Cross-Origin-Opener-Policy same-origin
Permissions-Policy geolocation, microphone and camera denied
Strict-Transport-Security Only when served over HTTPS

The CSP is strict enough that the admin UI needed one exception, style-src 'unsafe-inline', because the theme editor injects a live preview stylesheet. Script sources are 'self' with no exceptions.

X-Forwarded-For is believed only from a configured proxy CIDR. Without that configuration, any client could set it and spoof its address in the audit log — so the default is to ignore forwarding headers entirely and record the direct peer.

Secrets in Kubernetes

  • Secrets come from the environment, injected from a Secret.
  • Never a values file, a ConfigMap, a command-line flag or a committed file. A ConfigMap is world-readable in its namespace and appears in kubectl describe.
  • config.Redacted() exists so the configuration can be logged with secrets masked. There is a test that a redacted configuration contains no secret material.
  • The session secret is at least 32 bytes, and an ephemeral one is generated in development rather than demanding configuration that does not matter locally.
  • The chart-managed Secret uses helm.sh/resource-policy: keep, so an uninstall does not destroy the session key.
  • The chart reads a previously generated secret back on upgrade, so a rollout does not silently log everyone out.
  • The ServiceAccount token is not mounted. The service talks to nothing inside the cluster.

Supply chain

  • Themes are not code. A theme cannot execute script in a consumer's page. This is the largest single reduction in risk this design achieves: a token file has no transitive dependencies.
  • No runtime SDK is installed by consumers. They are served from this origin, so there is no npm supply chain in the integration path at all.
  • Pinned Go and Node dependencies, go.sum and package-lock.json committed, verified at build time.
  • A static binary with no libc, so no C library CVEs ride along.
  • Cosign keyless signing of release artifacts, and an SBOM per archive.
  • Renovate or Dependabot for updates, merging only on a green build.

An accepted residual risk

A published theme controls the appearance of every consuming application. A compromised administrator could publish a theme that impersonates a login screen and harvests credentials typed into it. This is a phishing-class risk inherent to any central theming service.

Mitigations: publish is a distinct action from edit, with validation in front of it; every publish is audited with an actor and an IP; and the runtime exposes X-Theme-Version so a consumer can detect an unexpected change. It is not eliminated, and it is the reason publisher access should be held narrowly.

Reporting a vulnerability

Report privately to the platform team. Please do not open a public issue.

Include the affected version, the reproduction, and the impact. Expect an acknowledgement within two working days and an assessment within five.

We will credit the reporter unless anonymity is preferred.