Skip to content

Security: nyblnet/telperion

SECURITY.md

Security policy

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.

Reporting a vulnerability

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.

Supported versions

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.

The security model

Understanding the trust boundaries matters more than any individual control. ARCHITECTURE.md has the diagrams; docs/adr/ has the reasoning.

What is enforced

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 Hostand 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.

What is not a boundary

Be clear-eyed about these. They are design consequences, not oversights.

  1. 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 /apps can 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.

  2. The contract bounds Laurelin, not egress. There is no egress allowlist around an app's own container. An app with no backends block 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.

  3. The compose stack is a development stack. deploy/compose and the root Dockerfile run the control plane as root with the container runtime's socket mounted, which is root-equivalent on the host, because TELPERION_RUNTIME=compose starts 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.

  4. Group membership is as good as your IdP. Access is access.groups intersected with the groups on the viewer's session, which come from the OIDC groups claim (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.

  5. The 60-second identity JWT is a bearer token for its minute. Anything that can read the X-Telperion-User header — 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.

  6. 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's max_apps, a role's CONNECTION LIMIT) is in scope.

  7. No encryption at rest. Use encrypted volumes or an encrypted Postgres. The registry holds session hashes, per-app database passwords and OAuth2 client records.

  8. 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.

Carried code: some reports belong in Laurelin

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.

Deploying safely

  • 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-Origin CSRF check both depend on this.
  • Terminate TLS at the ingress and make sure X-Forwarded-Proto reaches the app; __Host- cookies require Secure.
  • Give POST /apps to 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 NOBYPASSRLS and no superuser. Put the registry on its own DSN, never an app pool.
  • Set a real CONNECTION LIMIT and max_apps per pool; they are the only resource bound that exists today.
  • Verify X-Telperion-User in 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).

Scope

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.

There aren't any published security advisories