Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
<p align="center">
<img src="docs/hacker-bob.png" alt="Hacker Bob" width="320" />
</p>

<h1 align="center">Hacker Bob</h1>

<p align="center"><i>A local MCP workflow framework for authorized bug bounty research.</i></p>
Expand Down
18 changes: 18 additions & 0 deletions docs/FIRST_RUN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`:
Expand Down
24 changes: 21 additions & 3 deletions mcp/lib/http-scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
vmihalis marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return fields;
}

Expand Down Expand Up @@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟠 high

[Claude 🟠 high] security — Initial-hop credential strip lives only in http-scan; other credentialed safeFetch callers leak target creds to roamed hosts

The PR's stated invariant — "a credential bound to the target must never be sent to a different site, even on a direct operator-armed roam request" — is implemented ONLY here in http-scan.js (the roamedInitialRequest guard skipping applyAuthProfileHeaders). But safeFetch is the chokepoint, and it only strips credentials on REDIRECT hops; the first hop trusts options.headers verbatim. So any other credentialed caller of safeFetch that reaches a roamed host on its first request leaks the target's credentials off-site. Verified concrete path: offensive-idor-producer.js:1035 (runProbe) calls safeFetch with headers carrying the IDOR identity's real auth, followRedirects:false, and NO roam guard. With roam armed, validateHttpScanScope now ALLOWS a cross-host url; followRedirects:false means the redirect-strip never runs and there is no roamedInitialRequest check — so the target's session credentials are sent directly to a roamed host. offensive-cors/reflect producers and signup.js are the same shape. Fix: move the invariant into safeFetch itself — before the first requestOnce, if the hop host is not first-party (or scopeDecision.reason===operator_armed_roam), apply stripCredentialHeaders. Relying on 6+ call sites to each re-implement the guard is how this regresses.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟡 medium

[Claude 🟡 medium] security — Direct roamed request strips auth_profile but replays caller-supplied credential headers (args.headers) unfiltered

On a direct operator-armed roam request, applyAuthProfileHeaders is correctly withheld — but headers = args.headers || {} (http-scan.js:196) is passed to safeFetch unfiltered on that first hop. The redirect path applies the full stripCredentialHeaders allowlist (safe-fetch.js), but the initial direct roam hop does not. A caller/agent that placed Cookie, Authorization, or a custom X-Api-Key in args.headers replays it verbatim to the roamed cross-host origin on the first hop — while the identical header would be stripped if reached via redirect. The allowlist built for cross-site protection is applied asymmetrically: strict on redirects, absent on the direct initial roam. Fix: when roamedInitialRequest is true, run headers = stripCredentialHeaders(headers) before dispatch, mirroring the redirect path.

[glm (Claude) 🔵 low] security — Direct roamed path uses weaker cred-stripping than the redirect path (caller headers pass through)

http-scan.js blocks applyAuthProfileHeaders for a roamed initial request (correct), but args.headers (agent-supplied) flow through unfiltered. So the redirect path uses an allowlist (stripCredentialHeaders) and strips custom auth headers, while the direct roamed path only blocks the profile application. An agent that manually places the target's Cookie/Authorization/X-Api-Key into args.headers replays them to the roamed host. The README's guarantee is scoped to auth_profile specifically, so this is technically in-spec — but the two paths use different cred-stripping strengths, and defense-in-depth would run the same allowlist on a direct roamed request.

Suggested change
const roamedInitialRequest = !!(initialScopeDecision && initialScopeDecision.reason === "operator_armed_roam");
const roamedInitialRequest = !!(initialScopeDecision && initialScopeDecision.reason === "operator_armed_roam");
if (!roamedInitialRequest) {
headers = applyAuthProfileHeaders(headers, auth);
} else {
headers = stripCredentialHeaders(headers);
}

if (!roamedInitialRequest) {
headers = applyAuthProfileHeaders(headers, auth);
}
} else {
audit({
status: null,
Expand All @@ -228,6 +240,7 @@ async function httpScan(args) {
bodyTruncated,
text,
arrayBuffer,
scopeReason,
} = await safeFetch(url, {
method,
headers,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟡 medium

[agy 🟡 medium] security — Audit-trail evasion on redirect loops: roamed-then-back-to-first-party (and error hops) mislabeled

The scope reason is audited from response.scopeReason (the final hop). Scenario A: a scan initiated to a roamed host (allowed via operator roam) that redirects back to the first-party domain terminates with scopeReason "first_party_host" — the log attributes a roam-initiated request to first_party_host. Scenario B: a first-party scan that redirects to a roamed host and then fails (DNS error/timeout) falls into the catch block, which audits with initialScopeDecision.reason (first_party_host), obscuring that a roamed connection was attempted. Net: scans targeting roamed hosts or failing on a roamed redirect hop are recorded as first_party_host, making out-of-scope activity appear authorized under standard policy. Audit the per-hop decisions, not just the final hop's reason.

[Claude 🟡 medium] security — Redirect-into-roam audit row keeps the original first-party host's suffix metadata

auditScopeDecision spreads the INITIAL (first-party) decision and overwrites only reason. So when a first-party request redirects into a roamed host, the audit row shows scope_reason:operator_armed_roam and final_url: (correct) — but registrable_domain / public_suffix / public_suffix_source still describe the ORIGINAL first-party target, not the roamed host the request actually landed on. The whole selling point of roam is "every roamed host stays visible in the audit"; here the host-descriptor fields lie about which host was reached. safeFetch only carries response.scopeReason back, not the roamed decision's suffix info — carry the full final-hop scopeDecision instead and audit that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟡 medium

[glm (Claude) 🟡 medium] correctness — Audit records stale first-party suffix with operator_armed_roam; enforce_internal_block never persisted

The PR threads the final-hop reason but not the final-hop's suffix fields. On the canonical roam case (a first-party request that redirects INTO a roamed host), initialScopeDecision is first_party_host (registrable_domain: target) and scopeReason is operator_armed_roam; the merged object emits scope_reason: operator_armed_roam next to registrable_domain: <target> — the audit says 'roamed' while describing the target, not where the request landed. For a control whose entire rationale is making roamed hops 'loud in the trail,' the suffix provenance is misleading. Additionally, enforce_internal_block is never persisted at all — for a security-critical control, the audit should record that internal-blocking was enforced on the roamed hop. Thread the full final-hop scope decision (roamed host's registrable_domain/public_suffix + enforce_internal_block) instead of overlaying only reason.

[Claude 🔵 low] correctness — Audit row conflates two hosts on redirect-into-roam: scope_reason=operator_armed_roam alongside the TARGET's suffix fields

auditScopeDecision = { ...initialScopeDecision, reason: scopeReason } threads the final-hop reason but not the final-hop's suffix fields. On a first-party→roam redirect, reason becomes operator_armed_roam while registrable_domain/public_suffix/public_suffix_source (via scopeAuditFields) remain the TARGET's, not the roamed host's. The audit row then reads scope_reason: operator_armed_roam next to the target's registrable domain, with only final_url revealing where the request actually landed. For a feature whose selling point is 'every roamed host stays visible in the audit,' a row whose suffix fields describe a different host than its reason is a triage trap. Carry the final hop's full suffix info rather than only overriding reason.

? { ...(initialScopeDecision || {}), reason: scopeReason }
: initialScopeDecision;
Comment on lines +279 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the final roamed scope metadata in audits

When an in-target URL redirects to a roamed host, safeFetch only returns the final hop's reason, and this merge keeps the initial decision's registrable_domain/public_suffix while replacing just reason. The resulting http-audit row can say scope_reason: operator_armed_roam but still attribute the request to the target's domain instead of the roamed host, which weakens the audit trail for cross-host traffic; carry the final scope decision, not just its reason.

Useful? React with 👍 / 👎.

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
Expand Down
61 changes: 56 additions & 5 deletions mcp/lib/safe-fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const https = require("https");
const net = require("net");
const {
isBlockedInternalHost,
isFirstPartyHost,
shouldBlockInternalHosts,
validateScanUrl,
} = require("./url-surface.js");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Comment on lines 160 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin roamed browser navigations after DNS checks

When a roamed URL is checked for the browser path, this helper performs a Node DNS lookup and returns, but the subsequent browser navigation uses page.goto(url) in mcp/browser-driver.js:539 and resolves the hostname again. With roam armed, an attacker-controlled public hostname can answer public during preflight and then rebind to 169.254/loopback for Chromium, so the promised internal-host block for roamed browser navigation is bypassed unless the checked address is pinned or navigation is otherwise intercepted.

Useful? React with 👍 / 👎.

}

function makeTimeoutError(timeoutMs) {
Expand Down Expand Up @@ -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;
Comment thread
vmihalis marked this conversation as resolved.
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject roamed hops through proxy egress

For a roamed hop this sets hopBlockInternal, but proxy-backed egress can still be used because the proxy compatibility check only looks at the session's block_internal_hosts policy. In that configuration the target host is resolved again by the HTTP/HTTPS/SOCKS proxy (the local pre-resolution in requestOnce does not pin what the proxy connects to), so an operator-armed roam to an attacker-controlled public name can pass Bob's local DNS check and then resolve to metadata/private space from the proxy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟡 medium

[agy 🟡 medium] security — IP-pin bypassed under egress proxy: roamed host can be DNS-rebound at the proxy's resolution layer

requestOnce's lookup override (the IP pin) is only honored when Node establishes the socket directly. Custom HTTP/SOCKS proxy agents create their own sockets and tunnels and do NOT invoke requestOptions.lookup — they resolve the hostname themselves or hand it to the proxy to resolve. When an egress proxy agent is passed and blockInternalHosts is forced for a roamed request, resolveSafeAddress verifies the host is public and sets the lookup, but the proxy ignores it. So the pin is completely bypassed when an egress proxy is in use: a roamed hostname can be rebound to a private IP at the proxy resolution level, allowing unauthorized internal access. The new hopBlockInternal forcing here gives a false sense of safety for the proxy path.

[Claude 🟡 medium] security — Roam internal-block degrades from a pin to a point-in-time check under an egress proxy; http-scan path does not fail closed

The "roam can't reach internal, even via DNS rebinding" promise rests on pinning the resolved IP via requestOnce's lookup override (safe-fetch.js:212-219). For a roamed hop hopBlockInternal forces resolution, so selectedAddress is computed and lookup is set — BUT when options.agent is an egress proxy, the proxy does its own DNS for the host and ignores that lookup. The local resolveSafeAddress then degrades from a pin to a mere point-in-time check, which a rebinding attacker defeats (public at check time, internal at the proxy's connect time). The codebase already fails CLOSED for this elsewhere (browser-driver authed_fetch and offensive-confirmer both throw under proxy+blockInternalHosts), but http-scan→safeFetch for a roamed request proceeds on the weakened check. The doc claim "pins the resolved IP for the connection, so DNS rebinding can't smuggle one through" is false for the proxy case. Fix: a roamed hop (enforce_internal_block) must fail closed when options.agent is a proxy, mirroring confirmer/authed_fetch — or restrict roam to direct egress like the lab path does.

const response = await requestOnce(currentUrl, {
...options,
blockInternalHosts,
headers: currentHeaders,
blockInternalHosts: hopBlockInternal,
method: currentMethod,
body: currentBody,
Comment on lines +371 to 374

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Strip credentials before the initial roamed request

For an initial URL that is allowed only because of operator_armed_roam, safeFetch still sends the caller's original headers and body on the first hop; the new stripping logic only runs after a redirect is processed. Shared callers such as the IDOR producer pass auth-profile headers directly into safeFetch (mcp/lib/offensive-idor-producer.js:1035-1038), so with roam armed a cross-host candidate can receive the target's Authorization/Cookie/custom token headers before any redirect logic has a chance to sanitize them.

Useful? React with 👍 / 👎.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[Codex 🟡 medium] security — safeFetch returns only response.scopeReason, so the final-hop roam audit is materially wrong on first-party→roamed redirects

safeFetch returns only response.scopeReason back to the caller, and httpScan builds auditScopeDecision by copying the initial decision and replacing only reason. The suffix fields, registrable_domain, and audit base host remain from the initial URL. Impact: a request from target.com redirected to idp.example.org audits scope_reason:operator_armed_roam but still carries target-domain suffix metadata, so the "every roamed host stays visible in the audit" guarantee is unreliable unless someone manually parses final_url. Carry the full final-hop scope decision (including roamed suffix info) out of safeFetch, not just the reason string.


if (!followRedirects || !isRedirectStatus(response.status)) {
return response;
Expand All @@ -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;
Expand All @@ -383,4 +433,5 @@ module.exports = {
isRedirectStatus,
normalizeRedirectMethod,
safeFetch,
stripCredentialHeaders,
};
77 changes: 76 additions & 1 deletion mcp/lib/scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,39 @@ let publicSuffixOverlayCache = {
overlay: null,
};

// OPERATOR-ARMED CROSS-HOST ROAM (default OFF). When the operator sets
// BOB_HTTP_ROAM_AUTHORIZED=<target_domain> (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];
Comment thread
vmihalis marked this conversation as resolved.
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`);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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=<target_domain>
// 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)) {
Comment thread
vmihalis marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[glm (Claude) 🟡 medium] design — Roam is target-bound for arming but host-UNBOUNDED for use — an uncredentialed arbitrary-public-egress primitive

BOB_LAB_TARGET binds a grant to exact named hosts. BOB_HTTP_ROAM_AUTHORIZED binds only the target identity; once armed, validateHttpScanScope permits ANY registrable public host, and the agent — including a prompt-injected one — chooses which public hosts to roam to. Credentials are correctly stripped to roamed hosts (so it is not a cred-leak), but it remains an uncredentialed arbitrary-public-egress / data-exfil primitive the agent controls for the session: phone-home to attacker-controlled public hosts, probe third-party APIs from Bob's egress, etc. The asymmetry with the exact-host lab grant is worth flagging: this is a far wider blast radius than 'follow an engagement off the apex' implies. If the intent is engagement-specific hosts, an allowlist (like BOB_LAB_TARGET's exact-host binding) would match the lab feature's posture; as shipped, roam is 'the whole public internet for this session, minus internal.'

// 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟡 medium

[glm (Claude) 🟡 medium] security — Roam authorizes ANY public host on the internet, not engagement-related hosts — no allowlist, blast radius >> docs

The roam gate runs assertHttpScopeDomain(host, {ignoreLabAttestation:true}) and, if the host is any registrable public domain, returns allowed. There is NO relationship check between the roamed host and the target — not a suffix, not an allowlist, not an operator-supplied host set. BOB_HTTP_ROAM_AUTHORIZED=target.com is target-bound (it only arms sessions whose target_domain===target.com), but once armed it disables host confinement for that session ENTIRELY: bob_http_scan/safeFetch can hit example.com, a competitor's infrastructure, a third-party victim — anything public. The docs frame this as "follow an OAuth IdP / CDN / sibling app," implying a bounded set; the implementation is "the whole public internet minus RFC1918/metadata." For a scope kernel whose entire purpose is confinement this is a much larger relaxation than an operator reading the docs would infer. Fix: narrow to an explicit operator host-allowlist (e.g. BOB_HTTP_ROAM_HOSTS=idp.example,cdn.example), or at minimum state plainly in the docs that arming roam authorizes requests to ANY public host.

[Codex 🟡 medium] design — Roam bolted into the global scope primitive — every assertSafeRequestUrl caller silently inherited roam semantics

validateHttpScanScope is now both the "request may be sent" authority AND the "cookie may be installed / surface endpoint may be used" authority. Callers use assertSafeRequestUrl for different security questions but all now inherit operator_armed_roam: I verified resolveSurfaceEndpoint (offensive-http-common.js:563/587/606), offensive-runner, oob-collector, reflect/xss-exec/idor producers all call assertSafeRequestUrl with SCOPE_VALIDATION_OPTS. Adding a request-scope exception silently widened cookie scope, offensive producer endpoint scope, browser start scope, and navigate scope. This is the root cause of the cookie-rebind leak. One load-bearing scope function is doing too many jobs; the "request roam" vs "credential/scope ownership" distinction is not encoded in the API, so more leaks will appear as new tools reuse the helper.

roamedHostIsPublic = true;
Comment thread
vmihalis marked this conversation as resolved.
} catch {
roamedHostIsPublic = false;
}
if (roamedHostIsPublic) {
const roamedSuffixInfo = publicSuffixInfoForHost(host);
return {
allowed: true,
scope_decision: "allowed",
reason: "operator_armed_roam",
enforce_internal_block: true,
Comment on lines +348 to +349

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require resolving transports for roamed hosts

When BOB_HTTP_ROAM_AUTHORIZED is armed, this branch turns an off-target host into an allowed result and only annotates it with enforce_internal_block; that flag is honored by safeFetch/assertSafeResolvedRequestUrl, but not by callers that only use assertSafeRequestUrl. I checked mcp/lib/offensive-runner.js:527, which scope-checks URL flags this way and then starts the Dockerized tool, so a roamed attacker-controlled public hostname can be resolved later inside the tool to an internal/metadata address and bypass the boundary that this feature claims remains enforced.

Useful? React with 👍 / 👎.

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}`,
Expand Down Expand Up @@ -350,11 +423,13 @@ function filterExclusionsByHosts(entries, hosts, cap = 100) {
}

module.exports = {
ROAM_AUTHORIZED_ENV,
assertHttpScopeDomain,
filterExclusionsByHosts,
normalizeScopeExclusionToken,
publicSuffixInfoForHost,
readScopeExclusions,
resolveHttpScanTargetDomain,
roamAuthorizedForTarget,
validateHttpScanScope,
};
1 change: 1 addition & 0 deletions test/mcp-test-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading