diff --git a/README.md b/README.md index cb0df33..a6367da 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/); +});