resources/plan.md section 4 and the reference documents under
docs/reference/. Related documents:
- bot-and-inflation.md: the full bot and inflation strategy.
- supply-chain.md: build, signing and dependency controls.
- verify-tracker.md: how a tenant verifies and self-hosts
sdk.js. - SECURITY.md: how to report a vulnerability.
- branch-protection.md: repository settings for
main. - ../runbooks/security-patch.md: the 24 hour patch path.
1. Assets
2. Trust boundaries
- Browser to edge (
in.<BASE_DOMAIN>). Everything in the request is attacker-controlled: body,Origin,Sec-Fetch-*,User-Agent. Edge Caddy (deploy/caddy/edge.Caddyfile) setsX-Edge-Client-Ipfrom the TCP peer and removesX-Forwarded-For,X-Real-IpandForwarded. The gateway reads the address from that one header only and refuses the common proxy headers as sources (internal/gateway/clientip.go). - Tenant server to edge (
otlp.<BASE_DOMAIN>). Authenticated by OTLP token; the content is still whatever the tenant sends. - Browser to console (
app.<BASE_DOMAIN>). Session cookie, CSRF header andOrigincheck. - Browser to Umami UI (
web.<BASE_DOMAIN>). Umami token from SSO; Caddy allowlist indeploy/caddy/web.Caddyfile. - Machine clients to the machine API (
/api/metrics/v1/). Operator API key. - Private network. Gateway, collector, replayer, metricswriter, jobs, control plane, Umami,
PostgreSQL and NATS. Each service connects to NATS as its own user with publish and subscribe
allow lists (
internal/natsx/topology.go) and to PostgreSQL as its own role (plan 4.7). Transport (finding O-26 ofdocs/audit/functional-audit-ops.md, fixed 2026-11-13): every hop between nodes is encrypted and authenticated with certificates of an internal CA, one per environment. The CA key lives only in the SOPS fileinfra/secrets/<env>/ca.enc.yaml, which onlyplaybooks/internal-ca.ymlreads on the controller; nodes receive their own key and certificate fromtls.enc.yaml(../deploy/ansible.md section 9 lists every link). NATS client connections are TLS 1.3 from the first byte; NATS routes, etcd peers and clients, the Patroni REST API, pgBackRest, the metrics relay, the exporters and Alertmanager (web and gossip) require a client certificate of the internal CA; PostgreSQL admitshostssllines only and every client (services, Umami, replication,pg_rewind,postgres_exporter) usesverify-full. Services refuse a CA with a weakersslmodeand never fall back to plain text. NATS and PostgreSQL clients still log in with their per-service password on top of TLS; they present no client certificate. Proved by:TestLoadNATSWithTLS,TestLoadNATSRefusesBrokenTLS,TestLoadPostgresTLS(internal/config/tls_test.go);TestConnectWithTLS(internal/natsx/tls_test.go, real NATS 2.15.3: plain, wrong-CA and non-TLS-first clients refused);TestTLSOnlyServer(internal/db/tls_test.go, real PostgreSQL 18.6 withhostsslonly: plain text, foreign CA and wrong name refused);tests/localconverge/tls-audit.shon the local converge (capture of the private network, refusal of a host without a certificate, the end-to-end suite), results in the O-26 resolution row of the ops audit. Residual risk: NATS monitoring is plain HTTP on each edge node’s loopback and Compose network (not on the private network); the operators’ client certificate, if leaked, reads every monitoring endpoint until the CA is rotated (there is no revocation list); traffic inside one node’s Compose network (Caddy to its upstreams, the replayer, control plane and jobs to Umami, the relay to the exporters behind it) is not encrypted, and observing it needs code execution on that node; and Alertmanager’s gossip TLS is marked experimental upstream. - Upstream code. Umami image, Go modules, npm packages, base images, GitHub Actions.
3. Threats
3.1 Tracker compromise
An attacker changes thesdk.js that tenants load, to steal data from tenant pages or inject
code.
Built:
- Releases are immutable files under
packages/tracker/releases/<version>/sdk.js, listed inmanifest.jsonwithsha384and anintegrityvalue. There is nolatestalias. The gateway serves only those committed files withCache-Control: public, max-age=31536000, immutable, and answers 410 for a version inrevoked.json(docs/reference/gateway-limits.md, responses). - The snippet the console hands out carries
integrityandcrossorigin; the control plane refuses a manifest entry whose integrity value is malformed (internal/controlplane/snippet.go). A browser refuses a changed file whose hash does not match. - Tenants may self-host the same bytes with the same integrity hash (plan 4.4 step 17).
pnpm --filter tracker verify-releaseschecks that released files never change, that each rebuilds byte-identical, and the size budget (packages/tracker/scripts/verify-releases.mjs, run by thereleasesjob of.github/workflows/tracker.yml).
TestReleasesServeCommittedTracker and TestReleasesRevokedAndTampered
(internal/gateway/releases_test.go); TestTrackerVersionsAndRevocation,
TestLoadTrackerRefusesBadInput and TestSnippetMatchesTypeScript
(internal/controlplane/snippet_test.go); the verify-releases script.
Not built: provenance attestation of sdk.js (actions/attest), npm publication with trusted
publishing and provenance, and keyless cosign signatures. All of these need GitHub Actions, which
currently ends every workflow in startup_failure on this account. npm trusted publishing and
provenance also need a public repository, and npm publication needs input I9 (an npm scope with
trusted publishing), which has not been supplied. See supply-chain.md.
Residual risk: a tenant who copies the snippet without integrity gets no browser-side check.
Anyone with write access to main can add a new release; review on main is the control
(branch-protection.md), and it is not enforced yet. A compromised edge
node can serve different bytes for a new version, but a browser with the integrity attribute
still refuses them.
3.2 Cross-tenant data leak
A member of tenant A reads or changes tenant B’s data. Built:- Console API: every route under
/api/tenants/{tenant_id}and/api/sites/{site_id}checks membership first and answers 404 to non-members, then runs ascp_tenantinsidedb.WithTenant, where row-level security is forced on every tenant table and no application role hasBYPASSRLS(docs/reference/control-plane-api.mdsection 1.3, plan 4.7). - Umami: each tenant is one Umami team, each user is
view-onlywith team roleteam-view-only. Caddy onweb.<BASE_DOMAIN>makes/api/*read-only from outside and denies the write, admin, share and collection routes for every method (plan 4.9,deploy/caddy/web.Caddyfile), including under/teams/<id>/rewrites. - The tenant id of API metrics comes from the OTLP credential.
tenantguardreplaces resource attributes with an allowlisted set, so a forgedscf.tenant.idis dropped (collector/tenantguardprocessor/guard.go, functionstamp). - Operators see aggregates only and get no Umami access (plan 4.8).
TestUmamiTenantIsolation(tests/isolation/umami/isolation_test.go, build tagisolation): user B calls every operation of Umami’s OpenAPI contract with team A’s ids, on the private network and through web Caddy.TestConsolesubtestsIsolationandWrongTenantSeesNothing(tests/isolation/console/console_test.go) andTestRemovedTenantIsGoneForFormerMembers(tests/isolation/console/removal_test.go).TestTenantIsolation,TestNoTenantSetSeesNothing,TestTenantSeesOnlyItsMembers(internal/db/isolation_test.go);TestPolicyCoverageandTestOpenRefusesPrivilegedLogin(internal/db/migrate_test.go);TestGrantMatrixandTestRoleAttributes(internal/db/privileges_test.go);TestOperatorBoundaries,TestMetricsWriterBoundaries,TestJobsBoundaries(internal/db/boundaries_test.go).TestViewOnlyTeamMemberReadsButCannotChange,TestViewOnlyCannotReadOtherTeamandTestOpenAPIRouteListUnchanged(tests/umami-compat/access_test.go).TestWebCaddyLivesubtestsdeny list,api method allowlist,sso rules(deploy/caddy/web_live_test.go).- Collector:
TestCollectorsubtesthttp cumulative with forged tenant and secret seed(tests/collector/collector_test.go) andTestMetricsFilteredAndStamped(collector/tenantguardprocessor/guard_test.go). - Browser level:
tests/e2e-console/specs/console-isolation.spec.ts.
TestOpenAPIRouteListUnchanged fails when the
route list changes, which forces a review of the deny list before an upgrade.
3.3 Tenant metric inflation
A tenant inflates its own numbers to look more used than it is. Web: covered in full in bot-and-inflation.md. In short, the challenge and proof,Origin and fetch-metadata checks, the per-node replay limit, rate limits, same-IP
quarantine and Umami’s isbot raise the cost and catch naive server posts. They do not stop a
scripted client or real browsers on residential proxies.
API metrics: the tenant controls its own servers and therefore the OTLP data it sends. Nothing
can tell real requests from invented ones. Built limits: per-tenant request and item rate limits
in tenantguard (COLLECTOR_RATE_REQUESTS_PER_SECOND 50, burst 100;
COLLECTOR_RATE_ITEMS_PER_SECOND 50,000, burst 200,000, per tenant and node, from
infra/ansible/roles/edge/defaults/main.yml), daily route and service cardinality caps in the
metricswriter (routes beyond the cap become other with a cardinality_overflow warning,
internal/metricswriter/store.go), and dashboards that label span-derived data.
Proved by: TestRateLimit and TestLimiterSharedAcrossSignals
(collector/tenantguardprocessor/guard_test.go); TestCollector subtest
rate limit 429 then sdk retry succeeds; the metricswriter subtest
routes beyond the daily cap become other with a warning (internal/metricswriter/it_test.go).
Residual risk: API metrics are self-reported. The platform can bound volume and cardinality but
cannot verify that requests happened. Operators should read API numbers as the tenant’s own
claim. On-chain corroboration was considered and left out by the project owner (plan section 2).
3.4 Sabotage by a rival
A rival hurts another tenant’s numbers or standing. Vectors and what limits them:- Inflating the victim’s site so it looks manipulated. Site ids are public (they are in the
snippet), so anyone can fetch a challenge from an allowed origin header and post. The same
limits as 3.3 apply. Quarantined traffic lands in a separate Umami website, never in the
primary numbers, and per-site outcome counters (
accepted,quarantined,bot,rejected_<reason>) are visible to the tenant and operators (docs/reference/control-plane-api.mdsection 4.3.7). - Starving the victim’s rate limit.
RATE_SITE_PER_SECOND(200, burst 400) is one bucket per site per node, shared by all visitors of the site. A refusal by the per-prefix bucket (RATE_PREFIX_PER_SECOND20, burst 60) does not take a site token (TestRateLimitPrefixRefusalKeepsSiteTokens,internal/gateway/ratelimit_test.go), so one address prefix cannot drain the site bucket. An attacker with more than about ten prefixes per node can, and then real visitors of that site get 429 on that node. - Pushing real visitors into quarantine. An attacker sharing an address prefix with real visitors (same /24 or /48) can send 20 distinct visitor tokens and quarantine that prefix for the site for an hour. Real visitors elsewhere are unaffected.
- Claiming the victim’s domain. Sites carry a domain ownership check and a stellar.toml signal
(phase 7,
docs/reference/control-plane-api.mdsections 5.8 and 5.12). An unverified claim is visible to operators. Even an accepted claim gives the rival only its own site id; events of the victim’s snippet carry the victim’s site id. - Getting the victim removed. Removal is an operator decision with a required reason and an audit row (section 5.5 of the contract).
TestRateLimitSite, TestRateLimitPrefixBurstAndRefill,
TestRateLimitPrefixRefusalKeepsSiteTokens, TestRateLimitBoundedKeys
(internal/gateway/ratelimit_test.go); TestEdgeSecretsCluster subtest
quarantine starts at exactly the threshold across three gateways
(internal/gateway/visitor_test.go); TestWebPath subtest quarantine
(tests/webpath/webpath_test.go); TestVerifyRoutes, TestCheckDNS, TestCheckFile
(internal/controlplane/domaincheck/); TestRemovedDomainReview
(tests/enrollment/review_test.go).
Residual risk: a resourced rival can distort a victim’s numbers or rate-limit its visitors for
as long as the attack lasts. Detection is by the outcome counters; there is no automatic
attribution of an attack.
3.5 Token theft
3.6 Umami and dependency vulnerabilities
Built:- Umami runs unmodified as
ghcr.io/umami-software/umami:3.5.0pinned by digest (deploy/compose/dev.yml,upstream_imagesin Ansible). Base images are pinned by digest (deploy/images/*.Dockerfile). GitHub Actions are pinned by commit SHA (.github/workflows/*.yml). - Umami is never exposed for collection or writing: only the replayer and control plane reach
its write routes on the private network. Web Caddy strips
X-Middleware-Subrequest(the header behind CVE-2025-29927) along with every client address header. - Umami has no outbound access in normal operation, which
TestWebPathsubtestsegress from boot to hereandno outbound calls during a ui sessioncheck. - Go services run as
nonrooton distroless images; Caddy as user 10001 (deploy/images/).
- Daily Trivy against deployed digests,
govulncheck,pnpm audit, an hourly upstream advisory check, Dependabot, gitleaks and a licence scan. These are GitHub Actions workflows and run only once GitHub Actions works on the account. make umami-upgrade VERSION=runningtests/umami-compat/, the isolation suite and the end-to-end suite before rollout, and the patch path in ../runbooks/security-patch.md.- Read-only container filesystems and dropped capabilities: done for the deployed services.
The edge, core and observability compose templates under
infra/ansible/roles/*/templates/setread_only: true,cap_drop: [ALL]andno-new-privilegeson every service, with a small/tmptmpfs. Not set in the local-converge-only backup repository template (roles/backup) or in the development stackdeploy/compose/dev.yml, which never run in staging or production.
3.7 Ingest denial of service
Built:- Event bodies are capped at 8 KiB (
GATEWAY_MAX_BODY8192; 413 above). OTLP HTTP bodies at 4 MB on the wire in edge Caddy, and the collector checks size again after decompression. - Per-site and per-prefix token buckets on events; per-tenant request and item buckets in the
collector;
memory_limiterin the collector. - Every in-memory table is bounded: 200,000 entries each for the replay table, the IP-cluster
sets and each rate limit table (
internal/gateway/node.go,ratelimit.go). - Ingestion does not depend on PostgreSQL or Umami: events are queued in JetStream (R3, 72 hours
for
WEB_EVENTS), so a database outage does not drop events. - Three edge nodes in three locations.
TestRateLimitBoundedKeys, TestRateLimitConcurrent
(internal/gateway/ratelimit_test.go); TestEventChecks subtest oversized body is 413
(tests/gateway/checks_test.go); TestCollector subtests oversize and decompression bombs
and nats stopped gives 503 and recovers without a gap; TestQueueOutage
(tests/gateway/cluster_test.go); TestWebPath subtests outage umami stopped and
outage postgres stopped; TestThroughputK6 (tests/gateway/throughput_test.go, make gateway-load, 2,490 requests per
second at p99 1.48 ms on one gateway).
Residual risk: there is no rate limit on GET /v1/c (challenge) in the gateway or in edge Caddy;
the limiter runs only on POST /v1/e. A challenge costs one HMAC and is cheap, but the route is
unbounded. Neither edge Caddy nor core Caddy has a rate limiter; volumetric attacks are left to
the network provider. The sign-in start and callback routes limit each client on its own (a keyed
hash of the address core Caddy passes, held only in memory) under a per-instance ceiling, and each
OAuth state reaches the code exchange once (internal/controlplane/auth/ratelimit.go, control
plane API 4.1.1). A client is an IPv4 address or an IPv6 /48, so one site’s whole allocation is
one client. One client cannot lock others out; a flood from about 100 or more addresses or /48s
can still hold the ceiling (50 starts per second per instance), but a person who had been idle
then takes from a separate reserve that busy keys cannot reach (audit N-2). Only a flood with a
fresh address or /48 for every request, at more than the reserve’s 5 per second, delays
sign-in for everyone.
3.8 Operator credential compromise
An attacker takes over an operator’s GitHub account or console session. What an operator can do: approve, reject, suspend, resume and remove tenants; change enrollment mode and the removal setting; create and revoke operator API keys; read aggregates. An operator cannot read raw events or Umami data and has no Umami admin (plan 4.8). Built:- Operator status needs both
cp.users.is_operator = true(read per request) and agithub_idinOPERATOR_GITHUB_IDS; anything else is 403operator_required. Removing an id takes effect at that person’s next sign-in or the next control plane start. - Every action writes an audit row;
cp_systemcannot update or deletecp.audit_log. - Actions need a reason (remove, reject, suspend) and are refused as 409 on a repeat.
- Sign-in is GitHub OAuth only, with PKCE S256 and state, no scopes requested.
TestGuardAllowed (internal/controlplane/operator/guard_test.go);
TestGlobalRoutesNeedAnOperator (internal/controlplane/operator/global_umami_test.go);
TestSyncFlags (internal/controlplane/operator/tenants_test.go); TestAuditLogAppendOnly
(internal/db/boundaries_test.go); TestOperatorFlag (internal/controlplane/auth/);
tests/e2e-console/specs/operator-lifecycle.spec.ts.
Residual risk: operator security equals the security of the operator’s GitHub account; the
platform cannot require GitHub two-factor authentication. One operator alone can remove a
tenant, and removal purges the tenant’s Umami websites and data; there is no second approval and
no undo inside the application. Recovery depends on database backups (phase 16, not complete).
A compromised operator can also mint a global API key and read every tenant’s aggregates until
it is revoked.
3.9 Edge node compromise
An attacker gets code execution on an edge server. Exposure: the node sees raw client addresses in memory, holds the day’s secrets fromedge_secrets, and the gateway and collector NATS users on the node can publish to
WEB_EVENTS and API_METRICS, bypassing every check. The node holds no database credentials: the edge tier has no dependency on PostgreSQL.
Built: separate NATS users with allow lists per service (internal/natsx/topology.go), tested by
TestGatewayEdgePermissions (internal/gateway/visitor_test.go); secrets never touch disk
(memory KV); SSH limited to admin CIDRs (input I13) by the firewall in phase 15.
Residual risk: a compromised edge node can inject arbitrary events and API metrics for any
tenant and can link visitor tokens to addresses for the current day.
3.10 Server-side request forgery through domain checks
The control plane andjobs fetch files from tenant-supplied domains (domain verification and
stellar.toml). Built: internal/netguard resolves the host, refuses every non-public address
(including IANA special-purpose ranges and NAT64), connects only to the checked address and
re-checks before connect, caps size, time and redirects, and ignores proxy environment
variables. Proved by TestRefused, TestRefusedAddresses, TestRefusedNames,
TestDNSRebinding, TestRedirects, TestOversize, TestSlow, TestIgnoresProxyEnvironment
(internal/netguard/guard_test.go). Residual risk: none known beyond bugs in that package.
3.11 Personal data leaking into storage or logs
Built: the client IP never leaves gateway memory; Caddy log filters delete addresses, user agent and forwarding headers (edge) and all request headers, query strings and response headers (app, web); the replayer sends Umami the constant192.0.2.1; internal/redact replaces all ten
SEP-23 strkey types, email and federation addresses, with S secret seeds becoming
{redacted-secret}; the referrer is cut to scheme and host. Proved by TestNoAddressAnywhere
(tests/gateway/privacy_test.go), TestNoMarkerAddressAnywhere (tests/privacy/privacy_test.go),
TestEdgeAccessLogHasNoClientIdentity (deploy/caddy/edge_test.go), TestWebCaddyLive subtest
access log, TestClientIPNeverPrints (internal/gateway/clientip_test.go), TestWebPath
subtest privacy dump, TestCollector subtest secret seed absent from collector logs, and
internal/redact/redact_test.go. Details: docs/compliance/.
4. Gaps that need the owner
- GitHub Actions fails at startup for every workflow on the account, so no CI check, scheduled scan, signing or attestation runs. Every “proved by” above was run locally.
- The repository is private. npm provenance and trusted publishing need it public; the RFP requires it public. Making it public needs the owner’s explicit confirmation.
- Input I9 (npm scope with trusted publishing) is missing.
- Branch protection is documented but not applied (branch-protection.md).
- Private vulnerability reporting must be enabled by the owner (SECURITY.md).