fix(nats): security review — secure-by-default + per-tenant inbox isolation
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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user