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
|
request; that is specified as a Phase 1+ feature and will reuse this report
|
||||||
format with `direction: "inbound"`.
|
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
|
## Versioning
|
||||||
|
|
||||||
- The agent embeds semver + git hash + build timestamp (`--version`,
|
- 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
|
# BOOTSTRAP DEFAULT — no secrets, safe to commit.
|
||||||
# pre-auth broker so fresh deploys and the repo stay valid.
|
|
||||||
#
|
#
|
||||||
# Regenerated on deploy by scripts/generate-nats-auth.mjs with the privileged
|
# Anonymous is mapped to a HARMLESS namespace (corrosion.unclaimed.>), never to
|
||||||
# internal user + per-license scoped users (those carry secrets and must NOT be
|
# real tenant subjects (corrosion.{uuid}.>) — so a fresh/stale deploy running
|
||||||
# committed — mark the host copy with `git update-index --assume-unchanged`).
|
# 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 {
|
authorization {
|
||||||
users: [
|
users: [
|
||||||
{ user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }
|
{ user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }
|
||||||
]
|
]
|
||||||
no_auth_user: "anonymous"
|
no_auth_user: "anonymous"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,20 @@
|
|||||||
// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX
|
// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX
|
||||||
// for request-reply). The backend uses a privileged internal user.
|
// for request-reply). The backend uses a privileged internal user.
|
||||||
//
|
//
|
||||||
// STAGING (NATS_AUTH_STAGE env):
|
// STAGING (NATS_AUTH_STAGE env) — defaults to "enforce" (secure by default):
|
||||||
// "open" (default) — defines a full-access `anonymous` user and sets
|
// "enforce" (default) — no anonymous; unauthenticated connections rejected.
|
||||||
// no_auth_user, so unauthenticated clients still work.
|
// "open" — EXPLICIT opt-in for a brief migration window. Maps
|
||||||
// Non-breaking; lets you verify real creds first.
|
// anonymous to a HARMLESS namespace (corrosion.unclaimed.>),
|
||||||
// "enforce" — omits no_auth_user; anonymous connections are rejected.
|
// 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:
|
// Usage:
|
||||||
// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \
|
// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \
|
||||||
@@ -30,7 +39,7 @@ const {
|
|||||||
NATS_INTERNAL_USER,
|
NATS_INTERNAL_USER,
|
||||||
NATS_INTERNAL_PASSWORD,
|
NATS_INTERNAL_PASSWORD,
|
||||||
NATS_TOKEN_SECRET,
|
NATS_TOKEN_SECRET,
|
||||||
NATS_AUTH_STAGE = 'open',
|
NATS_AUTH_STAGE = 'enforce',
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
for (const [k, v] of Object.entries({ DATABASE_URL, NATS_INTERNAL_USER, NATS_INTERNAL_PASSWORD, NATS_TOKEN_SECRET })) {
|
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).
|
// Privileged internal user — the backend (full corrosion.> + _INBOX + _SYS).
|
||||||
lines.push(` { user: "${esc(NATS_INTERNAL_USER)}", password: "${esc(NATS_INTERNAL_PASSWORD)}", permissions: { publish: ">", subscribe: ">" } }`);
|
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) {
|
for (const { id } of rows) {
|
||||||
const pw = licensePassword(id, NATS_TOKEN_SECRET);
|
const pw = licensePassword(id, NATS_TOKEN_SECRET);
|
||||||
const scope = `corrosion.${id}.>`;
|
const scope = `corrosion.${id}.>`;
|
||||||
lines.push(
|
lines.push(
|
||||||
` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` +
|
` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` +
|
||||||
`publish: { allow: ["${scope}", "_INBOX.>"] }, ` +
|
`publish: { allow: ["${scope}"] }, ` +
|
||||||
`subscribe: { allow: ["${scope}", "_INBOX.>"] } } }`,
|
`subscribe: { allow: ["${scope}"] } } }`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (NATS_AUTH_STAGE === 'open') {
|
if (NATS_AUTH_STAGE === 'open') {
|
||||||
// Transition: unauthenticated clients map to a full-access user so nothing
|
// EXPLICIT migration opt-in only. Anonymous gets a HARMLESS namespace —
|
||||||
// breaks while real credentials roll out. Remove for enforcement.
|
// never real tenant subjects — so a stale "open" deploy leaks nothing.
|
||||||
lines.push(' { user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }');
|
lines.push(' { user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }');
|
||||||
}
|
}
|
||||||
lines.push(' ]');
|
lines.push(' ]');
|
||||||
if (NATS_AUTH_STAGE === 'open') {
|
if (NATS_AUTH_STAGE === 'open') {
|
||||||
|
|||||||
Reference in New Issue
Block a user