fix(nats): security review — secure-by-default + per-tenant inbox isolation
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 16s
CI / agent-tests (push) Successful in 43s
CI / integration (push) Successful in 23s

Two HIGH findings from automated review on the generator, both fixed:
1. Cross-tenant inbox access: per-license users were granted _INBOX.>,
   letting license A subscribe to license B's request-reply responses.
   Now scoped to corrosion.{license}.> ONLY; replies must ride the
   license namespace (corrosion.{license}.reply.<id>) — documented in
   PROTOCOL.md. Agent unchanged (responds to msg.reply); constraint is
   on the requester (internal user has full >).
2. Default-open auth bypass: generator defaulted to stage=open with a
   full-access anonymous user — a stale regen left the broker wide open.
   Now defaults to enforce (secure by default); the explicit 'open'
   migration stage maps anonymous to a harmless corrosion.unclaimed.>
   namespace, never real tenant subjects. Committed bootstrap default
   hardened the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 12:39:31 -04:00
parent 00cff51ce5
commit 463908b18e
3 changed files with 50 additions and 18 deletions

View File

@@ -6,11 +6,20 @@
// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX
// for request-reply). The backend uses a privileged internal user.
//
// STAGING (NATS_AUTH_STAGE env):
// "open" (default) — defines a full-access `anonymous` user and sets
// no_auth_user, so unauthenticated clients still work.
// Non-breaking; lets you verify real creds first.
// "enforce" — omits no_auth_user; anonymous connections are rejected.
// STAGING (NATS_AUTH_STAGE env) — defaults to "enforce" (secure by default):
// "enforce" (default) — no anonymous; unauthenticated connections rejected.
// "open" — EXPLICIT opt-in for a brief migration window. Maps
// anonymous to a HARMLESS namespace (corrosion.unclaimed.>),
// NEVER full access, so a stale "open" deploy cannot
// read or forge real tenant (corrosion.{uuid}.>) traffic.
//
// REPLY SUBJECTS: per-license users are scoped to corrosion.{license}.> ONLY —
// no _INBOX grant (that would let one license read another's request-reply
// responses). Backend→agent request-reply MUST therefore use a reply subject
// inside the license namespace, e.g. corrosion.{license}.reply.<id>, not the
// default global _INBOX. The agent simply responds to msg.reply, so no agent
// change is needed — the constraint is on the requester (the internal user has
// full > and is unaffected).
//
// Usage:
// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \
@@ -30,7 +39,7 @@ const {
NATS_INTERNAL_USER,
NATS_INTERNAL_PASSWORD,
NATS_TOKEN_SECRET,
NATS_AUTH_STAGE = 'open',
NATS_AUTH_STAGE = 'enforce',
} = process.env;
for (const [k, v] of Object.entries({ DATABASE_URL, NATS_INTERNAL_USER, NATS_INTERNAL_PASSWORD, NATS_TOKEN_SECRET })) {
@@ -58,21 +67,23 @@ const main = async () => {
// Privileged internal user — the backend (full corrosion.> + _INBOX + _SYS).
lines.push(` { user: "${esc(NATS_INTERNAL_USER)}", password: "${esc(NATS_INTERNAL_PASSWORD)}", permissions: { publish: ">", subscribe: ">" } }`);
// Per-license scoped users.
// Per-license scoped users — corrosion.{id}.> ONLY. No _INBOX grant:
// replies ride the license namespace (see header). This is the whole
// point — one license can never touch another's subjects.
for (const { id } of rows) {
const pw = licensePassword(id, NATS_TOKEN_SECRET);
const scope = `corrosion.${id}.>`;
lines.push(
` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` +
`publish: { allow: ["${scope}", "_INBOX.>"] }, ` +
`subscribe: { allow: ["${scope}", "_INBOX.>"] } } }`,
`publish: { allow: ["${scope}"] }, ` +
`subscribe: { allow: ["${scope}"] } } }`,
);
}
if (NATS_AUTH_STAGE === 'open') {
// Transition: unauthenticated clients map to a full-access user so nothing
// breaks while real credentials roll out. Remove for enforcement.
lines.push(' { user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }');
// EXPLICIT migration opt-in only. Anonymous gets a HARMLESS namespace —
// never real tenant subjects — so a stale "open" deploy leaks nothing.
lines.push(' { user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }');
}
lines.push(' ]');
if (NATS_AUTH_STAGE === 'open') {