253 lines
12 KiB
Markdown
253 lines
12 KiB
Markdown
<!-- go-doc:generated:begin source=go-bananas:docs/security.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
|
|
Imported from docs/security.md in the go-bananas repository.
|
|
|
|
---
|
|
|
|
# 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.
|
|
<!-- go-doc:generated:end -->
|