initial commit
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

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

247
docs/security.md Normal file
View file

@ -0,0 +1,247 @@
# Security
The threat model, what is defended, and what is not.
## Contents
- [Summary](#summary)
- [Threat model](#threat-model)
- [Trust boundaries](#trust-boundaries)
- [Authentication](#authentication)
- [Authorisation](#authorisation)
- [Session handling](#session-handling)
- [Input handling](#input-handling)
- [Output handling](#output-handling)
- [HTTP hardening](#http-hardening)
- [Secrets in Kubernetes](#secrets-in-kubernetes)
- [Supply chain](#supply-chain)
- [Reporting a vulnerability](#reporting-a-vulnerability)
## 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:
```go
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](adr/0003-stateful-sessions.md) 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.