From 463908b18ec1a59f53ae7839560bf1d57cadf4f1 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 11 Jun 2026 12:39:31 -0400 Subject: [PATCH] =?UTF-8?q?fix(nats):=20security=20review=20=E2=80=94=20se?= =?UTF-8?q?cure-by-default=20+=20per-tenant=20inbox=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.) — 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 --- corrosion-host-agent/PROTOCOL.md | 17 ++++++++++++++++ docker/nats-auth.conf | 16 +++++++++------ scripts/generate-nats-auth.mjs | 35 +++++++++++++++++++++----------- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/corrosion-host-agent/PROTOCOL.md b/corrosion-host-agent/PROTOCOL.md index 1acd4d6..95f91be 100644 --- a/corrosion-host-agent/PROTOCOL.md +++ b/corrosion-host-agent/PROTOCOL.md @@ -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.` — 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`, diff --git a/docker/nats-auth.conf b/docker/nats-auth.conf index 590c900..dd9329f 100644 --- a/docker/nats-auth.conf +++ b/docker/nats-auth.conf @@ -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" } diff --git a/scripts/generate-nats-auth.mjs b/scripts/generate-nats-auth.mjs index 27897bf..df600d4 100644 --- a/scripts/generate-nats-auth.mjs +++ b/scripts/generate-nats-auth.mjs @@ -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., 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') {