From 9adf05570a1218cafc90b72f7b78c56cca417459 Mon Sep 17 00:00:00 2001 From: Lewis Marshall Date: Thu, 3 Sep 2026 14:26:36 +0000 Subject: [PATCH 1/3] fix: allowlist credential URLs and harden fetch/error paths Validate gateway/backend hosts before sending tokens, set redirect:error on both fetch sites, and redact secrets in error output more thoroughly. --- README.md | 9 ++++ src/cli.js | 3 +- src/gateway.js | 24 ++++++--- src/url-policy.js | 113 ++++++++++++++++++++++++++++++++++++++++ test/url-policy.test.js | 81 ++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 src/url-policy.js create mode 100644 test/url-policy.test.js diff --git a/README.md b/README.md index e62f31c..039924e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,15 @@ gbot bots delete Writer Run `gbot --help` for every command. +## Gateway URL policy + +By default `gbot` only sends credentials to `https` URLs on `*.cursor.sh` / `*.cursor.com`. + +- `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` — permit `http(s)://127.0.0.1`, `localhost`, and `::1` (local/dev gateways). +- `GROK_BOT_ALLOW_ANY_GATEWAY=1` — disable host checks (unsafe; for break-glass only). + +All gateway / `EnsureSandBox` fetches use `redirect: "error"` so credentials are not followed across redirects. + ## License MIT diff --git a/src/cli.js b/src/cli.js index 58fe5be..b396bb0 100755 --- a/src/cli.js +++ b/src/cli.js @@ -3,6 +3,7 @@ import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS, StoreError, defaultCan import { hasGatewayAuth } from "./gateway.js"; import { openBackend } from "./commands.js"; import { inspectGrokBotGatewaySession } from "./app-session.js"; +import { redactSecrets } from "./url-policy.js"; function print(value) { if (typeof value === "string") process.stdout.write(value + "\n"); @@ -11,7 +12,7 @@ function print(value) { function fail(err) { let message = err instanceof Error ? err.message : String(err); - message = message.replace(/Bearer\s+[A-Za-z0-9._\-]+/g, "Bearer "); + message = redactSecrets(message); process.stderr.write(message + "\n"); process.exit(1); } diff --git a/src/gateway.js b/src/gateway.js index bd0eae4..67fb597 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { ensureSandboxHeaders, headersFromEnsureSandbox, headersFromEnv, mergeGatewayHeaders, normalizeHeaderMap, requestHeaders } from "./headers.js"; import { hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-session.js"; import { AVATAR_COLORS, AVATAR_SHAPES } from "./store.js"; +import { assertAllowedCredentialUrl, redactSecrets } from "./url-policy.js"; export class GatewayError extends Error { constructor(message, { status, method } = {}) { @@ -45,7 +46,13 @@ function gatewayOverride() { ? "http://127.0.0.1:" + (process.env.SAND_HOST_PORT || "1340") : ""; const url = explicitUrl || localUrl; - if (url && token) return { gatewayUrl: url.replace(/\/$/, ""), gatewayToken: token, gatewayHeaders: headersFromEnv() }; + if (url && token) { + return { + gatewayUrl: assertAllowedCredentialUrl(url.replace(/\/$/, ""), { kind: "gateway" }), + gatewayToken: token, + gatewayHeaders: headersFromEnv(), + }; + } return null; } @@ -53,7 +60,7 @@ function sessionFromApp() { const loaded = loadGrokBotGatewaySession(); if (!loaded) return null; return { - gatewayUrl: loaded.gatewayUrl, + gatewayUrl: assertAllowedCredentialUrl(loaded.gatewayUrl, { kind: "gateway" }), gatewayToken: loaded.gatewayToken, gatewayHeaders: mergeGatewayHeaders(normalizeHeaderMap(loaded.headers), headersFromEnv()), }; @@ -82,23 +89,24 @@ function pick(obj, ...keys) { } export async function ensureSandbox(accessToken) { - const url = backendBase() + "/aiserver.v1.GrokBotService/EnsureSandBox"; + const url = assertAllowedCredentialUrl(backendBase(), { kind: "backend" }) + "/aiserver.v1.GrokBotService/EnsureSandBox"; const res = await fetch(url, { method: "POST", + redirect: "error", headers: ensureSandboxHeaders(accessToken), body: "{}", }); const body = await readJson(res); if (!res.ok) { const detail = body.message || body.error || body.raw || res.statusText; - throw new GatewayError("EnsureSandBox failed: " + res.status + " " + detail, { status: res.status, method: "EnsureSandBox" }); + throw new GatewayError("EnsureSandBox failed: " + res.status + " " + redactSecrets(detail), { status: res.status, method: "EnsureSandBox" }); } const gatewayUrl = pick(body, "gatewayUrl", "gateway_url"); const gatewayToken = pick(body, "gatewayToken", "gateway_token"); if (!gatewayUrl || !gatewayToken) { throw new GatewayError("EnsureSandBox returned no gatewayUrl/gatewayToken. Auth may be a dashboard API key (those do not work)."); } - return { gatewayUrl: String(gatewayUrl).replace(/\/$/, ""), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) }; + return { gatewayUrl: assertAllowedCredentialUrl(String(gatewayUrl).replace(/\/$/, ""), { kind: "gateway" }), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) }; } export async function connectGateway() { @@ -114,16 +122,18 @@ export async function connectGateway() { } export async function gatewayCall(session, method, body = {}) { - const url = session.gatewayUrl + "/api/" + method; + const base = assertAllowedCredentialUrl(session.gatewayUrl, { kind: "gateway" }); + const url = base + "/api/" + method; const res = await fetch(url, { method: "POST", + redirect: "error", headers: requestHeaders(session), body: JSON.stringify(body), }); const data = await readJson(res); if (!res.ok) { const detail = data.message || data.error || data.raw || res.statusText; - throw new GatewayError(method + " failed: " + res.status + " " + String(detail).slice(0, 300), { status: res.status, method }); + throw new GatewayError(method + " failed: " + res.status + " " + redactSecrets(String(detail).slice(0, 300)), { status: res.status, method }); } return data; } diff --git a/src/url-policy.js b/src/url-policy.js new file mode 100644 index 0000000..d27b0ea --- /dev/null +++ b/src/url-policy.js @@ -0,0 +1,113 @@ +/** + * Gateway / backend URL policy: only send credentials to expected hosts. + * + * Default: https + *.cursor.sh / *.cursor.com (and apex). + * Local/dev: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1. + * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks). + */ + +function truthyEnv(name) { + const v = (process.env[name] || "").trim().toLowerCase(); + return v === "1" || v === "true" || v === "yes"; +} + +export function allowAnyGateway() { + return truthyEnv("GROK_BOT_ALLOW_ANY_GATEWAY"); +} + +export function allowLocalGateway() { + return truthyEnv("GROK_BOT_ALLOW_LOCAL_GATEWAY"); +} + +function isLocalHostname(hostname) { + const h = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, ""); + return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0"; +} + +function isCursorHostname(hostname) { + const h = String(hostname || "").toLowerCase(); + if (!h) return false; + if (h === "cursor.sh" || h === "cursor.com") return true; + return h.endsWith(".cursor.sh") || h.endsWith(".cursor.com"); +} + +/** + * @param {string} rawUrl + * @param {{ kind?: "gateway" | "backend" }} [opts] + * @returns {string} normalized URL without trailing slash + */ +export function assertAllowedCredentialUrl(rawUrl, opts = {}) { + const kind = opts.kind || "gateway"; + const label = kind === "backend" ? "backend URL" : "gateway URL"; + let parsed; + try { + parsed = new URL(String(rawUrl)); + } catch { + throw new Error("Invalid " + label + "."); + } + + if (parsed.username || parsed.password) { + throw new Error("Rejected " + label + ": userinfo is not allowed."); + } + + const normalized = parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "")) + parsed.search; + + if (allowAnyGateway()) { + return String(rawUrl).replace(/\/$/, ""); + } + + const host = parsed.hostname; + const local = isLocalHostname(host); + + if (local) { + if (!allowLocalGateway()) { + throw new Error( + "Rejected " + + label + + " host \"" + + host + + "\". Set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 to permit localhost/127.0.0.1 gateways.", + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Rejected " + label + ": local gateways must use http or https."); + } + return String(rawUrl).replace(/\/$/, ""); + } + + if (parsed.protocol !== "https:") { + throw new Error("Rejected " + label + ": only https is allowed (got " + parsed.protocol + ")."); + } + + if (!isCursorHostname(host)) { + throw new Error( + "Rejected " + + label + + " host \"" + + host + + "\". Expected *.cursor.sh / *.cursor.com, or set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 / GROK_BOT_ALLOW_ANY_GATEWAY=1.", + ); + } + + // Prefer origin-only gateways; allow path if present but strip trailing slash consistently. + void normalized; + return String(rawUrl).replace(/\/$/, ""); +} + +/** + * Redact common credential shapes from error / log strings. + * Broader than a Bearer-only regex; avoids dumping tokens in stderr. + */ +export function redactSecrets(text) { + let s = String(text); + s = s.replace(/Bearer\s+[A-Za-z0-9._+\/=-]+/gi, "Bearer "); + s = s.replace( + /(["']?(?:authorization|gatewayToken|gateway_token|access_token|accessToken|refresh_token|refreshToken|token|x-anyrun-network-token)["']?\s*[:=]\s*["']?)([^"',\s}]+)/gi, + "$1", + ); + s = s.replace( + /(x-anyrun-network-token\s*[=:]\s*)(\S+)/gi, + "$1", + ); + return s; +} diff --git a/test/url-policy.test.js b/test/url-policy.test.js new file mode 100644 index 0000000..ce05192 --- /dev/null +++ b/test/url-policy.test.js @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assertAllowedCredentialUrl, + redactSecrets, +} from "../src/url-policy.js"; + +function withEnv(values, fn) { + const prev = {}; + for (const key of Object.keys(values)) { + prev[key] = process.env[key]; + const v = values[key]; + if (v == null) delete process.env[key]; + else process.env[key] = v; + } + try { + return fn(); + } finally { + for (const key of Object.keys(values)) { + if (prev[key] === undefined) delete process.env[key]; + else process.env[key] = prev[key]; + } + } +} + +test("allows https cursor.sh gateway hosts", () => { + withEnv({ GROK_BOT_ALLOW_LOCAL_GATEWAY: null, GROK_BOT_ALLOW_ANY_GATEWAY: null }, () => { + assert.equal( + assertAllowedCredentialUrl("https://api2.cursor.sh"), + "https://api2.cursor.sh", + ); + assert.equal( + assertAllowedCredentialUrl("https://box-abc.cursor.sh/"), + "https://box-abc.cursor.sh", + ); + assert.equal( + assertAllowedCredentialUrl("https://agent.cursor.com"), + "https://agent.cursor.com", + ); + }); +}); + +test("rejects http and non-cursor hosts by default", () => { + withEnv({ GROK_BOT_ALLOW_LOCAL_GATEWAY: null, GROK_BOT_ALLOW_ANY_GATEWAY: null }, () => { + assert.throws(() => assertAllowedCredentialUrl("http://api2.cursor.sh"), /only https/i); + assert.throws(() => assertAllowedCredentialUrl("https://evil.example"), /Rejected gateway URL host/i); + assert.throws(() => assertAllowedCredentialUrl("https://127.0.0.1:1340"), /GROK_BOT_ALLOW_LOCAL_GATEWAY/i); + }); +}); + +test("allows local gateways when opted in", () => { + withEnv({ GROK_BOT_ALLOW_LOCAL_GATEWAY: "1", GROK_BOT_ALLOW_ANY_GATEWAY: null }, () => { + assert.equal( + assertAllowedCredentialUrl("http://127.0.0.1:1340"), + "http://127.0.0.1:1340", + ); + assert.equal( + assertAllowedCredentialUrl("http://localhost:1340/"), + "http://localhost:1340", + ); + }); +}); + +test("ALLOW_ANY_GATEWAY bypasses host checks", () => { + withEnv({ GROK_BOT_ALLOW_ANY_GATEWAY: "true", GROK_BOT_ALLOW_LOCAL_GATEWAY: null }, () => { + assert.equal( + assertAllowedCredentialUrl("https://evil.example/path"), + "https://evil.example/path", + ); + }); +}); + +test("redacts bearer and token-like fields", () => { + const out = redactSecrets( + 'EnsureSandBox failed: 401 {"gatewayToken":"supersecret","x-anyrun-network-token":"route"} Bearer abc.def-ghi+=', + ); + assert.match(out, /Bearer /); + assert.match(out, /gatewayToken":"/); + assert.doesNotMatch(out, /supersecret/); + assert.doesNotMatch(out, /abc\.def-ghi/); +}); From 2e62663bf2a915d7174d64bf9edde09c3aeb5bc2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 00:36:53 +0000 Subject: [PATCH 2/3] fix: allow the cursorvm.com box gateway family and add a changeset --- .changeset/gateway-url-allowlist.md | 5 +++++ README.md | 2 +- src/url-policy.js | 8 +++++--- test/url-policy.test.js | 6 ++++++ 4 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .changeset/gateway-url-allowlist.md diff --git a/.changeset/gateway-url-allowlist.md b/.changeset/gateway-url-allowlist.md new file mode 100644 index 0000000..1e267c7 --- /dev/null +++ b/.changeset/gateway-url-allowlist.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Send the gateway and EnsureSandBox bearer tokens only to `https` hosts on `*.cursor.sh`, `*.cursor.com`, or `*.cursorvm.com` (the box gateway family EnsureSandBox returns); refuse cross-origin fetch redirects; redact credential-shaped values from error output. `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` admits loopback gateways and `GROK_BOT_ALLOW_ANY_GATEWAY=1` disables the host check. diff --git a/README.md b/README.md index 039924e..a163b75 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Run `gbot --help` for every command. ## Gateway URL policy -By default `gbot` only sends credentials to `https` URLs on `*.cursor.sh` / `*.cursor.com`. +By default `gbot` only sends credentials to `https` URLs on `*.cursor.sh` / `*.cursor.com` / `*.cursorvm.com` (the box gateway family EnsureSandBox returns). - `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` — permit `http(s)://127.0.0.1`, `localhost`, and `::1` (local/dev gateways). - `GROK_BOT_ALLOW_ANY_GATEWAY=1` — disable host checks (unsafe; for break-glass only). diff --git a/src/url-policy.js b/src/url-policy.js index d27b0ea..e807c5f 100644 --- a/src/url-policy.js +++ b/src/url-policy.js @@ -1,7 +1,9 @@ /** * Gateway / backend URL policy: only send credentials to expected hosts. * - * Default: https + *.cursor.sh / *.cursor.com (and apex). + * Default: https + *.cursor.sh / *.cursor.com (and apex) / *.cursorvm.com, the + * host family EnsureSandBox hands out for the box gateway (e.g. + * -pod-.us12.cursorvm.com). * Local/dev: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1. * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks). */ @@ -28,7 +30,7 @@ function isCursorHostname(hostname) { const h = String(hostname || "").toLowerCase(); if (!h) return false; if (h === "cursor.sh" || h === "cursor.com") return true; - return h.endsWith(".cursor.sh") || h.endsWith(".cursor.com"); + return h.endsWith(".cursor.sh") || h.endsWith(".cursor.com") || h.endsWith(".cursorvm.com"); } /** @@ -85,7 +87,7 @@ export function assertAllowedCredentialUrl(rawUrl, opts = {}) { label + " host \"" + host + - "\". Expected *.cursor.sh / *.cursor.com, or set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 / GROK_BOT_ALLOW_ANY_GATEWAY=1.", + "\". Expected *.cursor.sh / *.cursor.com / *.cursorvm.com, or set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 / GROK_BOT_ALLOW_ANY_GATEWAY=1.", ); } diff --git a/test/url-policy.test.js b/test/url-policy.test.js index ce05192..e144d07 100644 --- a/test/url-policy.test.js +++ b/test/url-policy.test.js @@ -37,6 +37,12 @@ test("allows https cursor.sh gateway hosts", () => { assertAllowedCredentialUrl("https://agent.cursor.com"), "https://agent.cursor.com", ); + // The box gateway EnsureSandBox hands out lives on this family. + assert.equal( + assertAllowedCredentialUrl("https://145d7c6d03c7434e75f0-pod-abc-1340.us12.cursorvm.com/"), + "https://145d7c6d03c7434e75f0-pod-abc-1340.us12.cursorvm.com", + ); + assert.throws(() => assertAllowedCredentialUrl("https://cursorvm.com.evil.example"), /Rejected gateway URL host/i); }); }); From f70709a255a6d6c61f7687ad499e358e49d56f45 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 00:47:41 +0000 Subject: [PATCH 3/3] fix: split backend/gateway allowlists and harden redaction Backend EnsureSandBox URLs admit only *.cursor.sh / *.cursor.com so a Cursor access token cannot be pointed at *.cursorvm.com. Gateway policy keeps the box family. Redact Cookie/Set-Cookie and full Authorization values; drop 0.0.0.0 from local hosts; warn once for break-glass env. --- .changeset/gateway-url-allowlist.md | 2 +- README.md | 9 ++-- src/url-policy.js | 84 ++++++++++++++++++++++------- test/url-policy.test.js | 34 +++++++++++- 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/.changeset/gateway-url-allowlist.md b/.changeset/gateway-url-allowlist.md index 1e267c7..8d346da 100644 --- a/.changeset/gateway-url-allowlist.md +++ b/.changeset/gateway-url-allowlist.md @@ -2,4 +2,4 @@ "grok-bot-cli": patch --- -Send the gateway and EnsureSandBox bearer tokens only to `https` hosts on `*.cursor.sh`, `*.cursor.com`, or `*.cursorvm.com` (the box gateway family EnsureSandBox returns); refuse cross-origin fetch redirects; redact credential-shaped values from error output. `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` admits loopback gateways and `GROK_BOT_ALLOW_ANY_GATEWAY=1` disables the host check. +Send the gateway bearer only to `https` hosts on `*.cursor.sh`, `*.cursor.com`, or `*.cursorvm.com`, and the EnsureSandBox / Cursor access token only to `*.cursor.sh` / `*.cursor.com`; refuse cross-origin fetch redirects; redact Authorization (any scheme), Cookie, and named token fields from error output. `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` admits loopback gateways and `GROK_BOT_ALLOW_ANY_GATEWAY=1` disables the host check; both warn once on stderr. diff --git a/README.md b/README.md index a163b75..501295b 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,13 @@ Run `gbot --help` for every command. ## Gateway URL policy -By default `gbot` only sends credentials to `https` URLs on `*.cursor.sh` / `*.cursor.com` / `*.cursorvm.com` (the box gateway family EnsureSandBox returns). +By default `gbot` only sends credentials to expected hosts: -- `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` — permit `http(s)://127.0.0.1`, `localhost`, and `::1` (local/dev gateways). -- `GROK_BOT_ALLOW_ANY_GATEWAY=1` — disable host checks (unsafe; for break-glass only). +- **Gateway** URLs (box + API): `https` on `*.cursor.sh` / `*.cursor.com` / `*.cursorvm.com` (the box family EnsureSandBox returns). +- **Backend** URLs (`EnsureSandBox` / `CURSOR_API_BASE_URL`): `https` on `*.cursor.sh` / `*.cursor.com` only — never `*.cursorvm.com`, so a Cursor access token cannot be pointed at a box host. + +- `GROK_BOT_ALLOW_LOCAL_GATEWAY=1` — permit `http(s)://127.0.0.1`, `localhost`, and `::1` for **gateways** only (local/dev). Prints a one-shot stderr warning. +- `GROK_BOT_ALLOW_ANY_GATEWAY=1` — disable host checks (unsafe; for break-glass only). Prints a one-shot stderr warning. All gateway / `EnsureSandBox` fetches use `redirect: "error"` so credentials are not followed across redirects. diff --git a/src/url-policy.js b/src/url-policy.js index e807c5f..977c98c 100644 --- a/src/url-policy.js +++ b/src/url-policy.js @@ -1,11 +1,12 @@ /** * Gateway / backend URL policy: only send credentials to expected hosts. * - * Default: https + *.cursor.sh / *.cursor.com (and apex) / *.cursorvm.com, the - * host family EnsureSandBox hands out for the box gateway (e.g. - * -pod-.us12.cursorvm.com). - * Local/dev: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1. - * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks). + * Gateway (box + API): https on *.cursor.sh / *.cursor.com (and apex) / + * *.cursorvm.com (EnsureSandBox box hosts, e.g. -pod-.us12.cursorvm.com). + * Backend (EnsureSandBox / Cursor API): https on *.cursor.sh / *.cursor.com only — + * never *.cursorvm.com, so a CURSOR_ACCESS_TOKEN cannot be pointed at a box host. + * Local/dev gateways: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1. + * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks; warns once). */ function truthyEnv(name) { @@ -21,16 +22,34 @@ export function allowLocalGateway() { return truthyEnv("GROK_BOT_ALLOW_LOCAL_GATEWAY"); } +const warned = new Set(); + +function warnOnce(key, message) { + if (warned.has(key)) return; + warned.add(key); + process.stderr.write(message + "\n"); +} + +/** Test hook: clear the one-shot warn set. */ +export function resetPolicyWarnings() { + warned.clear(); +} + function isLocalHostname(hostname) { const h = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, ""); - return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0"; + return h === "localhost" || h === "127.0.0.1" || h === "::1"; } -function isCursorHostname(hostname) { +function isCursorApiHostname(hostname) { const h = String(hostname || "").toLowerCase(); if (!h) return false; if (h === "cursor.sh" || h === "cursor.com") return true; - return h.endsWith(".cursor.sh") || h.endsWith(".cursor.com") || h.endsWith(".cursorvm.com"); + return h.endsWith(".cursor.sh") || h.endsWith(".cursor.com"); +} + +function isCursorGatewayHostname(hostname) { + const h = String(hostname || "").toLowerCase(); + return isCursorApiHostname(h) || h.endsWith(".cursorvm.com"); } /** @@ -52,9 +71,11 @@ export function assertAllowedCredentialUrl(rawUrl, opts = {}) { throw new Error("Rejected " + label + ": userinfo is not allowed."); } - const normalized = parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "")) + parsed.search; - if (allowAnyGateway()) { + warnOnce( + "ALLOW_ANY", + "warning: GROK_BOT_ALLOW_ANY_GATEWAY is set; credential host checks are disabled.", + ); return String(rawUrl).replace(/\/$/, ""); } @@ -62,6 +83,13 @@ export function assertAllowedCredentialUrl(rawUrl, opts = {}) { const local = isLocalHostname(host); if (local) { + if (kind === "backend") { + throw new Error( + "Rejected backend URL host \"" + + host + + "\". EnsureSandBox backends must be https on *.cursor.sh / *.cursor.com.", + ); + } if (!allowLocalGateway()) { throw new Error( "Rejected " + @@ -74,6 +102,10 @@ export function assertAllowedCredentialUrl(rawUrl, opts = {}) { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("Rejected " + label + ": local gateways must use http or https."); } + warnOnce( + "ALLOW_LOCAL", + "warning: GROK_BOT_ALLOW_LOCAL_GATEWAY is set; credentials may be sent to a loopback gateway.", + ); return String(rawUrl).replace(/\/$/, ""); } @@ -81,35 +113,49 @@ export function assertAllowedCredentialUrl(rawUrl, opts = {}) { throw new Error("Rejected " + label + ": only https is allowed (got " + parsed.protocol + ")."); } - if (!isCursorHostname(host)) { + const allowed = + kind === "backend" ? isCursorApiHostname(host) : isCursorGatewayHostname(host); + if (!allowed) { + const expected = + kind === "backend" + ? "*.cursor.sh / *.cursor.com" + : "*.cursor.sh / *.cursor.com / *.cursorvm.com"; throw new Error( "Rejected " + label + " host \"" + host + - "\". Expected *.cursor.sh / *.cursor.com / *.cursorvm.com, or set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 / GROK_BOT_ALLOW_ANY_GATEWAY=1.", + "\". Expected " + + expected + + ", or set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 / GROK_BOT_ALLOW_ANY_GATEWAY=1.", ); } - // Prefer origin-only gateways; allow path if present but strip trailing slash consistently. - void normalized; return String(rawUrl).replace(/\/$/, ""); } /** * Redact common credential shapes from error / log strings. - * Broader than a Bearer-only regex; avoids dumping tokens in stderr. + * Covers Bearer/Basic/scheme Authorization values, cookie headers, and named token fields. */ export function redactSecrets(text) { let s = String(text); - s = s.replace(/Bearer\s+[A-Za-z0-9._+\/=-]+/gi, "Bearer "); + // Cookie headers first so a later Authorization pass cannot swallow them. + // Stop the value before the next header-shaped token on the same line. s = s.replace( - /(["']?(?:authorization|gatewayToken|gateway_token|access_token|accessToken|refresh_token|refreshToken|token|x-anyrun-network-token)["']?\s*[:=]\s*["']?)([^"',\s}]+)/gi, - "$1", + /(^|[\s,{;])((?:set-cookie|cookie)\s*[:=]\s*)([^\n;]+?)(?=\s+(?:set-cookie|cookie|authorization|proxy-authorization)\b|\s*$)/gi, + "$1$2", ); + // Scheme + credential only (e.g. "Basic abc", "Bearer xyz") — not the rest of the line. + s = s.replace( + /(^|[\s,{;])((?:authorization|proxy-authorization)\s*[:=]\s*)(\S+(?:\s+\S+)?)/gi, + "$1$2", + ); + s = s.replace(/Bearer\s+[A-Za-z0-9._+\/=-]+/gi, "Bearer "); s = s.replace( - /(x-anyrun-network-token\s*[=:]\s*)(\S+)/gi, + /(["']?(?:authorization|gatewayToken|gateway_token|access_token|accessToken|refresh_token|refreshToken|token|x-anyrun-network-token|cookie|set-cookie)["']?\s*[:=]\s*["']?)([^"',\s}]+)/gi, "$1", ); + s = s.replace(/(x-anyrun-network-token\s*[=:]\s*)(\S+)/gi, "$1"); return s; } diff --git a/test/url-policy.test.js b/test/url-policy.test.js index e144d07..1185d8b 100644 --- a/test/url-policy.test.js +++ b/test/url-policy.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { assertAllowedCredentialUrl, redactSecrets, + resetPolicyWarnings, } from "../src/url-policy.js"; function withEnv(values, fn) { @@ -46,15 +47,37 @@ test("allows https cursor.sh gateway hosts", () => { }); }); +test("backend URLs reject cursorvm and loopback", () => { + withEnv({ GROK_BOT_ALLOW_LOCAL_GATEWAY: "1", GROK_BOT_ALLOW_ANY_GATEWAY: null }, () => { + assert.equal( + assertAllowedCredentialUrl("https://api2.cursor.sh", { kind: "backend" }), + "https://api2.cursor.sh", + ); + assert.throws( + () => + assertAllowedCredentialUrl("https://145d7c6d03c7434e75f0-pod-abc-1340.us12.cursorvm.com", { + kind: "backend", + }), + /Rejected backend URL host/i, + ); + assert.throws( + () => assertAllowedCredentialUrl("http://127.0.0.1:1340", { kind: "backend" }), + /EnsureSandBox backends must be https/i, + ); + }); +}); + test("rejects http and non-cursor hosts by default", () => { withEnv({ GROK_BOT_ALLOW_LOCAL_GATEWAY: null, GROK_BOT_ALLOW_ANY_GATEWAY: null }, () => { assert.throws(() => assertAllowedCredentialUrl("http://api2.cursor.sh"), /only https/i); assert.throws(() => assertAllowedCredentialUrl("https://evil.example"), /Rejected gateway URL host/i); assert.throws(() => assertAllowedCredentialUrl("https://127.0.0.1:1340"), /GROK_BOT_ALLOW_LOCAL_GATEWAY/i); + assert.throws(() => assertAllowedCredentialUrl("https://0.0.0.0:1340"), /Rejected gateway URL host/i); }); }); test("allows local gateways when opted in", () => { + resetPolicyWarnings(); withEnv({ GROK_BOT_ALLOW_LOCAL_GATEWAY: "1", GROK_BOT_ALLOW_ANY_GATEWAY: null }, () => { assert.equal( assertAllowedCredentialUrl("http://127.0.0.1:1340"), @@ -68,6 +91,7 @@ test("allows local gateways when opted in", () => { }); test("ALLOW_ANY_GATEWAY bypasses host checks", () => { + resetPolicyWarnings(); withEnv({ GROK_BOT_ALLOW_ANY_GATEWAY: "true", GROK_BOT_ALLOW_LOCAL_GATEWAY: null }, () => { assert.equal( assertAllowedCredentialUrl("https://evil.example/path"), @@ -76,12 +100,18 @@ test("ALLOW_ANY_GATEWAY bypasses host checks", () => { }); }); -test("redacts bearer and token-like fields", () => { +test("redacts bearer, basic, cookie, and token-like fields", () => { const out = redactSecrets( - 'EnsureSandBox failed: 401 {"gatewayToken":"supersecret","x-anyrun-network-token":"route"} Bearer abc.def-ghi+=', + 'EnsureSandBox failed: 401 {"gatewayToken":"supersecret","x-anyrun-network-token":"route"} ' + + "Bearer abc.def-ghi+= authorization: Basic YWJjMTIz Cookie: sid=keepsecret Set-Cookie: a=b", ); assert.match(out, /Bearer /); assert.match(out, /gatewayToken":"/); + assert.match(out, /authorization: /i); + assert.match(out, /Cookie: /i); + assert.match(out, /Set-Cookie: /i); assert.doesNotMatch(out, /supersecret/); assert.doesNotMatch(out, /abc\.def-ghi/); + assert.doesNotMatch(out, /YWJjMTIz/); + assert.doesNotMatch(out, /keepsecret/); });