fix: silence unused hook token url param (#9436)

* fix: Gateway authentication token exposed in URL query parameters

* fix: silence unused hook token url param

* fix: remove gateway auth tokens from URLs (#9436) (thanks @coygeek)

* test: fix Windows path separators in audit test (#9436)

---------

Co-authored-by: George Pickett <gpickett00@gmail.com>
This commit is contained in:
Coy Geek
2026-02-05 18:08:29 -08:00
committed by GitHub
parent b1430aaaca
commit 717129f7f9
22 changed files with 107 additions and 172 deletions

View File

@@ -39,29 +39,25 @@ describe("gateway hooks helpers", () => {
expect(() => resolveHooksConfig(cfg)).toThrow("hooks.path may not be '/'");
});
test("extractHookToken prefers bearer > header > query", () => {
test("extractHookToken prefers bearer > header", () => {
const req = {
headers: {
authorization: "Bearer top",
"x-openclaw-token": "header",
},
} as unknown as IncomingMessage;
const url = new URL("http://localhost/hooks/wake?token=query");
const result1 = extractHookToken(req, url);
expect(result1.token).toBe("top");
expect(result1.fromQuery).toBe(false);
const result1 = extractHookToken(req);
expect(result1).toBe("top");
const req2 = {
headers: { "x-openclaw-token": "header" },
} as unknown as IncomingMessage;
const result2 = extractHookToken(req2, url);
expect(result2.token).toBe("header");
expect(result2.fromQuery).toBe(false);
const result2 = extractHookToken(req2);
expect(result2).toBe("header");
const req3 = { headers: {} } as unknown as IncomingMessage;
const result3 = extractHookToken(req3, url);
expect(result3.token).toBe("query");
expect(result3.fromQuery).toBe(true);
const result3 = extractHookToken(req3);
expect(result3).toBeUndefined();
});
test("normalizeWakePayload trims + validates", () => {

View File

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

View File

@@ -147,20 +147,22 @@ export function createHooksRequestHandler(
return false;
}
const { token, fromQuery } = extractHookToken(req, url);
if (url.searchParams.has("token")) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(
"Hook token must be provided via Authorization: Bearer <token> or X-OpenClaw-Token header (query parameters are not allowed).",
);
return true;
}
const token = extractHookToken(req);
if (!token || token !== hooksConfig.token) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
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-OpenClaw-Token header instead.",
);
}
if (req.method !== "POST") {
res.statusCode = 405;

View File

@@ -88,10 +88,7 @@ describe("gateway server hooks", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Query auth" }),
});
expect(resQuery.status).toBe(200);
const queryEvents = await waitForSystemEvent();
expect(queryEvents.some((e) => e.includes("Query auth"))).toBe(true);
drainSystemEvents(resolveMainKey());
expect(resQuery.status).toBe(400);
const resBadChannel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST",

View File

@@ -85,7 +85,7 @@ function formatGatewayAuthFailureMessage(params: {
const isCli = isGatewayCliClient(client);
const isControlUi = client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const isWebchat = isWebchatClient(client);
const uiHint = "open a tokenized dashboard URL or paste token in Control UI settings";
const uiHint = "open the dashboard URL and paste the token in Control UI settings";
const tokenHint = isCli
? "set gateway.remote.token to match gateway.auth.token"
: isControlUi || isWebchat