diff --git a/README.md b/README.md
index 419c2f5f..249be516 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,3 @@
-
-
-
-
Hacker Bob
A local MCP workflow framework for authorized bug bounty research.
diff --git a/docs/FIRST_RUN.md b/docs/FIRST_RUN.md
index 4d58b003..e6c290e7 100644
--- a/docs/FIRST_RUN.md
+++ b/docs/FIRST_RUN.md
@@ -130,6 +130,24 @@ 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.
+- **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.
+
## Lifecycle States
A `/bob-evaluate` run advances through six lifecycle states driven by `bob_advance_session(to_state)`:
diff --git a/mcp/lib/http-scan.js b/mcp/lib/http-scan.js
index 1e0a3934..e70376e4 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;
}
@@ -200,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,
@@ -228,6 +240,7 @@ async function httpScan(args) {
bodyTruncated,
text,
arrayBuffer,
+ scopeReason,
} = await safeFetch(url, {
method,
headers,
@@ -261,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 2c86829f..5a277537 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,22 @@ function makeScopeBlockedError(message) {
return error;
}
+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)) {
+ if (SAFE_REDIRECT_HEADERS.has(String(name).toLowerCase())) cleaned[name] = value;
+ }
+ return cleaned;
+}
+
function assertSafeRequestUrl(url, targetDomain, options = {}) {
try {
validateScanUrl(url, options);
@@ -131,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) {
@@ -337,18 +358,27 @@ 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 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,
- blockInternalHosts,
+ headers: currentHeaders,
+ 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;
@@ -364,6 +394,26 @@ async function safeFetch(url, options = {}) {
const nextUrl = new URL(location, currentUrl).toString();
assertSafeRequestUrl(nextUrl, targetDomain, { blockInternalHosts });
+ // 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);
currentMethod = normalized.method;
@@ -383,4 +433,5 @@ module.exports = {
isRedirectStatus,
normalizeRedirectMethod,
safeFetch,
+ stripCredentialHeaders,
};
diff --git a/mcp/lib/scope.js b/mcp/lib/scope.js
index c4368444..e69a72a7 100644
--- a/mcp/lib/scope.js
+++ b/mcp/lib/scope.js
@@ -29,6 +29,39 @@ 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;
+ 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) {
const raw = String(value || "").trim().replace(/\.+$/, "");
if (!raw) throw new Error(`${fieldName} is required`);
@@ -216,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);
@@ -283,6 +320,42 @@ 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)) {
+ // 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, { ignoreLabAttestation: true });
+ roamedHostIsPublic = true;
+ } catch {
+ roamedHostIsPublic = false;
+ }
+ if (roamedHostIsPublic) {
+ const roamedSuffixInfo = publicSuffixInfoForHost(host);
+ return {
+ allowed: true,
+ scope_decision: "allowed",
+ reason: "operator_armed_roam",
+ enforce_internal_block: true,
+ 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 +423,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..3144d1ee
--- /dev/null
+++ b/test/operator-armed-roam.test.js
@@ -0,0 +1,225 @@
+"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,
+ assertHttpScopeDomain,
+ 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 { 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
+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("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("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");
+});
+
+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("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));
+ 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
+});