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:
@@ -179,6 +179,23 @@ service that attempts connections to the customer's public IP/ports on
|
||||
request; that is specified as a Phase 1+ feature and will reuse this report
|
||||
format with `direction: "inbound"`.
|
||||
|
||||
## Authentication & tenant isolation
|
||||
|
||||
The broker enforces per-license auth: an agent connects with `user = license_id`,
|
||||
`password = HMAC-SHA256(license_id, NATS_TOKEN_SECRET)` (shown on the panel
|
||||
Server page), and is scoped to `corrosion.{license_id}.>` only. The backend uses
|
||||
a privileged internal user. This makes cross-tenant access impossible at the
|
||||
broker, not just by convention.
|
||||
|
||||
**Reply-subject rule:** per-license users have NO `_INBOX` permission (granting
|
||||
it would let one license read another's request-reply traffic). Therefore any
|
||||
backend→agent request-reply MUST use a reply subject inside the license
|
||||
namespace — e.g. `corrosion.{license_id}.reply.<id>` — never the client's
|
||||
default global `_INBOX`. The agent is unaffected: it responds to whatever
|
||||
`msg.reply` it receives. The constraint is on the requester (the internal user
|
||||
has full access). The contract/CI tests run against an unauthenticated broker
|
||||
and use the default inbox; production request-reply must follow this rule.
|
||||
|
||||
## Versioning
|
||||
|
||||
- The agent embeds semver + git hash + build timestamp (`--version`,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# SAFE OPEN DEFAULT — anonymous full access, no secrets. Same behavior as the
|
||||
# pre-auth broker so fresh deploys and the repo stay valid.
|
||||
# BOOTSTRAP DEFAULT — no secrets, safe to commit.
|
||||
#
|
||||
# Regenerated on deploy by scripts/generate-nats-auth.mjs with the privileged
|
||||
# internal user + per-license scoped users (those carry secrets and must NOT be
|
||||
# committed — mark the host copy with `git update-index --assume-unchanged`).
|
||||
# Anonymous is mapped to a HARMLESS namespace (corrosion.unclaimed.>), never to
|
||||
# real tenant subjects (corrosion.{uuid}.>) — so a fresh/stale deploy running
|
||||
# this default cannot read or forge any tenant's traffic. The REST API still
|
||||
# works; agent telemetry just won't flow until the real config is generated.
|
||||
#
|
||||
# On every real deploy, scripts/generate-nats-auth.mjs OVERWRITES this file
|
||||
# (on the host, not in git) with the privileged internal user + per-license
|
||||
# scoped users. NATS_AUTH_STAGE defaults to "enforce" (anonymous rejected).
|
||||
authorization {
|
||||
users: [
|
||||
{ user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }
|
||||
{ user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }
|
||||
]
|
||||
no_auth_user: "anonymous"
|
||||
}
|
||||
|
||||
@@ -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