Telperion stands between an app somebody (or some agent) just wrote and the data in Laurelin. The whole point of the contract is that an app gets exactly the reach it declared and no more, so this document states plainly what is enforced, what is not a boundary, and how to report a problem.
Telperion carries four modules from Laurelin. Some reports belong upstream — see Carried code below.
Please do not open a public issue for a security bug.
Preferred: GitHub's private vulnerability reporting on this repository (Security → Report a vulnerability), which opens a private advisory visible only to maintainers.
Private vulnerability reporting is the only channel: it reaches the maintainers directly, keeps the report private until a fix is out, and gives us a place to publish the advisory. There is no security mailing list.
Please include: what you found, how to reproduce it, the version or commit
(telperion --version prints both), whether the deployment was the compose dev
stack or a real one, and what an attacker gains. A proof of concept helps
enormously.
What to expect
| Acknowledgement | within 3 business days |
| Initial assessment (severity + plan) | within 10 business days |
| Fix for high/critical | prioritized over feature work |
| Public advisory | when the fix ships, or 90 days after the report, whichever comes first |
| Credit | in the advisory and the changelog, unless you'd rather not |
Telperion is pre-alpha and volunteer-maintained: we will be honest with you about timelines rather than promise an SLA we can't keep. If a report is serious and we're slow, escalate by opening a public issue that says only "privately reported security issue awaiting response", with no details.
We consider good-faith security research a contribution, not an attack. We won't pursue action against researchers who follow this policy, act in good faith, and avoid privacy violations, data destruction, or service disruption.
| Version | Supported |
|---|---|
the latest 0.x release |
✅ security fixes |
any earlier 0.x release |
❌ no backports |
main (untagged) |
✅ fixes land here first |
Telperion is pre-1.0 and has no tagged release yet; the first tag establishes
the table above. Versions come from git tags (ADR-0015), so "the latest
release" means the highest v* tag, and its image is
ghcr.io/<owner>/telperion:<version>. There are no backports to older 0.x
releases — the fix for a reported bug is an upgrade. When 1.0 ships, this
section will state a real support window.
Published images are rebuilt only on a tag, so a base-image CVE in an old tag is fixed by the next release, not in place.
Understanding the trust boundaries matters more than any individual control.
ARCHITECTURE.md has the diagrams; docs/adr/ has the reasoning.
Apps never run on Laurelin's origin. Each app gets its own host under a
separate registrable domain from the control plane (ADR-0003, ADR-0005). There
is no parent-domain cookie: the control-plane session cookie
(__Host-telperion_session) is scoped to the control-plane host, and each app
host receives its own __Host-telperion_app cookie through a single-use,
60-second, nonce-bound code exchange. An app origin therefore cannot toss a
cookie into the control plane's scope, which is what would otherwise defeat
double-submit CSRF and enable session fixation.
Viewer identity is carried, not asserted by the app. The proxy calls
/auth/check (forward-auth) on every request. Telperion resolves the per-host
cookie, intersects the viewer's groups with the app's declared access.groups,
and mints a 60-second ES256 JWT with an audience, a jti and a kid, passed
as X-Telperion-User. The app verifies it against Telperion's JWKS. An app that
skips verification is trusting its proxy; an app that is reached without the
proxy has no identity at all.
CSRF is two independent checks. The Origin header must match the
configured control-plane origin exactly — scheme, host and port
(TELPERION_ORIGIN), not the request's own Host — and the X-CSRF-Token
header must equal the readable __Host-telperion_csrf cookie. Both apply to
POST/PUT/PATCH/DELETE authenticated by the session cookie. Bearer
requests are exempt from both, because a bearer token is not ambiently attached.
Telperion never holds a Laurelin credential. Each app is its own OAuth2
public client in Laurelin, registered at telperion register with scopes
derived from the contract (ADR-0004). Browsers run authorization code with PKCE
S256 directly against Laurelin, which issues the short-lived scoped token and
records the client_id in its own audit log. Tokens live in SDK memory, never in
storage. The registrant's own Laurelin token is used once, at registration, and
discarded. A total compromise of Telperion cannot mint a Laurelin token — it
can register clients, which is a different and lesser capability.
Scopes describe an ontology surface, not an API. A scoped token admits object types and actions from the contract, and nothing that would read past the contract through a side door such as ad-hoc query or export (ADR-0006). The scope-to-surface table is a test fixture, not prose.
Storage is one database and one role per app. The provisioner role is
CREATEDB CREATEROLE NOSUPERUSER NOBYPASSRLS and nothing more. Each app gets
CREATE ROLE app_<name> LOGIN NOINHERIT … CONNECTION LIMIT n, its own database
owned by that role, and REVOKE CONNECT FROM PUBLIC, so one app's credential
does not open another app's database. There are negative isolation tests for
exactly that. The registry's own DSN is never one of the app pools (ADR-0008).
Uploaded bundles are treated as hostile input. Size, entry count, path
traversal, symlinks and entry types are all rejected, with a test for each. A
bundle is served by the static-server image (Caddy over /srv, no directory
listing), not executed.
Failures are typed and never quote the machinery. Everything a caller can see
goes through Failure.safe_detail() — a code and a phase from closed enums.
No DSN, token, driver sentence or stack trace reaches an API response. This is
the same rule Laurelin arrived at the hard way, carried over deliberately.
Secrets are stored as hashes. Sessions and API tokens are stored only as hashes; a registry dump does not yield usable credentials. Per-app database passwords are generated, not chosen.
Be clear-eyed about these. They are design consequences, not oversights.
-
Registering an app is running code on your infrastructure. A container image or a bundle you register is executed (or served) by your runtime, on your network, with a database of its own. Telperion bounds what an app can reach in Laurelin; it does not sandbox the app's own process. Whoever can call
POST /appscan run code in your cluster. Treat registration rights as you would treat deploy rights, because they are the same thing. This is the single most important line in this document. -
The contract bounds Laurelin, not egress. There is no egress allowlist around an app's own container. An app with no
backendsblock still has whatever network its runtime gives it. Apply NetworkPolicy (Kubernetes) or network scoping (compose) if your apps are not trusted with your internal network. -
The compose stack is a development stack.
deploy/composeand the rootDockerfilerun the control plane as root with the container runtime's socket mounted, which is root-equivalent on the host, becauseTELPERION_RUNTIME=composestarts app containers by driving that socket. It also ships a checked-in Keycloak realm and a dev init that generates keys. Never expose it to a network you do not control. The production path is the Helm chart. -
Group membership is as good as your IdP. Access is
access.groupsintersected with the groups on the viewer's session, which come from the OIDCgroupsclaim (ADR-0007). Telperion has no local role model and no local passwords: it cannot correct an IdP that puts the wrong person in a group. Groups are frozen onto an API token when it is minted, so a group removal takes effect on a session's next request but not on an already-issued token before its expiry. -
The 60-second identity JWT is a bearer token for its minute. Anything that can read the
X-Telperion-Userheader — a sidecar, a log, a proxy that mirrors headers — is the viewer to that app for up to 60 seconds. Do not log request headers. -
There is no general rate limiter yet. Rate and resource limits are milestone 1.5 (T36 in the design doc). Today a caller who issues many cheap authenticated requests is unbounded, and app registration is bounded only by the pools'
max_apps. A report that says "no rate limit exists" is describing a documented gap; a report that shows a way past a limit that does exist (a pool'smax_apps, a role'sCONNECTION LIMIT) is in scope. -
No encryption at rest. Use encrypted volumes or an encrypted Postgres. The registry holds session hashes, per-app database passwords and OAuth2 client records.
-
Multi-tenancy is soft. Apps are isolated by host and by database, and they share a control plane, a proxy and, unless you declare separate pools, a Postgres server. Treat the boundary between apps as an organizational one, not a hostile-tenant sandbox.
Four modules are copied from Laurelin under the same licence and pinned to a
commit in docs/CARRY-OVER.md: telperion/core/oidc.py,
telperion/core/auth.py, telperion/core/csrf.py and
telperion/core/failure.py (plus tokens.css in the UI). CI diffs them against
Laurelin main and fails on unacknowledged drift.
If you find a bug in the carried logic rather than in Telperion's divergence
from it, Laurelin is affected too. Report it to whichever project you found it
in and say so; the maintainers coordinate the two advisories and will not
disclose one before the other is fixed. The divergence column in
docs/CARRY-OVER.md tells you which side a given line belongs to — for example,
the cookie names and the exact-origin CSRF rule are Telperion's, while the OIDC
flow and PKCE are Laurelin's unchanged.
- Run the control plane on its own registrable domain, separate from the
apps domain, and never on a subdomain of it.
__Host-cookies and the exact-OriginCSRF check both depend on this. - Terminate TLS at the ingress and make sure
X-Forwarded-Protoreaches the app;__Host-cookies requireSecure. - Give
POST /appsto the people you would give cluster deploy rights to, and to nobody else (boundary 1). - Keep the compose stack off any shared network (boundary 3).
- Give the provisioner role
CREATEDB CREATEROLE NOSUPERUSER NOBYPASSRLSand no superuser. Put the registry on its own DSN, never an app pool. - Set a real
CONNECTION LIMITandmax_appsper pool; they are the only resource bound that exists today. - Verify
X-Telperion-Userin the app against Telperion's JWKS — audience and expiry both — rather than trusting the header's presence. Do not log request headers (boundary 5). - Keep the signing key in a Secret, not in
values.yaml, and rotate it through JWKS rather than by restarting with a new one. - Watch the changelog and take the newest tag; there are no backports (Supported versions).
In scope: authentication and session handling; the forward-auth path and the
X-Telperion-User JWT (forgery, replay past expiry, audience confusion); the
per-host cookie exchange (nonce binding, code reuse, redirect handling); CSRF;
an app reaching Laurelin data outside the scopes its contract declared;
privilege escalation between apps, between an app and the control plane, or
between a viewer and an operator; cross-app database access; bundle upload
handling (traversal, symlinks, zip bombs); the proxy and runtime renderers
emitting a config that drops the forward-auth gate or the reserved
/.telperion/* prefix; and secret exposure through an API response, a rendered
config, or a failure detail.
Out of scope: attacks by a principal who can already register an app (see boundary 1); anything requiring the compose dev stack's root-plus-socket posture (boundary 3); an IdP that asserts wrong group membership (boundary 4); the absence of rate limits (boundary 6, a documented milestone 1.5 gap); denial of service by volume alone; social engineering; and vulnerabilities in dependencies with no demonstrated exploit path through Telperion.
Laurelin's own boundaries are Laurelin's. A report that an app with a valid scoped token can read what that scope allows is the design working. A report that an app can read past its scope is in scope for one of the two projects — send it and we will work out which.