Security - FullmetalBober/Indexterity GitHub Wiki

A security review of what is here, what is checked automatically, and what is deliberately not done. Written against OWASP ASVS 5.0 Level 2 โ€” the level for an application that holds other people's credentials, which this one does.

Not a compliance document. Every row below is a link to code or a reason, and the ones that say "no" say why rather than being left out.

What CI checks on every pull request

Check What it answers Where
npm run audit:gate is there a high or critical advisory reachable from something we ship scripts/audit-gate.ts
OWASP ZAP baseline does the origin a browser gets return what it should โ€” headers, cookie flags, information leakage the zap job in .github/workflows/ci.yml
Chart install into Kind does the release actually install, and do the alert expressions parse the chart job โ€” path-filtered on ordinary PRs, and unconditional on a dev โ†’ main release PR, which is the last gate before a tag publishes the chart
The three test suites the controls below, individually โ€” the SSRF categories, the signup gate, the scrubber, the provisioned role's privileges, cross-tenant reads returning 404 npm run test, test:int, test:e2e

The audit gate is not npm audit

A bare npm audit in CI is a gate nobody can pass. It counts a dev-server advisory in the bundled esbuild inside drizzle-kit the same as a hole in the HTTP router, so the honest response to a red build becomes "stop reading it".

The gate walks the lockfile from each workspace's dependencies โ€” never its devDependencies โ€” through node resolution, and fails only on high or critical advisories inside that set. Of 1220 packages in the tree, 551 are reachable that way; the rest are build tools that never leave the runner.

npm audit --omit=dev is the obvious answer and does not work here: the runtime and dev trees are hoisted into one node_modules, and the lockfile carries no dev flag for a workspace's devDependencies, so npm reports the same advisories with the flag as without it.

Exceptions are entries in the script, with a reason and the condition that retires them. Two properties follow from keying them by GHSA id: a new advisory against an already-excepted package still fails, and an exception whose advisory has been fixed also fails โ€” a note claiming a risk is being carried when it is not gets read as still true, so removing it is enforced rather than hoped for.

Carried today: one. GHSA-xcx6-jx8p-x3h5 in @azure/msal-node (high, published 2026-10-23): MSAL's authorization code flow can disclose parameters to an attacker-controlled endpoint.

  • Why it ships: mssql โ†’ tedious โ†’ @azure/identity loads MSAL whenever the api starts.
  • Why it is not reached: mssqlConfig (apps/api/src/mssql/client.ts) sets a user and a password and never authentication. So tedious authenticates to SQL Server itself and builds no @azure/identity credential. None of the four credentials tedious can build (username and password, managed identity, default, client secret) uses the authorization code flow either.
  • What retires it: MSAL 7.1 is the fix, and no stable @azure/identity is on it yet; 4.14.0 and 4.15.0-beta.1 both ask for ^6. Once a release that tedious admits moves to 7.1, a lockfile bump removes the advisory, and the gate fails on the stale entry until it is deleted.
  • Not taken: an npm override onto MSAL 7. It would put @azure/identity on a major it does not declare, to fix a flow nothing here calls.

Empty is the intended resting state, not a sign nobody has filled the list in. It held one entry before this one: GHSA-c96f-x56v-gq3h in find-my-way, a denial of service needing HTTP/2 CONTINUATION frames that the api's FastifyAdapter cannot receive because it never sets http2. It stayed until @nestjs/[email protected] shipped find-my-way 9.7.0. That was the written until, and the gate failed on the stale entry rather than carrying a note about a risk that no longer existed.

Reported and not enforced, because it is a build tool: GHSA-67mh-4wv8-2f99 (esbuild's dev server, reached only through drizzle-kit, moderate). npm's proposed fix is [email protected] โ€” thirteen minor versions backwards, which would trade a dev-server advisory nothing here exposes for a migration tool that cannot read the current schema.

The gate is checked the same way the app is

A gate's real failure mode is not a wrong answer but an empty one. audit-gate.ts reads two external schemas by hand โ€” the lockfile's packages map, and npm audit --json, whose report is versioned (auditReportVersion: 2) and will move โ€” and a wrong field name in either does not throw. It produces no advisories, and a green build that checked nothing.

Three things stand between that and a false pass, and all three run on every invocation:

  • The script is TypeScript, and npm run typecheck reads it. Until #136 no check read the repo's scripts at all โ€” and they were not the last such files: the audit that followed found the apps' own config files, the whole e2e suite and a test whose extension hid it from the compiler in the same state. Every TypeScript file in the tree now belongs to a project that checks it, editors included. Renaming via.url to via.uri is a compile error rather than a silent empty list.
  • Both parses are held to a fixture. The reachability walk and the report parse are pure functions, run against a synthetic lockfile carrying one instance of every shape they must understand โ€” a workspace link, a nested node_modules shadow, an optionalDependencies edge, a dependency cycle, and a dev-only subtree that must not be reached โ€” and against a synthetic report. A break exits 2, distinct from the 1 that means "found an advisory".
  • npm's own totals are cross-checked. auditReportVersion must be the 2 the parse was written against, and metadata.vulnerabilities.total must equal the number of entries actually read. The schema moving under us fails loudly instead of reading as a clean tree.

The ZAP scan is passive, and it is signed in

The baseline scan spiders and reads; it does not inject a payload. Deciding to run an active scan is deciding what it may submit as an owner โ€” connect a cluster, apply a recommendation โ€” and that is a separate decision.

It targets the dashboard's port rather than the api's, because the dashboard answers /api itself, so one origin covers documents, assets and the api alike โ€” the shape a browser sees in production, minus the proxy.

It signs in, and that took two things. The job signs up over the api and creates an organization, then hands ZAP the session through ZAP_AUTH_HEADER=Cookie. SIGNUP_MODE is left alone: invite-only is the default and the first account always bootstraps an install, so a fresh database lets this through on the posture a real deployment runs. Without the organization the dashboard draws a create-org screen and nothing below it.

The second thing is less obvious. The dashboard's pages contain no href at all โ€” measured on the built output, signed in and signed out, zero in both, because TanStack Router navigates on the client. A link-following spider could never have found a route however well authenticated, which is why the first runs scanned thirteen URLs and none of them the product. -j puts a real browser on it, and the crawl went from 13 URLs to 87, reaching /app/clusters/new, /app/settings, /app/settings/account and /app/settings/organizations.

No cluster is connected, which bounds what the browser can reach: there is nothing to disconnect or drop, and deleting the organization needs its name typed into a confirm dialog.

Signing in added exactly one alert, and it is benign: information in browser sessionStorage. No code of ours writes there; the only write in the bundle is TanStack Router's reload guard, keyed tanstack_router_reload:<error message>, which stops a reload loop when a chunk fails to load. An asset-load error, not a tenant's data.

fail_action is on. A scan that cannot fail is a report nobody is obliged to read, and .zap/rules.tsv is the one place an alert can be silenced, with the reason beside it.

The first run reported eleven alerts, and four were fixed rather than listed โ€” nosniff missing on /favicon.svg and /robots.txt, no Permissions-Policy on the built assets, and no Cross-Origin-Resource-Policy or Cross-Origin-Opener-Policy anywhere. The first two had one cause: nitro serves public/ and the assets from a static handler that answers before the server entry, so nitro's routeRules is the only seam that reaches them.

Five rule ids are listed, and only two of those are risk decisions โ€” Cross-Origin-Embedder-Policy and a reset token in a URL. Two are verified non-findings: "private IP disclosure" matches the literal 10.0.0.0/8 inside the TRUST_PROXY validation message, and the sessionStorage alert is TanStack Router's reload guard. The last is there because zap-baseline counts an informational alert as a warning unless it is listed โ€” 10049 fires because the cache headers are right, reporting every no-store response as "non-storable" and every content-hashed asset as "cacheable".

An entry that stops firing is deleted rather than left to be believed, which is the rule audit-gate.ts enforces for advisories. 10109, "Modern Web Application", was listed until the scan gained a browser spider: ZAP had been observing that it could not crawl a JavaScript app, and now it can.

The length of that file is therefore not a measure of debt, and its header says so.

ASVS 5.0 by chapter

V1 Encoding and sanitization

Injection into MongoDB Every command the engine issues is built from typed values, never from string concatenation, and the collection and index names come from the cluster's own listIndexes rather than from a request.
Injection into Postgres Drizzle's query builder throughout โ€” parameters, not interpolation.
SSRF The control plane dials whatever an owner pastes, which is the whole attack surface of a hosted install. engine/net-guard.ts resolves the host โ€” A, AAAA and SRV, since mongodb+srv:// hides its real targets behind a lookup โ€” and categorises every resulting address in two tiers. FORBIDDEN (cloud metadata at 169.254.169.254, multicast, documentation ranges) is never dialled at all. PRIVATE (RFC1918, loopback, CGNAT) is refused unless ALLOW_PRIVATE_CLUSTER_TARGETS=true, which is the normal posture for a self-hosted install whose database is on the same network and the wrong one for a hosted deployment.
Output encoding React escapes by default and nothing here uses dangerouslySetInnerHTML.

V2 Validation and business logic

Every request body is a zod schema in @repo/contracts, shared with the client, so the browser and the api validate against the same definition rather than two that can drift.

Business-logic limits are not drawn in the dashboard โ€” they are enforced by the api and refused with 402 rather than 403, because the caller is an owner and "forbidden" sends them looking for a permissions problem they do not have. See Plans and policy.

V3 Web frontend security

Header Api Dashboard
Content-Security-Policy default-src 'none'; frame-ancestors 'none'; base-uri 'none' โ€” true, not aspirational: it serves no documents default-src 'none' and a per-response nonce on script-src (below)
X-Content-Type-Options nosniff nosniff
X-Frame-Options DENY DENY
Referrer-Policy no-referrer strict-origin-when-cross-origin
Permissions-Policy camera, microphone, geolocation, payment all () same
Cache-Control no-store no-store, except content-hashed assets under /_build
Strict-Transport-Security the ingress's, not the app's same

Set in the app rather than at the ingress deliberately: a header that only exists in the chart is a header npm run dev, the e2e suite and every self-hoster on compose do not have โ€” so the one place it is exercised is the one place nobody tests.

HSTS is the exception and belongs to whatever terminates TLS. Sent from a process reached over plain HTTP inside the cluster it is ignored by every browser, which makes it a header that looks like a policy and is not.

The dashboard's CSP

default-src 'none';
script-src 'self' 'nonce-<128 bits, per response>';
style-src 'self' 'nonce-<same>' 'sha256-<sonner>' 'sha256-<radix select>';
style-src-attr 'unsafe-inline';
img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self';
frame-ancestors 'none'; base-uri 'none'; object-src 'none'

It was the last header to arrive because it is the only one here that breaks the app silently. TanStack Start emits inline scripts per response โ€” the router's dehydrated payload, and the buffered block carrying what resolved after the shell โ€” and a script-src that refuses them leaves a page that renders and then never becomes interactive, which reads as a slow dashboard rather than a broken one.

The framework has the seam: router.options.ssr.nonce. The server entry mints 128 bits per response, gives it to the router and names it in the header built from the same value, so the two cannot drift. Every script in the response carries it โ€” the module entry and the landing page's JSON-LD block included.

Not 'strict-dynamic': it makes browsers ignore 'self', and the <link rel=modulepreload> tags have no nonce to inherit trust from. 'self' cannot be turned into a script by getting the api to echo JSON back at a <script src>, because nosniff is sent on every response and the type has to be right too.

connect-src is 'self' and that is the whole of it. The web server answers /api itself, so the api and the SSE stream are same-origin; error reporting is server-side only and the browser bundle contains no Sentry to dial an ingest host.

style-src carries no 'unsafe-inline' either. Three dependencies write style elements from JavaScript, and each is allowed by name rather than by opening the directive:

how it is covered
react-remove-scroll-bar (under every Radix dialog) asks get-nonce for a nonce, and gets this response's. Its rule carries the measured scrollbar width, so a hash could never cover it
sonner content hash. Injects at import time and takes no nonce
@radix-ui/react-select viewport rule content hash. It does take a nonce prop, but reaching it means forking the vendored components/ui/select.tsx that the next shadcn add undoes

The nonce reaches get-nonce without being written into the page. It is read off the document's own script tag: browsers hide a nonce from attribute reads โ€” getAttribute("nonce") returns "", measured โ€” precisely so injected markup cannot read one and reuse it, while the .nonce property still answers to script that is already running and has therefore already satisfied script-src.

Both hashes are constants of the installed version, and a unit test recomputes them from node_modules. An upgrade that changes one byte is a red test naming the constant to update, not a toast that quietly loses its styling.

'unsafe-inline' survives in exactly one place, style-src-attr, which React's server-rendered style={{โ€ฆ}} requires โ€” that is how the virtualized tables position their rows. A style attribute carries no selector, so it cannot do the reading that makes injected CSS worth having, and ZAP does not report it: both spellings were scanned, and style-src 'unsafe-inline' is 10055 while style-src-attr 'unsafe-inline' is a PASS.

One directive differs under vite dev, and it belongs to the dev server rather than to the app. There is no built CSS file then: @vite/client fetches the stylesheet and applies it by creating a style element, which the policy above refuses โ€” so npm run dev rendered the dashboard unstyled until style-src became 'self' 'unsafe-inline' there. It is not for want of a nonce on either side. Vite looks one up at meta[property=csp-nonce], and TanStack Start emits that very tag, but writes the value to content while stamping nonce on every other head element it renders โ€” so the one attribute Vite reads is the one that is not set.

The dev spelling drops the nonce and the hashes rather than adding 'unsafe-inline' beside them, because a browser ignores that keyword in any directive carrying a nonce or a hash. That rule is also why the two can sit side by side elsewhere without weakening anything. Production is unaffected, and production is what is checked: the e2e suite and the ZAP job both run against the built output.

How it is checked. The e2e suite's test carries a fixture that fails any test in which the browser refused anything โ€” so the policy is exercised against every page the suite touches, in a real browser, rather than asserted as a string. That is what a header test cannot do: a nonce that no longer matches the scripts produces a perfectly well-formed policy and a dead page.

CSRF is origin-checked rather than token-based: better-auth verifies Origin against trustedOrigins, and the session cookie is SameSite=Lax. The two together are why there is no separate token โ€” and why X-Frame-Options: DENY matters, since Lax cookies do travel on a top-level framed navigation.

V6 Authentication

Credentials better-auth email + password, hashed with scrypt (its default). Minimum length 8, no composition rules โ€” length is the only requirement with evidence behind it.
Brute force Two limiters, and they answer different questions. Fastify's is per replica, in memory, on every route (RATE_LIMIT_MAX, 300/min) with a tighter budget on /api/auth/*. better-auth's counts in Postgres, so the credential endpoints have one budget for the whole deployment rather than one per pod โ€” a rolling deploy used to hand every attacker their budget back.
2FA TOTP with backup codes, plus an emailed code where SMTP is configured. REQUIRE_OWNER_2FA=true demands it of owners before any owner-only mutation โ€” these are the accounts that can flip a customer's cluster live. Accounts that sign in through GitHub are exempt: there is no password to pair a code with, and GitHub enforces its own.
Account enumeration Sign-up under SIGNUP_MODE=invite (the default) refuses an address with no pending invite. The first account is always allowed โ€” someone has to bootstrap the install.
Re-authentication Going live, rotating credentials and disconnecting a cluster need a sign-in from the last hour, independently of the 7-day session lifetime.

V7 Session management

Sessions are rows in Postgres with a signed cookie: HttpOnly, SameSite=Lax, and Secure decided explicitly rather than inferred from the base URL's scheme. The organization switcher is per session rather than per user, so two browsers can sit in two different orgs.

The one subtlety worth writing down is the cookie cache. A short-lived signed cookie carries a copy of the session so most requests need no database read โ€” and a copy is exactly the thing that can outlive what it copies. Only better-auth's own set-active re-signs it, so the organization plugin's create, delete and updateMemberRole would each leave a stale copy authoritative for up to its maxAge. hooks.after in auth.config.ts expires it on those paths; sign-out is handled by better-auth clearing both cookies in the same response.

V8 Authorization

Owner or member, and deliberately not better-auth's third default role. admin would be a real third rung and half the api asks the single question requireOwner asks โ€” adding the rung without teaching every mutation about it would produce an admin who can remove people but not connect a cluster.

Tenancy is enforced per handler โ€” the active organization is resolved once per request and passed into a query that filters on it โ€” and a cluster id belonging to another org is a 404 rather than a 403, because whether an id exists in somebody else's account is not the caller's business.

Per-handler enforcement is exactly the kind that a NEW route can forget, and a route that forgets still names an authorization level, still passes every test that drives it as the owner, and is a hole nothing announces. So the route surface is swept as a whole: every route on the contract with a path parameter is driven by an owner of a different organization, enumerated off the contract rather than from a list, and a new one fails the suite until somebody decides what a stranger should get. Writes must be refused outright; the reads that answer with an empty shape instead are held to returning byte-identical bodies for another org's cluster and for an id that never existed, so neither can be used to learn that the other is real.

An organization can also forbid holding credentials broader than the engine needs โ€” off by default, owner-only to change, enforced at both doors that store a string (connect and rotate) against the diagnosis rather than against the shape of what was typed, because whether a string can create users is a question only the cluster can answer. A deployment running with authentication disabled is refused outright rather than exempted: it grants every privilege to anyone who can reach it, and no grant narrows that. See Plans and policy โ†’ Policy knobs (per organization), which also covers why it is deliberately not retroactive.

Enumerating what a stored credential is permitted to do is owner-only and not a judgement call: the response is half of what an attacker would want before using it. It dials the customer's cluster, so it goes through the same outbound dial guard and per-account budget as testing a pasted string โ€” a string accepted once is not exempt from those, because the host it names could have been re-pointed since.

V11 Cryptography

Cluster credentials are envelope-encrypted: a random 32-byte DEK per secret under XChaCha20-Poly1305, and the DEK wrapped by a KEK derived from MASTER_KEY. managedNonce prepends a random 24-byte nonce, so nonce reuse is not something a caller can get wrong.

Each row records the key version that sealed it, so a rotation (MASTER_KEY_V<n> + MASTER_KEY_VERSION, driven by rotate-key.js) leaves older rows readable while new ones are written under the new key. The schema refuses to boot if MASTER_KEY_VERSION names a key the environment does not carry โ€” the one mistake in that file with no recovery.

The interface is a KeyProvider, so a Vault or KMS custodian is an implementation swap rather than a migration. That is deferred, not overlooked: see the decision log's open items.

V14 Data protection

Indexterity never reads your documents. The provisioned role (indexterity) has no find on your collections โ€” it holds listDatabases, serverStatus, the queryStats actions, index and collection statistics, and find on system.profile and config.collections alone. The admin string used to create it is used once and never persisted.

It also holds enableProfiler, and that one changes something on your server, so it is worth spelling out (D183). While a drop is in its observe window, the profiler on that database runs at level 1 with Indexterity's filter: failed operations and hint() on the watched collection, plus whatever the database recorded or logged before โ€” so operations slower than slowms are written to system.profile for the window, where at level 0 they were only logged. Profiler entries carry query literals, and the role could already read system.profile; what is new is that there is something in it. The settings are given back when the window ends, a profiler someone turned off is not turned back on, and removing the action from the role turns the whole behaviour off โ€” the check then reports it was skipped, and nothing else changes.

The role only ever grows when its owner hands over an admin string for it (D184). A release that can use one more privilege makes it optional, tells each cluster once, and offers either the statement that grants it or an upgrade on provisioning's terms: the admin string is used for that one operation, refused if it names none of the cluster's hosts, and never stored; the result is checked with the stored credentials; and the act is in the security trail as CLUSTER_ROLE_UPGRADED, marked severe because it widens what a credential the control plane holds may do. The upgrade replaces the role's privileges with today's definition, so it ends exactly as a cluster provisioned today would have it โ€” including dropping anything granted to that role by hand.

The SQL Server login is the same promise in that engine's terms: VIEW SERVER STATE, VIEW DATABASE STATE, and ALTER on each schema that owns tables โ€” no SELECT, so a plain read of a customer table comes back as Msg 229. ALTER is scoped to schemas rather than to the database because database-wide ALTER would also permit dropping tables, and SQL Server offers no index-only permission to ask for instead.

An engine that asked for read would be simpler and is the thing this product cannot afford to do, so the role is asserted in an integration test rather than documented and trusted.

That promise held on the provisioning path and was simply absent on the other one. "Use these credentials as-is" sat beside "Create a scoped user and connect", was one click cheaper, and sealed whatever was pasted โ€” so an organization that had decided least privilege was mandatory enforced it by asking colleagues nicely. Two things close that: the org-level rule under V8, and a connection card that breaks the stored credentials into provided / required / redundant so a surplus grant has a name and the statement that removes it, instead of one badge saying only "can do more than manage indexes". What counts as surplus per engine, and why read access is not on PostgreSQL's list, is in Connecting a cluster ยง3.1.

Retention deletes on each plan's own window and is enforced on read as well โ€” see Plans and policy.

V15 Secure coding and V16 Logging

Every environment variable is validated at boot by one schema per process, and a misconfiguration prints what is wrong and exits rather than taking a default โ€” positiveEnv("AUTH_RATE_LIMIT_MAX", 20) used to read 2O (letter O) as 20, so a fat-fingered brute-force budget looked correct.

Two audit tables, because they answer different questions and age differently: actions is per cluster and records every executed index operation immutably, security_events is per organization and records owner-level acts โ€” sign-ins, role changes, credential rotations, and the VPN peerings the control plane dials through, including every reachability test and what came back โ€” with the actor resolved from the request. better-auth's hooks carry no session, so the actor is resolved rather than assumed.

Secrets are stripped in three places: pino redacts authorization, cookie and set-cookie; the Sentry scrubber in packages/errors strips connection strings and secrets from every event before it leaves the process; and the environment error report names a bad secret variable without printing what was in it.

Deliberately not done

  • A full active ZAP scan. It would submit forms as an owner. What it is allowed to touch has to be decided first.
  • KMS/Vault custodian. The KeyProvider interface is the seam; the implementation waits for a deployment that needs it.
  • Subresource integrity. Every asset is same-origin and content-hashed; SRI would add a hash to a URL that already is one.
  • A bug bounty. Report anything you find to [email protected].
โš ๏ธ **GitHub.com Fallback** โš ๏ธ