mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 14:58:26 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user