From 27db587474786461da0feaa65e6baff07297596a Mon Sep 17 00:00:00 2001 From: vmihalis <67660547+vmihalis@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:42:23 +0800 Subject: [PATCH 1/4] feat(scope): operator-armed cross-host roam (BOB_HTTP_ROAM_AUTHORIZED, default-off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the operator sets BOB_HTTP_ROAM_AUTHORIZED= (target-bound, mirroring the IDOR live arm BOB_IDOR_PROVISION_AUTHORIZED), validateHttpScanScope stops rejecting a URL whose host is OUTSIDE the session's target_domain — so Bob may follow an engagement off the apex (OAuth/SSO IdP, CDN, sibling app, an authorized redirect/SSRF chain). The env is the out-of-band operator gate a confined MCP/Bash agent cannot set. Single chokepoint: validateHttpScanScope backs the HTTP tools (safe-fetch), the browser driver (navigate/authed_fetch via assertSafeResolvedRequestUrl), and redirect-following — so one relaxation covers them all. A roamed request returns scope_decision: allowed, reason: operator_armed_roam (every roamed host stays visible in the audit). Deliberately NOT relaxed: - Lab-attested private targets: the lab path returns BEFORE the roam check, so a 127.0.0.1/RFC1918 session can never pivot to 169.254.169.254 or a LAN neighbour even with roam armed (asserted). - block_internal_hosts: a separate DNS-resolution policy (safe-fetch resolveSafeAddress); roam relaxes the target-DOMAIN boundary only, never internal-host blocking. Roam alone is not SSRF-to-internal. Target-bound: roam authorizes ONLY the session whose target_domain equals the env value (trim + case-insensitive, whole-target, not a suffix), so arming one engagement never relaxes another. Tests: test/operator-armed-roam.test.js (7) — off blocks, on allows, wrong-target blocks, suffix-no-match, first-party unaffected, lab-locked-under-roam, helper unit. docs/FIRST_RUN.md documents the arm + the two non-relaxed boundaries. test:mcp 3180/0, test:prompts clean. --- docs/FIRST_RUN.md | 17 +++++ mcp/lib/scope.js | 47 +++++++++++++ test/mcp-test-manifest.json | 1 + test/operator-armed-roam.test.js | 116 +++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 test/operator-armed-roam.test.js diff --git a/docs/FIRST_RUN.md b/docs/FIRST_RUN.md index 4d58b003..7aea293c 100644 --- a/docs/FIRST_RUN.md +++ b/docs/FIRST_RUN.md @@ -130,6 +130,23 @@ By default Bob's scope kernel rejects any non-public target — bare IPs, loopba All three are required. Without the env vars the declaration alone does nothing, and the grant is **bound to the host(s) named in `BOB_LAB_TARGET`** — so an active session cannot be turned against a neighboring private host. Only IPv4 loopback (`127.0.0.0/8`) and RFC1918 (`10/8`, `172.16/12`, `192.168/16`) are eligible; IPv6, link-local (`169.254/16`), cloud-metadata, and `.internal`/`.local` names are never eligible even with the attestation. The attestation is recorded as an audit-graded session artifact, pins scope to that one host, and requires the default (direct) egress profile. +### Cross-host roam (following an engagement off the apex) + +By default Bob's scope kernel rejects any URL whose host is **outside** the session's `target_domain` ("URL host … is outside target_domain …"), so an authorized scan of `target.com` cannot wander onto an unrelated host. When an engagement legitimately spans hosts you are authorized to test (an OAuth/SSO identity provider, a CDN/asset host, a sibling app, or a redirect/SSRF chain you may follow), the **operator** can arm cross-host roam out-of-band — the agent cannot arm it for itself (it never sets the server's environment): + +```text +export BOB_HTTP_ROAM_AUTHORIZED=target.com # the session's exact target_domain +``` + +This is **default-off** and **target-bound**: roam is authorized only for the session whose `target_domain` equals the value, so arming one engagement never relaxes another. When armed, an out-of-`target_domain` request is allowed and recorded with `scope_decision: allowed, reason: operator_armed_roam` (every roamed host stays visible in the audit). One scope chokepoint covers the HTTP tools, the browser driver (navigate / authed_fetch), and redirect-following. + +Two boundaries are deliberately **not** relaxed by roam: + +- **Attested lab targets stay pinned.** A `BOB_LAB_TARGET` session remains locked to its exact attested host even if roam is armed — a loopback/RFC1918 session can never pivot to `169.254.169.254` or a LAN neighbour. +- **Internal/metadata hosts stay blocked.** `block_internal_hosts` is a separate DNS-resolution policy; roam relaxes the *target-domain* boundary only. To also reach internal IPs you must separately disable `block_internal_hosts` — roam alone is not SSRF-to-internal. + +Only arm roam when the engagement's scope explicitly authorizes the other hosts. + ## Lifecycle States A `/bob-evaluate` run advances through six lifecycle states driven by `bob_advance_session(to_state)`: diff --git a/mcp/lib/scope.js b/mcp/lib/scope.js index c4368444..fa667dd3 100644 --- a/mcp/lib/scope.js +++ b/mcp/lib/scope.js @@ -29,6 +29,32 @@ let publicSuffixOverlayCache = { overlay: null, }; +// OPERATOR-ARMED CROSS-HOST ROAM (default OFF). When the operator sets +// BOB_HTTP_ROAM_AUTHORIZED= (target-bound, exactly like the IDOR live arm +// BOB_IDOR_PROVISION_AUTHORIZED), validateHttpScanScope stops rejecting a URL whose host is OUTSIDE the +// session's target_domain — so Bob may follow an engagement off the apex (OAuth IdP, CDN, redirect/SSRF +// chain, sibling app) when the operator has explicitly authorized it for THIS target. The env is the +// deliberate out-of-band gate: a confined MCP/Bash agent cannot set the server's process.env (the same +// boundary as the row-MAC key + the IDOR arm), so this is an operator decision, not an agent one. +// +// SCOPE OF THE RELAXATION — deliberately narrow: +// - Target-bound: roam is authorized ONLY for the session whose target_domain EQUALS the env value, so +// arming one engagement never silently relaxes another. +// - The lab-attested private-target path is NOT relaxed (it returns BEFORE this check), so an attested +// 192.168/127.0.0.1 session can never be roamed onto 169.254.169.254 or a LAN neighbour. +// - block_internal_hosts is a SEPARATE policy enforced at DNS resolution (safe-fetch.js resolveSafeAddress), +// NOT here — so roam relaxes the target-DOMAIN boundary only; internal/metadata IPs stay blocked unless +// the operator ALSO disables block_internal_hosts. Roam ≠ SSRF-to-internal. +const ROAM_AUTHORIZED_ENV = "BOB_HTTP_ROAM_AUTHORIZED"; + +function roamAuthorizedForTarget(targetDomain) { + const armed = process.env[ROAM_AUTHORIZED_ENV]; + if (typeof armed !== "string" || !armed.trim()) return false; + const domain = typeof targetDomain === "string" ? targetDomain.trim().toLowerCase() : ""; + if (!domain) return false; + return armed.trim().toLowerCase() === domain; +} + function normalizeDnsHostToAscii(value, fieldName) { const raw = String(value || "").trim().replace(/\.+$/, ""); if (!raw) throw new Error(`${fieldName} is required`); @@ -283,6 +309,25 @@ function validateHttpScanScope(url, targetDomain, opts = {}) { } if (!isFirstPartyHost(host, domain)) { + // OPERATOR-ARMED ROAM (default OFF): if the operator armed BOB_HTTP_ROAM_AUTHORIZED= + // for THIS session, allow the cross-host URL instead of rejecting it. The lab-attested path above + // already returned, so this never relaxes an attested private target; block_internal_hosts (enforced + // separately at DNS resolution in safe-fetch.js) still blocks internal/metadata IPs. The roamed host + // is described from ITS OWN public-suffix info so the audit shows exactly where the request went. + if (roamAuthorizedForTarget(domain)) { + const roamedSuffixInfo = publicSuffixInfoForHost(host); + return { + allowed: true, + scope_decision: "allowed", + reason: "operator_armed_roam", + host, + target_domain: domain, + registrable_domain: roamedSuffixInfo.registrable_domain, + public_suffix: roamedSuffixInfo.public_suffix, + public_suffix_source: roamedSuffixInfo.public_suffix_source, + psl_overlay_file: roamedSuffixInfo.psl_overlay_file, + }; + } const domainSuffixInfo = publicSuffixInfoForHost(domain); throw makeScopeBlockedError( `URL host ${host} is outside target_domain ${domain}`, @@ -350,11 +395,13 @@ function filterExclusionsByHosts(entries, hosts, cap = 100) { } module.exports = { + ROAM_AUTHORIZED_ENV, assertHttpScopeDomain, filterExclusionsByHosts, normalizeScopeExclusionToken, publicSuffixInfoForHost, readScopeExclusions, resolveHttpScanTargetDomain, + roamAuthorizedForTarget, validateHttpScanScope, }; diff --git a/test/mcp-test-manifest.json b/test/mcp-test-manifest.json index 8ea3ee1f..0bf142d0 100644 --- a/test/mcp-test-manifest.json +++ b/test/mcp-test-manifest.json @@ -2,6 +2,7 @@ "test/mcp-server.test.js", "test/wave-merge-friction-mechanization.test.js", "test/lab-target-attest.test.js", + "test/operator-armed-roam.test.js", "test/pii-detector.test.js", "test/auto-signup-pii-gate.test.js", "test/pipeline-events.test.js", diff --git a/test/operator-armed-roam.test.js b/test/operator-armed-roam.test.js new file mode 100644 index 00000000..9df98817 --- /dev/null +++ b/test/operator-armed-roam.test.js @@ -0,0 +1,116 @@ +"use strict"; + +// Operator-armed cross-host roam (BOB_HTTP_ROAM_AUTHORIZED=). Proves the scope kernel's +// default-OFF, target-bound relaxation: when armed for THIS session's target, a cross-host URL is allowed +// (reason operator_armed_roam); otherwise the public first-party gate is UNCHANGED. The lab-attested +// private-target path is NOT relaxed (no 169.254 pivot from a lab session), and block_internal_hosts is a +// separate DNS-layer policy this never touches. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + ROAM_AUTHORIZED_ENV, + roamAuthorizedForTarget, + validateHttpScanScope, +} = require("../mcp/lib/scope.js"); +const { + LAB_TARGET_ACK_TOKEN, + LAB_TARGET_ACK_ENV, + LAB_TARGET_HOST_ENV, +} = require("../mcp/lib/lab-target-attest.js"); + +const TARGET = "vu.nl"; +const CROSS_HOST_URL = "https://attacker.example.org/x"; // host outside vu.nl; registrable example.org +const FIRST_PARTY_URL = "https://www.vu.nl/en/research"; // subdomain of the target + +function withRoamEnv(value, fn) { + const prev = process.env[ROAM_AUTHORIZED_ENV]; + if (value === null) delete process.env[ROAM_AUTHORIZED_ENV]; + else process.env[ROAM_AUTHORIZED_ENV] = value; + try { + return fn(); + } finally { + if (prev === undefined) delete process.env[ROAM_AUTHORIZED_ENV]; + else process.env[ROAM_AUTHORIZED_ENV] = prev; + } +} + +test("default (roam OFF): a cross-host URL is blocked", () => { + withRoamEnv(null, () => { + assert.throws(() => validateHttpScanScope(CROSS_HOST_URL, TARGET), /outside target_domain/); + }); +}); + +test("roam ARMED for this target: a cross-host URL is allowed with reason operator_armed_roam", () => { + withRoamEnv(TARGET, () => { + const result = validateHttpScanScope(CROSS_HOST_URL, TARGET); + assert.equal(result.allowed, true); + assert.equal(result.scope_decision, "allowed"); + assert.equal(result.reason, "operator_armed_roam"); + assert.equal(result.host, "attacker.example.org"); + assert.equal(result.target_domain, TARGET); + // The roamed host is described from ITS OWN public-suffix info, so the audit shows where it went. + assert.equal(result.registrable_domain, "example.org"); + }); +}); + +test("roam armed for a DIFFERENT target does not relax this session", () => { + withRoamEnv("some-other-engagement.com", () => { + assert.throws(() => validateHttpScanScope(CROSS_HOST_URL, TARGET), /outside target_domain/); + }); +}); + +test("roam matches the WHOLE target (trim + case-insensitive), not a suffix", () => { + withRoamEnv(` ${TARGET.toUpperCase()} `, () => { + assert.equal(validateHttpScanScope(CROSS_HOST_URL, TARGET).reason, "operator_armed_roam"); + }); + withRoamEnv("nl", () => { + // A public-suffix fragment of the target must NOT authorize roam. + assert.throws(() => validateHttpScanScope(CROSS_HOST_URL, TARGET), /outside target_domain/); + }); +}); + +test("roam ON: a FIRST-PARTY URL still resolves via the normal first_party_host path", () => { + withRoamEnv(TARGET, () => { + const result = validateHttpScanScope(FIRST_PARTY_URL, TARGET); + assert.equal(result.allowed, true); + assert.equal(result.reason, "first_party_host"); // not operator_armed_roam — roam only fires off-apex + }); +}); + +test("CRITICAL: an attested LAB target is NOT roamed off even with roam armed (no 169.254 pivot)", () => { + const prevAck = process.env[LAB_TARGET_ACK_ENV]; + const prevHost = process.env[LAB_TARGET_HOST_ENV]; + process.env[LAB_TARGET_ACK_ENV] = LAB_TARGET_ACK_TOKEN; + process.env[LAB_TARGET_HOST_ENV] = "127.0.0.1"; + try { + withRoamEnv("127.0.0.1", () => { + const labOpts = { labAuthorization: { private_targets: true } }; + // Same attested host: allowed via the lab path. + assert.equal( + validateHttpScanScope("http://127.0.0.1/api", "127.0.0.1", labOpts).reason, + "lab_attested_private_target", + ); + // Cross-host (cloud metadata IP) stays BLOCKED despite roam being armed for the lab target — the lab + // path returns/throws before the roam check, so a lab session can never pivot off the attested host. + assert.throws( + () => validateHttpScanScope("http://169.254.169.254/latest/meta-data/", "127.0.0.1", labOpts), + /outside attested lab target/, + ); + }); + } finally { + if (prevAck === undefined) delete process.env[LAB_TARGET_ACK_ENV]; + else process.env[LAB_TARGET_ACK_ENV] = prevAck; + if (prevHost === undefined) delete process.env[LAB_TARGET_HOST_ENV]; + else process.env[LAB_TARGET_HOST_ENV] = prevHost; + } +}); + +test("roamAuthorizedForTarget unit: empty/whitespace/mismatch false; exact (trim/case) true", () => { + withRoamEnv(null, () => assert.equal(roamAuthorizedForTarget(TARGET), false)); + withRoamEnv(" ", () => assert.equal(roamAuthorizedForTarget(TARGET), false)); + withRoamEnv("other.com", () => assert.equal(roamAuthorizedForTarget(TARGET), false)); + withRoamEnv(TARGET, () => assert.equal(roamAuthorizedForTarget(TARGET), true)); + withRoamEnv(` ${TARGET.toUpperCase()} `, () => assert.equal(roamAuthorizedForTarget(TARGET), true)); + withRoamEnv(TARGET, () => assert.equal(roamAuthorizedForTarget(""), false)); // empty target never roams +}); From c0c356264534fd68c07e1ab92676f22195ac4002 Mon Sep 17 00:00:00 2001 From: vmihalis <67660547+vmihalis@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:14:31 +0800 Subject: [PATCH 2/4] fix(scope): roam reaches PUBLIC hosts only; strip creds on roamed redirects; audit reason; IDN arm (round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review found a CRITICAL SSRF and related issues — fixed: - CRITICAL (agy/Codex P1): bob_browser_navigate calls the scope kernel with blockInternalHosts:false and relies on it entirely, so roam returning allowed for ANY host was an SSRF-to-metadata/localhost bypass. Roam now allows only valid PUBLIC DNS hosts (assertHttpScopeDomain gate) — an IP literal, loopback, RFC1918/link-local, cloud-metadata, or non-public name falls through to the cross-host block regardless of block_internal_hosts. New test asserts 169.254.169.254 / 127.0.0.1 / 10.x / [::1] / *.internal / *.local stay blocked under roam armed for a PUBLIC target (the case that would have caught it). - P1 #2 (Codex): safeFetch reused options.headers across redirects, so a credentialed first-party scan following a 302 to a roamed host leaked the target's Cookie/Authorization. Now strips them on a redirect whose host is NOT first-party to targetDomain (cross-site); first-party subdomain redirects keep them. stripCredentialHeaders exported + unit-tested. - P2 (Codex): scopeAuditFields now persists scope_reason, so a roamed request is loud in http-audit (operator_armed_roam) instead of an inferred URL-host diff. - P3 (Codex/agy): roamAuthorizedForTarget normalizes BOTH sides via normalizeDnsHostToAscii, so an operator arming the Unicode form of an IDN target matches the punycode session domain. Tested. glm's 'psl.parse-throw as the only IP filter' is replaced by the explicit assertHttpScopeDomain public-DNS gate (tested with IP literals). docs/FIRST_RUN.md updated: roam = public-only, creds-stripped-cross-site, audited. test:mcp 3185/0, 12 roam tests, check:syntax clean. --- docs/FIRST_RUN.md | 5 ++- mcp/lib/http-scan.js | 6 +++ mcp/lib/safe-fetch.js | 28 ++++++++++++++ mcp/lib/scope.js | 52 +++++++++++++++++-------- test/operator-armed-roam.test.js | 66 ++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 17 deletions(-) diff --git a/docs/FIRST_RUN.md b/docs/FIRST_RUN.md index 7aea293c..64b06b02 100644 --- a/docs/FIRST_RUN.md +++ b/docs/FIRST_RUN.md @@ -143,9 +143,10 @@ This is **default-off** and **target-bound**: roam is authorized only for the se Two boundaries are deliberately **not** relaxed by roam: - **Attested lab targets stay pinned.** A `BOB_LAB_TARGET` session remains locked to its exact attested host even if roam is armed — a loopback/RFC1918 session can never pivot to `169.254.169.254` or a LAN neighbour. -- **Internal/metadata hosts stay blocked.** `block_internal_hosts` is a separate DNS-resolution policy; roam relaxes the *target-domain* boundary only. To also reach internal IPs you must separately disable `block_internal_hosts` — roam alone is not SSRF-to-internal. +- **Roam reaches PUBLIC hosts only — never internal/metadata.** Roam authorizes cross-host to other registrable **public DNS** hosts; an IP literal, loopback, RFC1918/link-local, cloud-metadata (`169.254.169.254`), or a non-public name (`.internal`/`.local`/bare host) is **never** roamable — even if `block_internal_hosts` is off. The browser-navigate path disables `block_internal_hosts` and leans entirely on this scope kernel, so roam holds that line itself: it is never an SSRF-to-internal primitive. +- **Credentials are not replayed across sites.** On a redirect to a roamed (non-first-party) host, `Cookie`/`Authorization` are stripped, so an authenticated first-party scan that follows a 302 off-site never sends the target's credentials to the other host. -Only arm roam when the engagement's scope explicitly authorizes the other hosts. +A roamed request is recorded with `scope_reason: operator_armed_roam` in the HTTP audit. Only arm roam when the engagement's scope explicitly authorizes the other hosts. ## Lifecycle States diff --git a/mcp/lib/http-scan.js b/mcp/lib/http-scan.js index 1e0a3934..f06af695 100644 --- a/mcp/lib/http-scan.js +++ b/mcp/lib/http-scan.js @@ -45,6 +45,12 @@ function scopeAuditFields(scopeDecision) { for (const field of ["registrable_domain", "public_suffix", "public_suffix_source", "psl_overlay_file"]) { if (scopeDecision[field] != null) fields[field] = scopeDecision[field]; } + // Persist the scope-decision REASON so an audited request shows WHY it was allowed — in particular + // operator_armed_roam, so a roamed (cross-host) request is loud in the trail rather than an inferred + // diff of URL hosts (Codex P2). Same spread path as the suffix fields above. + if (typeof scopeDecision.reason === "string" && scopeDecision.reason) { + fields.scope_reason = scopeDecision.reason; + } return fields; } diff --git a/mcp/lib/safe-fetch.js b/mcp/lib/safe-fetch.js index 2c86829f..95a23cc7 100644 --- a/mcp/lib/safe-fetch.js +++ b/mcp/lib/safe-fetch.js @@ -6,6 +6,7 @@ const https = require("https"); const net = require("net"); const { isBlockedInternalHost, + isFirstPartyHost, shouldBlockInternalHosts, validateScanUrl, } = require("./url-surface.js"); @@ -35,6 +36,21 @@ function makeScopeBlockedError(message) { return error; } +// On a cross-SITE redirect (the next host is not first-party to the scoped target — e.g. a host reached +// via operator-armed roam), drop target-bound credentials so the original site's Cookie/Authorization are +// never replayed to a different site. Standard browser behaviour; load-bearing now that roam can let a +// redirect cross hosts (Codex P1). A same-site redirect (a first-party subdomain) keeps its headers. +function stripCredentialHeaders(headers) { + if (!headers || typeof headers !== "object") return headers; + const cleaned = {}; + for (const [name, value] of Object.entries(headers)) { + const lower = String(name).toLowerCase(); + if (lower === "authorization" || lower === "cookie") continue; + cleaned[name] = value; + } + return cleaned; +} + function assertSafeRequestUrl(url, targetDomain, options = {}) { try { validateScanUrl(url, options); @@ -337,12 +353,14 @@ async function safeFetch(url, options = {}) { let currentUrl = String(url); let currentMethod = String(options.method || "GET").toUpperCase(); let currentBody = options.body; + let currentHeaders = options.headers; let redirects = 0; while (true) { assertSafeRequestUrl(currentUrl, targetDomain, { blockInternalHosts }); const response = await requestOnce(currentUrl, { ...options, + headers: currentHeaders, blockInternalHosts, method: currentMethod, body: currentBody, @@ -364,6 +382,15 @@ async function safeFetch(url, options = {}) { const nextUrl = new URL(location, currentUrl).toString(); assertSafeRequestUrl(nextUrl, targetDomain, { blockInternalHosts }); + // Cross-SITE redirect (a roamed host that is not first-party to the scoped target): strip target-bound + // credentials before the next hop so the original site's Cookie/Authorization never reach it. + if (targetDomain) { + let nextHost = ""; + try { nextHost = new URL(nextUrl).hostname; } catch { nextHost = ""; } + if (nextHost && !isFirstPartyHost(nextHost, targetDomain)) { + currentHeaders = stripCredentialHeaders(currentHeaders); + } + } redirects += 1; const normalized = normalizeRedirectMethod(response.status, currentMethod, currentBody); currentMethod = normalized.method; @@ -383,4 +410,5 @@ module.exports = { isRedirectStatus, normalizeRedirectMethod, safeFetch, + stripCredentialHeaders, }; diff --git a/mcp/lib/scope.js b/mcp/lib/scope.js index fa667dd3..3b529c1f 100644 --- a/mcp/lib/scope.js +++ b/mcp/lib/scope.js @@ -50,9 +50,16 @@ const ROAM_AUTHORIZED_ENV = "BOB_HTTP_ROAM_AUTHORIZED"; function roamAuthorizedForTarget(targetDomain) { const armed = process.env[ROAM_AUTHORIZED_ENV]; if (typeof armed !== "string" || !armed.trim()) return false; - const domain = typeof targetDomain === "string" ? targetDomain.trim().toLowerCase() : ""; - if (!domain) return false; - return armed.trim().toLowerCase() === domain; + if (typeof targetDomain !== "string" || !targetDomain.trim()) return false; + // Normalize BOTH sides through the SAME DNS/IDNA + trailing-dot + lowercase path the target domain + // already passed (assertHttpScopeDomain → normalizeDnsHostToAscii), so an operator who arms the Unicode + // form of an IDN target (食狮.com.cn) still matches the punycode session domain (xn--85x722f.com.cn). + // Fail closed on a value that won't normalize. + try { + return normalizeDnsHostToAscii(armed, ROAM_AUTHORIZED_ENV) === normalizeDnsHostToAscii(targetDomain, "target_domain"); + } catch { + return false; + } } function normalizeDnsHostToAscii(value, fieldName) { @@ -315,18 +322,33 @@ function validateHttpScanScope(url, targetDomain, opts = {}) { // separately at DNS resolution in safe-fetch.js) still blocks internal/metadata IPs. The roamed host // is described from ITS OWN public-suffix info so the audit shows exactly where the request went. if (roamAuthorizedForTarget(domain)) { - const roamedSuffixInfo = publicSuffixInfoForHost(host); - return { - allowed: true, - scope_decision: "allowed", - reason: "operator_armed_roam", - host, - target_domain: domain, - registrable_domain: roamedSuffixInfo.registrable_domain, - public_suffix: roamedSuffixInfo.public_suffix, - public_suffix_source: roamedSuffixInfo.public_suffix_source, - psl_overlay_file: roamedSuffixInfo.psl_overlay_file, - }; + // Roam authorizes cross-host to other PUBLIC DNS hosts ONLY. An IP literal, loopback, RFC1918 / + // link-local, cloud-metadata, or any non-public name is NEVER roamable — even when a caller disables + // block_internal_hosts (the browser navigate path passes blockInternalHosts:false and leans ENTIRELY + // on this kernel), so roam can never become an SSRF-to-metadata/localhost primitive. The roamed host + // must clear the SAME public-DNS bar assertHttpScopeDomain holds the target to (registrable domain + // under a public suffix); anything that fails it falls through to the cross-host block below. + let roamedHostIsPublic = false; + try { + assertHttpScopeDomain(host); + roamedHostIsPublic = true; + } catch { + roamedHostIsPublic = false; + } + if (roamedHostIsPublic) { + const roamedSuffixInfo = publicSuffixInfoForHost(host); + return { + allowed: true, + scope_decision: "allowed", + reason: "operator_armed_roam", + host, + target_domain: domain, + registrable_domain: roamedSuffixInfo.registrable_domain, + public_suffix: roamedSuffixInfo.public_suffix, + public_suffix_source: roamedSuffixInfo.public_suffix_source, + psl_overlay_file: roamedSuffixInfo.psl_overlay_file, + }; + } } const domainSuffixInfo = publicSuffixInfoForHost(domain); throw makeScopeBlockedError( diff --git a/test/operator-armed-roam.test.js b/test/operator-armed-roam.test.js index 9df98817..e8c9f96a 100644 --- a/test/operator-armed-roam.test.js +++ b/test/operator-armed-roam.test.js @@ -18,6 +18,8 @@ const { LAB_TARGET_ACK_ENV, LAB_TARGET_HOST_ENV, } = require("../mcp/lib/lab-target-attest.js"); +const { stripCredentialHeaders } = require("../mcp/lib/safe-fetch.js"); +const { isFirstPartyHost } = require("../mcp/lib/url-surface.js"); const TARGET = "vu.nl"; const CROSS_HOST_URL = "https://attacker.example.org/x"; // host outside vu.nl; registrable example.org @@ -106,6 +108,70 @@ test("CRITICAL: an attested LAB target is NOT roamed off even with roam armed (n } }); +test("CRITICAL: roam armed for a PUBLIC target does NOT reach an internal/metadata host", () => { + // The SSRF case the browser navigate path (blockInternalHosts:false) leans ENTIRELY on this kernel to + // block. Roam authorizes cross-host to other PUBLIC hosts only — an IP literal / internal name is not + // public, so it falls through to the cross-host block even with roam armed for the public target. + withRoamEnv(TARGET, () => { + assert.throws( + () => validateHttpScanScope("http://169.254.169.254/latest/meta-data/", TARGET), + /outside target_domain/, + "cloud-metadata IP must stay blocked under roam", + ); + for (const u of [ + "http://127.0.0.1/", // loopback + "http://10.0.0.5/", // RFC1918 + "http://[::1]/", // IPv6 loopback + "http://metadata.internal/", // non-public name + "http://printer.local/", // non-public name + "http://intranet/", // bare host, no public suffix + ]) { + assert.throws(() => validateHttpScanScope(u, TARGET), undefined, `must stay blocked under roam: ${u}`); + } + }); +}); + +test("roam matches an IDN target armed in its Unicode form (normalized to punycode)", () => { + // assertHttpScopeDomain normalizes the session domain to ASCII before validateHttpScanScope sees it; + // the arm must normalize the same way so the operator can arm with the Unicode name. + const PUNY = "xn--85x722f.com.cn"; // 食狮.com.cn + withRoamEnv("食狮.com.cn", () => { + assert.equal(validateHttpScanScope(CROSS_HOST_URL, PUNY).reason, "operator_armed_roam"); + }); +}); + +// ── P1 #2: roamed redirects must not carry the target's credentials ────────────────────────────────── + +test("safeFetch strips Cookie + Authorization (case-insensitive) and keeps other headers", () => { + const out = stripCredentialHeaders({ + Cookie: "sid=secret", + authorization: "Bearer a", + AUTHORIZATION: "Bearer b", + "User-Agent": "bob", + Accept: "application/json", + }); + assert.equal(out.Cookie, undefined); + assert.equal(out.authorization, undefined); + assert.equal(out.AUTHORIZATION, undefined); + assert.equal(out["User-Agent"], "bob"); + assert.equal(out.Accept, "application/json"); +}); + +test("stripCredentialHeaders is null/empty safe", () => { + assert.equal(stripCredentialHeaders(null), null); + assert.equal(stripCredentialHeaders(undefined), undefined); + assert.deepEqual(stripCredentialHeaders({}), {}); +}); + +test("redirect cred-strip decision: a roamed cross-site host is not first-party (strip), a first-party subdomain is (keep)", () => { + // safeFetch strips credentials on a redirect whose host is NOT first-party to targetDomain — so a 302 + // from the target to a roamed host never replays the target's Cookie/Authorization, while a first-party + // subdomain redirect keeps them. This is the predicate that gates the strip. + assert.equal(isFirstPartyHost("evil.example.org", "vu.nl"), false); // roamed → strip + assert.equal(isFirstPartyHost("api.vu.nl", "vu.nl"), true); // first-party subdomain → keep + assert.equal(isFirstPartyHost("vu.nl", "vu.nl"), true); // same host → keep +}); + test("roamAuthorizedForTarget unit: empty/whitespace/mismatch false; exact (trim/case) true", () => { withRoamEnv(null, () => assert.equal(roamAuthorizedForTarget(TARGET), false)); withRoamEnv(" ", () => assert.equal(roamAuthorizedForTarget(TARGET), false)); From 39b36a0765327cdf88b378d12128a25ba36a6374 Mon Sep 17 00:00:00 2001 From: vmihalis <67660547+vmihalis@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:11:21 +0800 Subject: [PATCH 3/4] fix(scope): close roam SSRF (rebind + lab-escape) + comprehensive cross-site credential/body protection (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 found TWO more CRITICALs + credential vectors. All fixed (operator keeps allow-all-public; the exfiltration-to-any-PUBLIC-host is logged accepted risk): CRITICALs (SSRF): - Lab-escape (agy/Codex): the roam public-host check called assertHttpScopeDomain which HONORS a lab attestation, so a concurrent BOB_LAB_TARGET reclassified an internal host as roamable. Added ignoreLabAttestation; roam uses the lab-BLIND form, so an attested internal host is rejected. Tested. - DNS rebind (Claude/Codex): the gate validated the host STRING, not the resolved IP, and the browser path disables block_internal_hosts. The roam decision now carries enforce_internal_block:true; safeFetch + assertSafeResolvedRequestUrl ALWAYS resolve + block internal IPs for a roamed request (IP pinned for connect), so a public name resolving to 169.254/127/10.x is blocked. Tested. Credential / exfil-of-target-secrets vectors: - Initial direct request to a roamed host (agy HIGH): http-scan no longer applies the target's auth_profile when initialScopeDecision.reason is operator_armed_roam. - Cross-site redirect (CodeRabbit CRITICAL + Claude/Codex): stripCredentialHeaders is now an ALLOWLIST (keep user-agent/accept/…); drops Cookie, Authorization, Proxy-Authorization, AND custom X-Api-Key/X-Auth-Token headers — not a 2-name denylist. Body dropped on cross-site (covers 307/308). Protocol downgrade (https->http) also strips (agy). - Audit (CodeRabbit/Codex): safeFetch carries the final hop's scopeReason; a first-party request that redirects INTO a roam now audits operator_armed_roam. docs/FIRST_RUN.md updated (lab-blind, resolved-IP, comprehensive strip). test:mcp 3187/0, 14 roam tests, check:syntax clean. --- docs/FIRST_RUN.md | 4 +- mcp/lib/http-scan.js | 18 +++++++-- mcp/lib/safe-fetch.js | 63 ++++++++++++++++++++++---------- mcp/lib/scope.js | 22 +++++++---- test/operator-armed-roam.test.js | 45 ++++++++++++++++++++++- 5 files changed, 118 insertions(+), 34 deletions(-) diff --git a/docs/FIRST_RUN.md b/docs/FIRST_RUN.md index 64b06b02..e6c290e7 100644 --- a/docs/FIRST_RUN.md +++ b/docs/FIRST_RUN.md @@ -143,8 +143,8 @@ This is **default-off** and **target-bound**: roam is authorized only for the se Two boundaries are deliberately **not** relaxed by roam: - **Attested lab targets stay pinned.** A `BOB_LAB_TARGET` session remains locked to its exact attested host even if roam is armed — a loopback/RFC1918 session can never pivot to `169.254.169.254` or a LAN neighbour. -- **Roam reaches PUBLIC hosts only — never internal/metadata.** Roam authorizes cross-host to other registrable **public DNS** hosts; an IP literal, loopback, RFC1918/link-local, cloud-metadata (`169.254.169.254`), or a non-public name (`.internal`/`.local`/bare host) is **never** roamable — even if `block_internal_hosts` is off. The browser-navigate path disables `block_internal_hosts` and leans entirely on this scope kernel, so roam holds that line itself: it is never an SSRF-to-internal primitive. -- **Credentials are not replayed across sites.** On a redirect to a roamed (non-first-party) host, `Cookie`/`Authorization` are stripped, so an authenticated first-party scan that follows a 302 off-site never sends the target's credentials to the other host. +- **Roam reaches PUBLIC hosts only — never internal/metadata.** Roam authorizes cross-host to other registrable **public DNS** hosts; an IP literal, loopback, RFC1918/link-local, cloud-metadata (`169.254.169.254`), or a non-public name (`.internal`/`.local`/bare host) is **never** roamable — even if `block_internal_hosts` is off, and even if a concurrent `BOB_LAB_TARGET` attestation names that host (the roam check is lab-blind). A public **name** that *resolves* to an internal IP is blocked too: a roamed request always resolves and rejects internal addresses and pins the resolved IP for the connection, so DNS rebinding can't smuggle one through. The browser-navigate path disables `block_internal_hosts` and leans on this kernel; roam holds the line itself. Roam is not an SSRF-to-internal primitive. +- **Credentials and bodies are not replayed off-site.** Bob never sends the target's `auth_profile` credentials to a roamed host — not on a direct roamed request (no `auth_profile` is applied), and not on a redirect to a roamed host or a protocol downgrade (`https`→`http`): the request is reduced to a minimal non-credential header allowlist and the body is dropped, covering `Cookie`/`Authorization`/`Proxy-Authorization`/custom `X-Api-Key`-style headers — not just `Cookie`/`Authorization`. A roamed request is recorded with `scope_reason: operator_armed_roam` in the HTTP audit. Only arm roam when the engagement's scope explicitly authorizes the other hosts. diff --git a/mcp/lib/http-scan.js b/mcp/lib/http-scan.js index f06af695..e70376e4 100644 --- a/mcp/lib/http-scan.js +++ b/mcp/lib/http-scan.js @@ -206,8 +206,14 @@ async function httpScan(args) { // Merge only the profile's HEADER fields; the canonical PROFILE_METADATA_KEYS strip // (credentials/storage + PR-PROV synthetic provenance flags + synthetic mailbox) // ensures Bob-local secrets never reach the target as request headers. Pure helper: - // returns a new map, so reassign. - headers = applyAuthProfileHeaders(headers, auth); + // returns a new map, so reassign. BUT do NOT apply the target's auth_profile to a ROAMED host + // (initialScopeDecision.reason === operator_armed_roam): a credential bound to the target must never + // be sent to a different site, even on a direct operator-armed roam request (round-2 agy HIGH) — the + // redirect path strips cross-site too; this closes the initial hop. A roamed host proceeds uncredentialed. + const roamedInitialRequest = !!(initialScopeDecision && initialScopeDecision.reason === "operator_armed_roam"); + if (!roamedInitialRequest) { + headers = applyAuthProfileHeaders(headers, auth); + } } else { audit({ status: null, @@ -234,6 +240,7 @@ async function httpScan(args) { bodyTruncated, text, arrayBuffer, + scopeReason, } = await safeFetch(url, { method, headers, @@ -267,12 +274,17 @@ async function httpScan(args) { const responseMode = args.response_mode || "full"; const bodyLimit = args.body_limit || 2000; const auditTs = new Date().toISOString(); + // Audit the FINAL hop's scope reason: a first-party request that redirects INTO a roamed host must + // record operator_armed_roam, not the initial first-party decision (round-2 CodeRabbit/Codex). + const auditScopeDecision = scopeReason + ? { ...(initialScopeDecision || {}), reason: scopeReason } + : initialScopeDecision; audit({ status, error: null, scope_decision: "allowed", final_url: redactUrlSensitiveValues(finalUrl), - ...scopeAuditFields(initialScopeDecision), + ...scopeAuditFields(auditScopeDecision), }); // Plane T Cycle T.5 — JWT-as-observation-kind. Scan response headers + body // for JWT-shaped tokens; emit one observation.recorded per distinct token diff --git a/mcp/lib/safe-fetch.js b/mcp/lib/safe-fetch.js index 95a23cc7..5a277537 100644 --- a/mcp/lib/safe-fetch.js +++ b/mcp/lib/safe-fetch.js @@ -36,17 +36,18 @@ function makeScopeBlockedError(message) { return error; } -// On a cross-SITE redirect (the next host is not first-party to the scoped target — e.g. a host reached -// via operator-armed roam), drop target-bound credentials so the original site's Cookie/Authorization are -// never replayed to a different site. Standard browser behaviour; load-bearing now that roam can let a -// redirect cross hosts (Codex P1). A same-site redirect (a first-party subdomain) keeps its headers. +const SAFE_REDIRECT_HEADERS = new Set(["user-agent", "accept", "accept-language", "accept-encoding"]); + +// On a cross-SITE or protocol-DOWNGRADE redirect (a roamed host, or https→http), reduce request headers to +// a minimal non-credential ALLOWLIST so NO target-bound credential — Cookie, Authorization, +// Proxy-Authorization, or a custom auth header (X-Api-Key, X-Auth-Token, …) the caller/auth_profile set — +// is replayed to the new origin. An allowlist, not a Cookie/Authorization denylist, so an arbitrary custom +// auth header can't slip through (round-2: a Cookie/Authorization-only strip was insufficient). function stripCredentialHeaders(headers) { if (!headers || typeof headers !== "object") return headers; const cleaned = {}; for (const [name, value] of Object.entries(headers)) { - const lower = String(name).toLowerCase(); - if (lower === "authorization" || lower === "cookie") continue; - cleaned[name] = value; + if (SAFE_REDIRECT_HEADERS.has(String(name).toLowerCase())) cleaned[name] = value; } return cleaned; } @@ -147,13 +148,17 @@ async function resolveSafeAddress(hostname, options = {}) { } async function assertSafeResolvedRequestUrl(url, targetDomain, options = {}) { - assertSafeRequestUrl(url, targetDomain, options); - if (!shouldBlockInternalHosts(options)) { + const scopeDecision = assertSafeRequestUrl(url, targetDomain, options); + // A roamed (operator_armed_roam) request ALWAYS resolves and blocks internal IPs, even when the caller + // disabled blockInternalHosts (the browser navigate path does) — so a public name that RESOLVES to an + // internal/metadata IP cannot become an SSRF via roam (DNS rebinding). (Round-2 CRITICAL.) + const enforceInternalBlock = !!(scopeDecision && scopeDecision.enforce_internal_block); + if (!enforceInternalBlock && !shouldBlockInternalHosts(options)) { return; } const parsed = new URL(url); - await resolveSafeAddress(parsed.hostname, options); + await resolveSafeAddress(parsed.hostname, { ...options, blockInternalHosts: true }); } function makeTimeoutError(timeoutMs) { @@ -357,16 +362,23 @@ async function safeFetch(url, options = {}) { let redirects = 0; while (true) { - assertSafeRequestUrl(currentUrl, targetDomain, { blockInternalHosts }); + const scopeDecision = assertSafeRequestUrl(currentUrl, targetDomain, { blockInternalHosts }); + // A roamed hop ALWAYS resolves + blocks internal IPs, even if the caller disabled blockInternalHosts — + // a public name that resolves to an internal IP cannot become a roam SSRF (DNS rebinding). (CRITICAL.) + const hopBlockInternal = blockInternalHosts || !!(scopeDecision && scopeDecision.enforce_internal_block); const response = await requestOnce(currentUrl, { ...options, headers: currentHeaders, - blockInternalHosts, + blockInternalHosts: hopBlockInternal, method: currentMethod, body: currentBody, redirected: redirects > 0, redirectCount: redirects, }); + // Carry THIS hop's scope reason on the response so the caller can audit the FINAL hop — in particular a + // first-party request that REDIRECTS into a roamed host audits operator_armed_roam, not the initial + // first-party decision (round-2 CodeRabbit/Codex). The returned response is the final hop's. + response.scopeReason = scopeDecision && scopeDecision.reason ? scopeDecision.reason : null; if (!followRedirects || !isRedirectStatus(response.status)) { return response; @@ -382,14 +394,25 @@ async function safeFetch(url, options = {}) { const nextUrl = new URL(location, currentUrl).toString(); assertSafeRequestUrl(nextUrl, targetDomain, { blockInternalHosts }); - // Cross-SITE redirect (a roamed host that is not first-party to the scoped target): strip target-bound - // credentials before the next hop so the original site's Cookie/Authorization never reach it. - if (targetDomain) { - let nextHost = ""; - try { nextHost = new URL(nextUrl).hostname; } catch { nextHost = ""; } - if (nextHost && !isFirstPartyHost(nextHost, targetDomain)) { - currentHeaders = stripCredentialHeaders(currentHeaders); - } + // Reduce headers to the safe allowlist + drop the body on a CROSS-SITE (roamed) or protocol-DOWNGRADE + // (https→http) redirect, so the target's credentials and request body are never replayed to a different + // origin — including a 307/308 that would otherwise preserve them. A same-site, same-scheme redirect + // keeps them. (Round-2: custom auth headers, proxy creds, body, and downgrade — not just Cookie/Auth.) + let crossOrigin = false; + try { + const from = new URL(currentUrl); + const to = new URL(nextUrl); + const crossSite = targetDomain + ? !isFirstPartyHost(to.hostname, targetDomain) + : to.host !== from.host; + const downgrade = from.protocol === "https:" && to.protocol === "http:"; + crossOrigin = crossSite || downgrade; + } catch { + crossOrigin = true; + } + if (crossOrigin) { + currentHeaders = stripCredentialHeaders(currentHeaders); + currentBody = undefined; } redirects += 1; const normalized = normalizeRedirectMethod(response.status, currentMethod, currentBody); diff --git a/mcp/lib/scope.js b/mcp/lib/scope.js index 3b529c1f..e69a72a7 100644 --- a/mcp/lib/scope.js +++ b/mcp/lib/scope.js @@ -249,7 +249,11 @@ function assertHttpScopeDomain(targetDomain, opts = {}) { // attestation. The attestation is supplied explicitly (opts.labAuthorization, // the init bootstrap before state is persisted) or read from the persisted // audit-graded session artifact. See lab-target-attest.js. - if (labTargetEligibleHost(host)) { + // ignoreLabAttestation (roam): a roamed host must clear the PUBLIC-DNS bar with NO lab escape, so a + // session-or-env lab attestation (BOB_LAB_TARGET) can never reclassify an internal/loopback host as + // roamable — otherwise roam on a public target would inherit a concurrent lab attestation and become a + // LAN/loopback SSRF (round-2 CRITICAL). Without the flag the existing lab escape is unchanged. + if (!opts.ignoreLabAttestation && labTargetEligibleHost(host)) { const authorization = opts.labAuthorization != null ? opts.labAuthorization : labAuthorizationForTarget(host); @@ -322,15 +326,16 @@ function validateHttpScanScope(url, targetDomain, opts = {}) { // separately at DNS resolution in safe-fetch.js) still blocks internal/metadata IPs. The roamed host // is described from ITS OWN public-suffix info so the audit shows exactly where the request went. if (roamAuthorizedForTarget(domain)) { - // Roam authorizes cross-host to other PUBLIC DNS hosts ONLY. An IP literal, loopback, RFC1918 / - // link-local, cloud-metadata, or any non-public name is NEVER roamable — even when a caller disables - // block_internal_hosts (the browser navigate path passes blockInternalHosts:false and leans ENTIRELY - // on this kernel), so roam can never become an SSRF-to-metadata/localhost primitive. The roamed host - // must clear the SAME public-DNS bar assertHttpScopeDomain holds the target to (registrable domain - // under a public suffix); anything that fails it falls through to the cross-host block below. + // Roam authorizes cross-host to other PUBLIC DNS hosts ONLY, with NO lab escape (ignoreLabAttestation): + // an IP literal, loopback, RFC1918/link-local, cloud-metadata, or non-public name — OR a host a + // concurrent lab attestation (BOB_LAB_TARGET) would otherwise whitelist — is never roamable and falls + // through to the cross-host block. A public NAME can still RESOLVE to an internal IP (DNS rebinding), + // so the decision carries enforce_internal_block:true and the fetch/driver layer ALWAYS resolves and + // blocks internal IPs for a roamed request, even when the caller disabled block_internal_hosts (the + // browser navigate path does) — closing the round-2 CRITICALs (lab-escape SSRF + rebind-to-internal). let roamedHostIsPublic = false; try { - assertHttpScopeDomain(host); + assertHttpScopeDomain(host, { ignoreLabAttestation: true }); roamedHostIsPublic = true; } catch { roamedHostIsPublic = false; @@ -341,6 +346,7 @@ function validateHttpScanScope(url, targetDomain, opts = {}) { allowed: true, scope_decision: "allowed", reason: "operator_armed_roam", + enforce_internal_block: true, host, target_domain: domain, registrable_domain: roamedSuffixInfo.registrable_domain, diff --git a/test/operator-armed-roam.test.js b/test/operator-armed-roam.test.js index e8c9f96a..3144d1ee 100644 --- a/test/operator-armed-roam.test.js +++ b/test/operator-armed-roam.test.js @@ -10,6 +10,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { ROAM_AUTHORIZED_ENV, + assertHttpScopeDomain, roamAuthorizedForTarget, validateHttpScanScope, } = require("../mcp/lib/scope.js"); @@ -142,17 +143,25 @@ test("roam matches an IDN target armed in its Unicode form (normalized to punyco // ── P1 #2: roamed redirects must not carry the target's credentials ────────────────────────────────── -test("safeFetch strips Cookie + Authorization (case-insensitive) and keeps other headers", () => { +test("cross-site redirect headers: allowlist keeps only safe headers, drops ALL credential headers", () => { const out = stripCredentialHeaders({ Cookie: "sid=secret", authorization: "Bearer a", AUTHORIZATION: "Bearer b", + "Proxy-Authorization": "Basic z", + "X-Api-Key": "k", // custom auth header — must NOT slip through a Cookie/Authorization-only denylist + "X-Auth-Token": "t", "User-Agent": "bob", Accept: "application/json", }); + // dropped: every credential-bearing header, including custom + proxy assert.equal(out.Cookie, undefined); assert.equal(out.authorization, undefined); assert.equal(out.AUTHORIZATION, undefined); + assert.equal(out["Proxy-Authorization"], undefined); + assert.equal(out["X-Api-Key"], undefined); + assert.equal(out["X-Auth-Token"], undefined); + // kept: only the safe, non-credential allowlist assert.equal(out["User-Agent"], "bob"); assert.equal(out.Accept, "application/json"); }); @@ -172,6 +181,40 @@ test("redirect cred-strip decision: a roamed cross-site host is not first-party assert.equal(isFirstPartyHost("vu.nl", "vu.nl"), true); // same host → keep }); +test("CRITICAL: roam's public-host check is lab-BLIND — an attested internal host is rejected", () => { + // Round-2 CRITICAL: assertHttpScopeDomain(host) honors a lab attestation, so without ignoreLabAttestation + // a concurrent BOB_LAB_TARGET would reclassify an internal host as roamable → LAN/loopback SSRF. + const prevAck = process.env[LAB_TARGET_ACK_ENV]; + const prevHost = process.env[LAB_TARGET_HOST_ENV]; + process.env[LAB_TARGET_ACK_ENV] = LAB_TARGET_ACK_TOKEN; + process.env[LAB_TARGET_HOST_ENV] = "192.168.1.53"; + try { + // With the attestation, the internal host IS accepted by the normal check... + assert.equal( + assertHttpScopeDomain("192.168.1.53", { labAuthorization: { private_targets: true } }), + "192.168.1.53", + ); + // ...but the lab-BLIND form the roam gate uses rejects it regardless of the attestation. + assert.throws( + () => assertHttpScopeDomain("192.168.1.53", { ignoreLabAttestation: true }), + /not a public DNS domain/, + ); + } finally { + if (prevAck === undefined) delete process.env[LAB_TARGET_ACK_ENV]; + else process.env[LAB_TARGET_ACK_ENV] = prevAck; + if (prevHost === undefined) delete process.env[LAB_TARGET_HOST_ENV]; + else process.env[LAB_TARGET_HOST_ENV] = prevHost; + } +}); + +test("the roam decision carries enforce_internal_block (fetch/driver then resolves + blocks internal IPs)", () => { + withRoamEnv(TARGET, () => { + const result = validateHttpScanScope(CROSS_HOST_URL, TARGET); + assert.equal(result.reason, "operator_armed_roam"); + assert.equal(result.enforce_internal_block, true); + }); +}); + test("roamAuthorizedForTarget unit: empty/whitespace/mismatch false; exact (trim/case) true", () => { withRoamEnv(null, () => assert.equal(roamAuthorizedForTarget(TARGET), false)); withRoamEnv(" ", () => assert.equal(roamAuthorizedForTarget(TARGET), false)); From f33fc70417617a5073517afd59f10d046e325ef2 Mon Sep 17 00:00:00 2001 From: vmihalis <67660547+vmihalis@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:22:51 +0800 Subject: [PATCH 4/4] docs(readme): remove logo image from header Claude-Session: https://claude.ai/code/session_011bmG7Hzq1LA8gPriZqJZnQ --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 419c2f5f..249be516 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -

- Hacker Bob -

-

Hacker Bob

A local MCP workflow framework for authorized bug bounty research.