Files
base/docs/threat-model.md
zeekay 4506925bff Mirror a superuser only for the admin org's own people
hanzoai/authz v1.10.42 decides Sudo on the person's own org, the first
entry of orgs, so a brand org's user holding an admin-org membership no
longer mirrors onto _superusers, and that membership no longer selects
another tenant's Base or the admin org's.

The fixtures that stood for a SuperAdmin now carry admin first, and
TestIAMSuperuserMirror, TestOperatorMayReachATenant and
TestOperatorReachesTheOrgItNames each refuse the brand member beside
the operator they admit.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
2026-09-27 19:34:54 -07:00

6.0 KiB

What Base defends, and what you configure

Base is one binary holding many tenants' data. This is what it defends on its own, what it deliberately leaves to the deployment, and the knob for each. Read it before Base listens on anything but loopback.

Nothing here is a finding. Every line is a standing property of the software with the thing you do about it.

The boundary

Identity comes from Hanzo IAM and from nowhere else. Base hosts no login and stores no password. A request is whoever IAM's signed token says it is, verified against IAM's JWKS. IAM_ENDPOINT turns that on. Without it, the base binary serves one Base in which every HTTP caller is anonymous, except one holding a token Base signed itself, such as a superuser token left from an older version. Rotate those after an upgrade; versions.md shows how.

A tenant is a file. The org on a verified token selects {DataDir}/orgs/{org}/data.db, and every read and write in /v1 lands there. Two tenants cannot appear in one query because they are not in one database. The org comes from the token's membership — identity headers arriving from a client (X-Org-Id, X-User-Id, X-User-Email) are deleted at ingress, because inbound they are a claim rather than a fact.

Collection rules are predicates, not filters applied afterwards. A list rule is ANDed into the query before pagination and before the count, a joined collection contributes its own rule, and ?expand= applies the related collection's view rule. A collection with no rule is superuser-only: the default is closed.

Platform authority is one predicate. A person whose own org is IAM's reserved admin org, asked once through authz.Claims.Sudo(). A membership of admin held from another org, and an admin role on an ordinary org, are different authorities and grant none of it.

At rest

The platform database is not encrypted by the driver. DefaultDBConnect opens data.db with pragmas only — no key. Per-org shards open under a per-org DEK derived from a master key the org plugin reads from KMS beneath IAM_ORGANIZATION, and only on a build with SQLCipher linked. The base image links none: with a key stored it refuses to start, and without one its org shards open unencrypted as well. Put the data directory on an encrypted volume. Anyone who can read a file can read that Base with any SQLite client.

Settings are plaintext JSON unless you set the encryption env. SMTP passwords, S3 secrets and OAuth2 client secrets live in _params. With EncryptionEnv named and set, that row is AES-256-GCM; without it, it is readable JSON. Set it before you configure any of those. An unset key does not fail — it writes plaintext and says nothing — so the only signal you get is the one you go looking for.

Backups are plain zip archives. No encryption, no signature. A backup is as sensitive as the database inside it. Keep them in a bucket with server-side encryption and tight access, and treat a restore as trusting whoever could write to that bucket.

A backup taken on the platform Base spans the deployment. It archives DataDir, which contains every tenant's file. A tenant's own backup is scoped to its own directory. Do not hand a platform backup to one tenant.

On the wire

TLS is on when you configure HTTPS, and TLS 1.2 is the floor. ACME and Let's Encrypt are built in. TLS settings apply only when HTTPS is configured, so plain-HTTP health probes keep working.

No Strict-Transport-Security header. Base does not send one. Set HSTS at your ingress.

CORS defaults to *. With no AllowedOrigins, any origin may call the API. Two things keep that from being worse than it reads: AllowCredentials is false unless you set it, and the middleware refuses a wildcard origin together with credentials unless you also set UnsafeWildcardOriginWithAllowCredentials, which is named that way on purpose. Base's auth is header-based rather than cookie-based for the same reason. Name your origins in production, and do not reach for that flag.

Rate limiting is off by default. The rules exist and are sensible (*:auth 2 per 3s, *:create 20 per 5s) but Enabled is false on a fresh install. Turn it on before you expose an auth endpoint. The limits and their counters belong to the process, not to a tenant, so one org cannot spend another's budget.

Files

Uploaded files are typed by their content, not their Content-Type header. Only an allowlist — images, video, audio, PDF — is served inline; everything else gets Content-Disposition: attachment. Every download carries default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox. An HTML file uploaded as .png is detected as HTML, served as an attachment, and could not run script even if it were not.

Extensions

A JavaScript extension runs with the process's authority. The jsvm host binds $os with readFile, writeFile, exec and exit, and routerAdd registers a route at any path the extension names. There is no sandbox between an extension and the machine. Load extensions you wrote or audited, and treat the right to add one as equivalent to shell on the host. If you accept extensions from tenants, do not use jsvm.

Deliberately not defended

SQL built from your own strings. DeleteTable(dangerousName) and its siblings do not parameterize table and column names — the dangerous prefix is the warning. Record data, filters and rules all bind as parameters; these low-level methods are the exception, and they are yours to call safely.

Timing on client-supplied filters. Filterable fields are subject to timing inference. Mark secret, code, token and similar fields Hidden so they cannot be used in a client filter.

Concurrent edits to one record. Base minimizes transactions to avoid lock contention, so a read and a write are not wrapped together by default. Use the batch API, or an explicit transaction, where you need one.

Reporting

Found something not on this page? .github/SECURITY.md.