-
Notifications
You must be signed in to change notification settings - Fork 15
feat(scope): operator-armed cross-host roam (BOB_HTTP_ROAM_AUTHORIZED, default-off) #176
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
27db587
c0c3562
39b36a0
f33fc70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||
| } | ||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||
| 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"); | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, [glm (Claude) 🔵 low] security — Direct roamed path uses weaker cred-stripping than the redirect path (caller headers pass through) http-scan.js blocks
Suggested change
|
||||||||||||||||
| 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 | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 [Claude 🔵 low] correctness — Audit row conflates two hosts on redirect-into-roam: scope_reason=operator_armed_roam alongside the TARGET's suffix fields
|
||||||||||||||||
| ? { ...(initialScopeDecision || {}), reason: scopeReason } | ||||||||||||||||
| : initialScopeDecision; | ||||||||||||||||
|
Comment on lines
+279
to
+281
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an in-target URL redirects to a roamed host, 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 | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }); | ||
|
Comment on lines
160
to
+161
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| 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; | ||
|
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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a roamed hop this sets Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For an initial URL that is allowed only because of 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]; | ||
|
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`); | ||
|
|
@@ -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=<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)) { | ||
|
vmihalis marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
| // 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 }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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}`, | ||
|
|
@@ -350,11 +423,13 @@ function filterExclusionsByHosts(entries, hosts, cap = 100) { | |
| } | ||
|
|
||
| module.exports = { | ||
| ROAM_AUTHORIZED_ENV, | ||
| assertHttpScopeDomain, | ||
| filterExclusionsByHosts, | ||
| normalizeScopeExclusionToken, | ||
| publicSuffixInfoForHost, | ||
| readScopeExclusions, | ||
| resolveHttpScanTargetDomain, | ||
| roamAuthorizedForTarget, | ||
| validateHttpScanScope, | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.