feat(gateway): deprecate query param hook token auth for security (#2200)

* feat(gateway): deprecate query param hook token auth for security

Query parameter tokens appear in:
- Server access logs
- Browser history
- Referrer headers
- Network monitoring tools

This change adds a deprecation warning when tokens are provided via
query parameter, encouraging migration to header-based authentication
(Authorization: Bearer <token> or X-Clawdbot-Token header).

Changes:
- Modified extractHookToken to return { token, fromQuery } object
- Added deprecation warning in server-http.ts when fromQuery is true
- Updated tests to verify the new return type and fromQuery flag

Fixes #2148

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: deprecate hook query token auth (#2200) (thanks @YuriNachos)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Yuri Chukhlib
2026-01-26 15:51:25 +01:00
committed by GitHub
parent f3e3c4573b
commit 961b4adc1c
5 changed files with 32 additions and 13 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
Status: unreleased. Status: unreleased.
### Changes ### Changes
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. - Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.

View File

@@ -27,10 +27,10 @@ Notes:
## Auth ## Auth
Every request must include the hook token: Every request must include the hook token. Prefer headers:
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>` (recommended)
- or `x-clawdbot-token: <token>` - `x-clawdbot-token: <token>`
- or `?token=<token>` - `?token=<token>` (deprecated; logs a warning and will be removed in a future major release)
## Endpoints ## Endpoints

View File

@@ -47,15 +47,21 @@ describe("gateway hooks helpers", () => {
}, },
} as unknown as IncomingMessage; } as unknown as IncomingMessage;
const url = new URL("http://localhost/hooks/wake?token=query"); const url = new URL("http://localhost/hooks/wake?token=query");
expect(extractHookToken(req, url)).toBe("top"); const result1 = extractHookToken(req, url);
expect(result1.token).toBe("top");
expect(result1.fromQuery).toBe(false);
const req2 = { const req2 = {
headers: { "x-clawdbot-token": "header" }, headers: { "x-clawdbot-token": "header" },
} as unknown as IncomingMessage; } as unknown as IncomingMessage;
expect(extractHookToken(req2, url)).toBe("header"); const result2 = extractHookToken(req2, url);
expect(result2.token).toBe("header");
expect(result2.fromQuery).toBe(false);
const req3 = { headers: {} } as unknown as IncomingMessage; const req3 = { headers: {} } as unknown as IncomingMessage;
expect(extractHookToken(req3, url)).toBe("query"); const result3 = extractHookToken(req3, url);
expect(result3.token).toBe("query");
expect(result3.fromQuery).toBe(true);
}); });
test("normalizeWakePayload trims + validates", () => { test("normalizeWakePayload trims + validates", () => {

View File

@@ -41,21 +41,26 @@ export function resolveHooksConfig(cfg: ClawdbotConfig): HooksConfigResolved | n
}; };
} }
export function extractHookToken(req: IncomingMessage, url: URL): string | undefined { export type HookTokenResult = {
token: string | undefined;
fromQuery: boolean;
};
export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResult {
const auth = const auth =
typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : ""; typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : "";
if (auth.toLowerCase().startsWith("bearer ")) { if (auth.toLowerCase().startsWith("bearer ")) {
const token = auth.slice(7).trim(); const token = auth.slice(7).trim();
if (token) return token; if (token) return { token, fromQuery: false };
} }
const headerToken = const headerToken =
typeof req.headers["x-clawdbot-token"] === "string" typeof req.headers["x-clawdbot-token"] === "string"
? req.headers["x-clawdbot-token"].trim() ? req.headers["x-clawdbot-token"].trim()
: ""; : "";
if (headerToken) return headerToken; if (headerToken) return { token: headerToken, fromQuery: false };
const queryToken = url.searchParams.get("token"); const queryToken = url.searchParams.get("token");
if (queryToken) return queryToken.trim(); if (queryToken) return { token: queryToken.trim(), fromQuery: true };
return undefined; return { token: undefined, fromQuery: false };
} }
export async function readJsonBody( export async function readJsonBody(

View File

@@ -76,13 +76,20 @@ export function createHooksRequestHandler(
return false; return false;
} }
const token = extractHookToken(req, url); const { token, fromQuery } = extractHookToken(req, url);
if (!token || token !== hooksConfig.token) { if (!token || token !== hooksConfig.token) {
res.statusCode = 401; res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized"); res.end("Unauthorized");
return true; return true;
} }
if (fromQuery) {
logHooks.warn(
"Hook token provided via query parameter is deprecated for security reasons. " +
"Tokens in URLs appear in logs, browser history, and referrer headers. " +
"Use Authorization: Bearer <token> or X-Clawdbot-Token header instead.",
);
}
if (req.method !== "POST") { if (req.method !== "POST") {
res.statusCode = 405; res.statusCode = 405;