Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/gateway-url-allowlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

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.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ gbot bots delete Writer

Run `gbot --help` for every command.

## Gateway URL policy

By default `gbot` only sends credentials to expected hosts:

- **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.

## License

MIT
3 changes: 2 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 <redacted>");
message = redactSecrets(message);
process.stderr.write(message + "\n");
process.exit(1);
}
Expand Down
24 changes: 17 additions & 7 deletions src/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -45,15 +46,21 @@ 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;
}

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()),
};
Expand Down Expand Up @@ -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() {
Expand All @@ -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;
}
Expand Down
161 changes: 161 additions & 0 deletions src/url-policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Gateway / backend URL policy: only send credentials to expected hosts.
*
* Gateway (box + API): https on *.cursor.sh / *.cursor.com (and apex) /
* *.cursorvm.com (EnsureSandBox box hosts, e.g. <id>-pod-<id>.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) {
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");
}

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";
}

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

function isCursorGatewayHostname(hostname) {
const h = String(hostname || "").toLowerCase();
return isCursorApiHostname(h) || h.endsWith(".cursorvm.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.");
}

if (allowAnyGateway()) {
warnOnce(
"ALLOW_ANY",
"warning: GROK_BOT_ALLOW_ANY_GATEWAY is set; credential host checks are disabled.",
);
return String(rawUrl).replace(/\/$/, "");
}

const host = parsed.hostname;
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 " +
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.");
}
warnOnce(
"ALLOW_LOCAL",
"warning: GROK_BOT_ALLOW_LOCAL_GATEWAY is set; credentials may be sent to a loopback gateway.",
);
return String(rawUrl).replace(/\/$/, "");
}

if (parsed.protocol !== "https:") {
throw new Error("Rejected " + label + ": only https is allowed (got " + parsed.protocol + ").");
}

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 " +
expected +
", or set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 / GROK_BOT_ALLOW_ANY_GATEWAY=1.",
);
}

return String(rawUrl).replace(/\/$/, "");
}

/**
* Redact common credential shapes from error / log strings.
* Covers Bearer/Basic/scheme Authorization values, cookie headers, and named token fields.
*/
export function redactSecrets(text) {
let s = String(text);
// 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(
/(^|[\s,{;])((?:set-cookie|cookie)\s*[:=]\s*)([^\n;]+?)(?=\s+(?:set-cookie|cookie|authorization|proxy-authorization)\b|\s*$)/gi,
"$1$2<redacted>",
);
// 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<redacted>",
);
s = s.replace(/Bearer\s+[A-Za-z0-9._+\/=-]+/gi, "Bearer <redacted>");
s = s.replace(
/(["']?(?:authorization|gatewayToken|gateway_token|access_token|accessToken|refresh_token|refreshToken|token|x-anyrun-network-token|cookie|set-cookie)["']?\s*[:=]\s*["']?)([^"',\s}]+)/gi,
"$1<redacted>",
);
s = s.replace(/(x-anyrun-network-token\s*[=:]\s*)(\S+)/gi, "$1<redacted>");
return s;
}
Loading