Skip to content

Commit eaf6d3c

Browse files
authored
fix(dashboard): keep bearer token out of runtime logs
Avoid logging tokenized Control UI URLs or SSH hints while preserving clipboard/browser token handoff.\n\nThanks @Ziy1-Tan!
1 parent c2a2a48 commit eaf6d3c

3 files changed

Lines changed: 76 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai
7171

7272
### Fixes
7373

74+
- Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan.
7475
- Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21.
7576
- macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc.
7677
- TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris.

src/commands/dashboard.links.test.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,42 @@ describe("dashboardCommand", () => {
8989
customBindHost: undefined,
9090
basePath: undefined,
9191
});
92+
// clipboard and browser still get the full authenticated URL
9293
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
9394
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
9495
expect(runtime.log).toHaveBeenCalledWith(
9596
"Opened in your browser. Keep that tab to control OpenClaw.",
9697
);
9798
});
9899

100+
it("never logs the gateway token in the dashboard URL (CVE regression)", async () => {
101+
const secretToken = "super-secret-bearer-token";
102+
mockSnapshot(secretToken);
103+
copyToClipboardMock.mockResolvedValue(true);
104+
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
105+
openUrlMock.mockResolvedValue(true);
106+
107+
await dashboardCommand(runtime);
108+
109+
// Clipboard and browser should still receive the tokenized URL.
110+
expect(copyToClipboardMock).toHaveBeenCalledWith(
111+
`http://127.0.0.1:18789/#token=${secretToken}`,
112+
);
113+
expect(openUrlMock).toHaveBeenCalledWith(`http://127.0.0.1:18789/#token=${secretToken}`);
114+
115+
// The logged output must never contain the token — it flows into
116+
// console-captured log files readable by operator.read-scoped devices.
117+
for (const call of runtime.log.mock.calls) {
118+
const line = String(call[0]);
119+
expect(line).not.toContain(secretToken);
120+
expect(line).not.toContain("#token=");
121+
}
122+
123+
// Base URL should be logged without the fragment.
124+
expect(runtime.log).toHaveBeenCalledWith("Dashboard URL: http://127.0.0.1:18789/");
125+
expect(runtime.log).toHaveBeenCalledWith("Token auto-auth included in browser/clipboard URL.");
126+
});
127+
99128
it("prints SSH hint when browser cannot open", async () => {
100129
mockSnapshot("shhhh");
101130
copyToClipboardMock.mockResolvedValue(false);
@@ -111,14 +140,50 @@ describe("dashboardCommand", () => {
111140
expect(runtime.log).toHaveBeenCalledWith("ssh hint");
112141
});
113142

114-
it("respects --no-open and skips browser attempts", async () => {
115-
mockSnapshot();
143+
it("never passes token to SSH hint (CVE regression — SSH path)", async () => {
144+
const secretToken = "super-secret-bearer-token";
145+
mockSnapshot(secretToken);
146+
copyToClipboardMock.mockResolvedValue(false);
147+
detectBrowserOpenSupportMock.mockResolvedValue({ ok: false, reason: "ssh" });
148+
formatControlUiSshHintMock.mockReturnValue("ssh hint without token");
149+
150+
await dashboardCommand(runtime);
151+
152+
// formatControlUiSshHint must NOT receive the token — the returned
153+
// hint string is written to runtime.log, which flows into the same
154+
// console-captured log file readable by operator.read-scoped devices.
155+
expect(formatControlUiSshHintMock).toHaveBeenCalledWith({ port: 18789, basePath: undefined });
156+
expect(formatControlUiSshHintMock).not.toHaveBeenCalledWith(
157+
expect.objectContaining({ token: expect.anything() }),
158+
);
159+
160+
// Double-check: no logged line contains the secret.
161+
for (const call of runtime.log.mock.calls) {
162+
const line = String(call[0]);
163+
expect(line).not.toContain(secretToken);
164+
expect(line).not.toContain("#token=");
165+
}
166+
});
167+
168+
it("respects --no-open and tells user token URL is in clipboard", async () => {
169+
mockSnapshot("abc");
116170
copyToClipboardMock.mockResolvedValue(true);
117171

118172
await dashboardCommand(runtime, { noOpen: true });
119173

120174
expect(detectBrowserOpenSupportMock).not.toHaveBeenCalled();
121175
expect(openUrlMock).not.toHaveBeenCalled();
176+
expect(runtime.log).toHaveBeenCalledWith(
177+
"Browser launch disabled (--no-open). Token-authenticated URL copied to clipboard.",
178+
);
179+
});
180+
181+
it("respects --no-open with plain URL hint when clipboard fails", async () => {
182+
mockSnapshot("abc");
183+
copyToClipboardMock.mockResolvedValue(false);
184+
185+
await dashboardCommand(runtime, { noOpen: true });
186+
122187
expect(runtime.log).toHaveBeenCalledWith(
123188
"Browser launch disabled (--no-open). Use the URL above.",
124189
);

src/commands/dashboard.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ export async function dashboardCommand(
4646
? `${links.httpUrl}#token=${encodeURIComponent(token)}`
4747
: links.httpUrl;
4848

49-
runtime.log(`Dashboard URL: ${dashboardUrl}`);
49+
runtime.log(`Dashboard URL: ${links.httpUrl}`);
50+
if (includeTokenInUrl) {
51+
runtime.log("Token auto-auth included in browser/clipboard URL.");
52+
}
5053
if (resolvedToken.secretRefConfigured && token) {
5154
runtime.log(
5255
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token; use your external token source if prompted.",
@@ -73,11 +76,13 @@ export async function dashboardCommand(
7376
hint = formatControlUiSshHint({
7477
port,
7578
basePath,
76-
token: includeTokenInUrl ? token || undefined : undefined,
7779
});
7880
}
7981
} else {
80-
hint = "Browser launch disabled (--no-open). Use the URL above.";
82+
hint =
83+
copied && includeTokenInUrl
84+
? "Browser launch disabled (--no-open). Token-authenticated URL copied to clipboard."
85+
: "Browser launch disabled (--no-open). Use the URL above.";
8186
}
8287

8388
if (opened) {

0 commit comments

Comments
 (0)