mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 19:01:47 +03: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:
@@ -18,34 +18,6 @@ const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
|
||||
type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
|
||||
const cleanupHandlers = new Map<CleanupSignal, () => void>();
|
||||
|
||||
/**
|
||||
* Release all held locks - called on process exit to prevent orphaned locks
|
||||
*/
|
||||
async function releaseAllLocks(): Promise<void> {
|
||||
const locks = Array.from(HELD_LOCKS.values());
|
||||
HELD_LOCKS.clear();
|
||||
for (const lock of locks) {
|
||||
try {
|
||||
await lock.handle.close();
|
||||
await fs.rm(lock.lockPath, { force: true });
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "test" && !process.env.VITEST) {
|
||||
// Register cleanup handlers to release locks on unexpected termination
|
||||
process.on("exit", releaseAllLocks);
|
||||
process.on("SIGTERM", () => {
|
||||
void releaseAllLocks().then(() => process.exit(0));
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
void releaseAllLocks().then(() => process.exit(0));
|
||||
});
|
||||
// Note: unhandledRejection handler will call process.exit() which triggers 'exit'
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
return false;
|
||||
|
||||
@@ -292,31 +292,32 @@ export async function dispatchReplyFromConfig(params: {
|
||||
let accumulatedBlockText = "";
|
||||
let blockCount = 0;
|
||||
|
||||
const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native";
|
||||
|
||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||
ctx,
|
||||
{
|
||||
...params.replyOptions,
|
||||
onToolResult:
|
||||
ctx.ChatType !== "group" && ctx.CommandSource !== "native"
|
||||
? (payload: ReplyPayload) => {
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "tool",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload, undefined, false);
|
||||
} else {
|
||||
dispatcher.sendToolResult(ttsPayload);
|
||||
}
|
||||
};
|
||||
return run();
|
||||
}
|
||||
: undefined,
|
||||
onToolResult: shouldSendToolSummaries
|
||||
? (payload: ReplyPayload) => {
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "tool",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload, undefined, false);
|
||||
} else {
|
||||
dispatcher.sendToolResult(ttsPayload);
|
||||
}
|
||||
};
|
||||
return run();
|
||||
}
|
||||
: undefined,
|
||||
onBlockReply: (payload: ReplyPayload, context) => {
|
||||
const run = async () => {
|
||||
// Accumulate block text for TTS generation after streaming
|
||||
|
||||
@@ -83,8 +83,8 @@ describe("dashboardCommand", () => {
|
||||
customBindHost: undefined,
|
||||
basePath: undefined,
|
||||
});
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/?token=abc123");
|
||||
expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/?token=abc123");
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/");
|
||||
expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/");
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"Opened in your browser. Keep that tab to control OpenClaw.",
|
||||
);
|
||||
|
||||
@@ -23,7 +23,6 @@ export async function dashboardCommand(
|
||||
const bind = cfg.gateway?.bind ?? "loopback";
|
||||
const basePath = cfg.gateway?.controlUi?.basePath;
|
||||
const customBindHost = cfg.gateway?.customBindHost;
|
||||
const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
|
||||
|
||||
const links = resolveControlUiLinks({
|
||||
port,
|
||||
@@ -31,11 +30,11 @@ export async function dashboardCommand(
|
||||
customBindHost,
|
||||
basePath,
|
||||
});
|
||||
const authedUrl = token ? `${links.httpUrl}?token=${encodeURIComponent(token)}` : links.httpUrl;
|
||||
const dashboardUrl = links.httpUrl;
|
||||
|
||||
runtime.log(`Dashboard URL: ${authedUrl}`);
|
||||
runtime.log(`Dashboard URL: ${dashboardUrl}`);
|
||||
|
||||
const copied = await copyToClipboard(authedUrl).catch(() => false);
|
||||
const copied = await copyToClipboard(dashboardUrl).catch(() => false);
|
||||
runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable.");
|
||||
|
||||
let opened = false;
|
||||
@@ -43,13 +42,12 @@ export async function dashboardCommand(
|
||||
if (!options.noOpen) {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
opened = await openUrl(authedUrl);
|
||||
opened = await openUrl(dashboardUrl);
|
||||
}
|
||||
if (!opened) {
|
||||
hint = formatControlUiSshHint({
|
||||
port,
|
||||
basePath,
|
||||
token: token || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -179,23 +179,16 @@ export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
|
||||
return { ok: true, command: resolved.command };
|
||||
}
|
||||
|
||||
export function formatControlUiSshHint(params: {
|
||||
port: number;
|
||||
basePath?: string;
|
||||
token?: string;
|
||||
}): string {
|
||||
export function formatControlUiSshHint(params: { port: number; basePath?: string }): string {
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
const uiPath = basePath ? `${basePath}/` : "/";
|
||||
const localUrl = `http://localhost:${params.port}${uiPath}`;
|
||||
const tokenParam = params.token ? `?token=${encodeURIComponent(params.token)}` : "";
|
||||
const authedUrl = params.token ? `${localUrl}${tokenParam}` : undefined;
|
||||
const sshTarget = resolveSshTargetHint();
|
||||
return [
|
||||
"No GUI detected. Open from your computer:",
|
||||
`ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`,
|
||||
"Then open:",
|
||||
localUrl,
|
||||
authedUrl,
|
||||
"Docs:",
|
||||
"https://docs.openclaw.ai/gateway/remote",
|
||||
"https://docs.openclaw.ai/web/control-ui",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+4
-13
@@ -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
|
||||
|
||||
@@ -255,11 +255,7 @@ export async function finalizeOnboardingWizard(
|
||||
customBindHost: settings.customBindHost,
|
||||
basePath: controlUiBasePath,
|
||||
});
|
||||
const tokenParam =
|
||||
settings.authMode === "token" && settings.gatewayToken
|
||||
? `?token=${encodeURIComponent(settings.gatewayToken)}`
|
||||
: "";
|
||||
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||||
const dashboardUrl = links.httpUrl;
|
||||
const gatewayProbe = await probeGatewayReachable({
|
||||
url: links.wsUrl,
|
||||
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
|
||||
@@ -279,8 +275,7 @@ export async function finalizeOnboardingWizard(
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
`Web UI: ${links.httpUrl}`,
|
||||
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||
`Web UI: ${dashboardUrl}`,
|
||||
`Gateway WS: ${links.wsUrl}`,
|
||||
gatewayStatusLine,
|
||||
"Docs: https://docs.openclaw.ai/web/control-ui",
|
||||
@@ -313,8 +308,11 @@ export async function finalizeOnboardingWizard(
|
||||
[
|
||||
"Gateway token: shared auth for the Gateway + Control UI.",
|
||||
"Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
|
||||
`View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`,
|
||||
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`,
|
||||
"Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).",
|
||||
`Get the tokenized link anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
|
||||
`Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
|
||||
"Paste the token into Control UI settings if prompted.",
|
||||
].join("\n"),
|
||||
"Token",
|
||||
);
|
||||
@@ -343,24 +341,22 @@ export async function finalizeOnboardingWizard(
|
||||
} else if (hatchChoice === "web") {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
controlUiOpened = await openUrl(authedUrl);
|
||||
controlUiOpened = await openUrl(dashboardUrl);
|
||||
if (!controlUiOpened) {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
await prompter.note(
|
||||
[
|
||||
`Dashboard link (with token): ${authedUrl}`,
|
||||
`Dashboard link: ${dashboardUrl}`,
|
||||
controlUiOpened
|
||||
? "Opened in your browser. Keep that tab to control OpenClaw."
|
||||
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
|
||||
@@ -446,25 +442,23 @@ export async function finalizeOnboardingWizard(
|
||||
if (shouldOpenControlUi) {
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (browserSupport.ok) {
|
||||
controlUiOpened = await openUrl(authedUrl);
|
||||
controlUiOpened = await openUrl(dashboardUrl);
|
||||
if (!controlUiOpened) {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
controlUiOpenHint = formatControlUiSshHint({
|
||||
port: settings.port,
|
||||
basePath: controlUiBasePath,
|
||||
token: settings.gatewayToken,
|
||||
});
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
`Dashboard link (with token): ${authedUrl}`,
|
||||
`Dashboard link: ${dashboardUrl}`,
|
||||
controlUiOpened
|
||||
? "Opened in your browser. Keep that tab to control OpenClaw."
|
||||
: "Copy/paste this URL in a browser on this machine to control OpenClaw.",
|
||||
@@ -511,10 +505,10 @@ export async function finalizeOnboardingWizard(
|
||||
|
||||
await prompter.outro(
|
||||
controlUiOpened
|
||||
? "Onboarding complete. Dashboard opened with your token; keep that tab to control OpenClaw."
|
||||
? "Onboarding complete. Dashboard opened; keep that tab to control OpenClaw."
|
||||
: seededInBackground
|
||||
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above."
|
||||
: "Onboarding complete. Use the tokenized dashboard link above to control OpenClaw.",
|
||||
? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above."
|
||||
: "Onboarding complete. Use the dashboard link above to control OpenClaw.",
|
||||
);
|
||||
|
||||
return { launchedTui };
|
||||
|
||||
Reference in New Issue
Block a user