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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
113 changes: 113 additions & 0 deletions src/url-policy.js
Original file line number Diff line number Diff line change
@@ -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 <redacted>");
s = s.replace(
/(["']?(?:authorization|gatewayToken|gateway_token|access_token|accessToken|refresh_token|refreshToken|token|x-anyrun-network-token)["']?\s*[:=]\s*["']?)([^"',\s}]+)/gi,
"$1<redacted>",
);
s = s.replace(
/(x-anyrun-network-token\s*[=:]\s*)(\S+)/gi,
"$1<redacted>",
);
return s;
}
81 changes: 81 additions & 0 deletions test/url-policy.test.js
Original file line number Diff line number Diff line change
@@ -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 <redacted>/);
assert.match(out, /gatewayToken":"<redacted>/);
assert.doesNotMatch(out, /supersecret/);
assert.doesNotMatch(out, /abc\.def-ghi/);
});