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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
247
docs/security.md
Normal file
247
docs/security.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue