Skip to content

Commit 2321d67

Browse files
authored
fix(gateway): require auth for control ui bootstrap config (#70247)
* fix(gateway): require auth for control ui bootstrap config * fix(ui): send auth on bootstrap fetch * fix(ui): keep bootstrap auth same-origin * fix(ui): refresh bootstrap after auth hello * docs(changelog): note control ui bootstrap auth * fix(ui): retry bootstrap auth with alternate shared secret on 401
1 parent c87c974 commit 2321d67

11 files changed

Lines changed: 339 additions & 42 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Docs: https://docs.openclaw.ai
114114
- Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi `0.68.1` session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman.
115115
- Agents/Pi: honor explicit `strict-agentic` execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer.
116116
- OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00.
117+
- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00.
117118

118119
## 2026.4.21
119120

src/gateway/control-ui.auto-root.http.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => {
4949
resolveControlUiRootSyncMock.mockReturnValue(tmp);
5050

5151
const { res, end } = makeMockHttpResponse();
52-
const handled = handleControlUiHttpRequest(
52+
const handled = await handleControlUiHttpRequest(
5353
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
5454
res,
5555
);
@@ -70,7 +70,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => {
7070
resolveControlUiRootSyncMock.mockReturnValue(tmp);
7171

7272
const { res, end } = makeMockHttpResponse();
73-
const handled = handleControlUiHttpRequest(
73+
const handled = await handleControlUiHttpRequest(
7474
{ url: "/dashboard", method: "GET" } as IncomingMessage,
7575
res,
7676
);
@@ -91,7 +91,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => {
9191
resolveControlUiRootSyncMock.mockReturnValue(tmp);
9292

9393
const { res } = makeMockHttpResponse();
94-
const handled = handleControlUiHttpRequest(
94+
const handled = await handleControlUiHttpRequest(
9595
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
9696
res,
9797
);

src/gateway/control-ui.http.test.ts

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ describe("handleControlUiHttpRequest", () => {
4848
expect(params.end).toHaveBeenCalledWith("Not Found");
4949
}
5050

51-
function runControlUiRequest(params: {
51+
async function runControlUiRequest(params: {
5252
url: string;
5353
method: "GET" | "HEAD" | "POST";
5454
rootPath: string;
5555
basePath?: string;
5656
rootKind?: "resolved" | "bundled";
5757
}) {
5858
const { res, end } = makeMockHttpResponse();
59-
const handled = handleControlUiHttpRequest(
59+
const handled = await handleControlUiHttpRequest(
6060
{ url: params.url, method: params.method } as IncomingMessage,
6161
res,
6262
{
@@ -67,6 +67,33 @@ describe("handleControlUiHttpRequest", () => {
6767
return { res, end, handled };
6868
}
6969

70+
async function runBootstrapConfigRequest(params: {
71+
rootPath: string;
72+
basePath?: string;
73+
auth?: ResolvedGatewayAuth;
74+
headers?: IncomingMessage["headers"];
75+
}) {
76+
const { res, end } = makeMockHttpResponse();
77+
const url = params.basePath
78+
? `${params.basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
79+
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
80+
const handled = await handleControlUiHttpRequest(
81+
{
82+
url,
83+
method: "GET",
84+
headers: params.headers ?? {},
85+
socket: { remoteAddress: "127.0.0.1" },
86+
} as IncomingMessage,
87+
res,
88+
{
89+
...(params.basePath ? { basePath: params.basePath } : {}),
90+
...(params.auth ? { auth: params.auth } : {}),
91+
root: { kind: "resolved", path: params.rootPath },
92+
},
93+
);
94+
return { res, end, handled };
95+
}
96+
7097
async function runAvatarRequest(params: {
7198
url: string;
7299
method: "GET" | "HEAD";
@@ -241,7 +268,7 @@ describe("handleControlUiHttpRequest", () => {
241268
await withControlUiRoot({
242269
fn: async (tmp) => {
243270
const { res, setHeader } = makeMockHttpResponse();
244-
const handled = handleControlUiHttpRequest(
271+
const handled = await handleControlUiHttpRequest(
245272
{ url: "/", method: "GET" } as IncomingMessage,
246273
res,
247274
{
@@ -405,7 +432,7 @@ describe("handleControlUiHttpRequest", () => {
405432
indexHtml: html,
406433
fn: async (tmp) => {
407434
const { res, setHeader } = makeMockHttpResponse();
408-
handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, {
435+
await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, {
409436
root: { kind: "resolved", path: tmp },
410437
});
411438
const cspCalls = setHeader.mock.calls.filter(
@@ -424,7 +451,7 @@ describe("handleControlUiHttpRequest", () => {
424451
indexHtml: html,
425452
fn: async (tmp) => {
426453
const { res, end } = makeMockHttpResponse();
427-
const handled = handleControlUiHttpRequest(
454+
const handled = await handleControlUiHttpRequest(
428455
{ url: "/", method: "GET" } as IncomingMessage,
429456
res,
430457
{
@@ -445,7 +472,7 @@ describe("handleControlUiHttpRequest", () => {
445472
await withControlUiRoot({
446473
fn: async (tmp) => {
447474
const { res, end } = makeMockHttpResponse();
448-
const handled = handleControlUiHttpRequest(
475+
const handled = await handleControlUiHttpRequest(
449476
{ url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage,
450477
res,
451478
{
@@ -467,11 +494,43 @@ describe("handleControlUiHttpRequest", () => {
467494
});
468495
});
469496

497+
it("rejects bootstrap config requests without a valid auth token when auth is enabled", async () => {
498+
await withControlUiRoot({
499+
fn: async (tmp) => {
500+
const { res, handled, end } = await runBootstrapConfigRequest({
501+
rootPath: tmp,
502+
auth: { mode: "token", token: "test-token", allowTailscale: false },
503+
});
504+
expect(handled).toBe(true);
505+
expect(res.statusCode).toBe(401);
506+
expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized");
507+
},
508+
});
509+
});
510+
511+
it("serves bootstrap config JSON when auth is enabled and the token is valid", async () => {
512+
await withControlUiRoot({
513+
fn: async (tmp) => {
514+
const { res, handled, end } = await runBootstrapConfigRequest({
515+
rootPath: tmp,
516+
auth: { mode: "token", token: "test-token", allowTailscale: false },
517+
headers: {
518+
authorization: "Bearer test-token",
519+
},
520+
});
521+
expect(handled).toBe(true);
522+
expect(res.statusCode).toBe(200);
523+
const parsed = parseBootstrapPayload(end);
524+
expect(parsed.assistantAgentId).toBe("main");
525+
},
526+
});
527+
});
528+
470529
it("serves bootstrap config JSON under basePath", async () => {
471530
await withControlUiRoot({
472531
fn: async (tmp) => {
473532
const { res, end } = makeMockHttpResponse();
474-
const handled = handleControlUiHttpRequest(
533+
const handled = await handleControlUiHttpRequest(
475534
{ url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, method: "GET" } as IncomingMessage,
476535
res,
477536
{
@@ -613,7 +672,7 @@ describe("handleControlUiHttpRequest", () => {
613672
await fs.symlink(outsideFile, path.join(assetsDir, "leak.txt"));
614673

615674
const { res, end } = makeMockHttpResponse();
616-
const handled = handleControlUiHttpRequest(
675+
const handled = await handleControlUiHttpRequest(
617676
{ url: "/assets/leak.txt", method: "GET" } as IncomingMessage,
618677
res,
619678
{
@@ -634,7 +693,7 @@ describe("handleControlUiHttpRequest", () => {
634693
const { assetsDir, filePath } = await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
635694
await fs.symlink(filePath, path.join(assetsDir, "linked.txt"));
636695

637-
const { res, end, handled } = runControlUiRequest({
696+
const { res, end, handled } = await runControlUiRequest({
638697
url: "/assets/linked.txt",
639698
method: "GET",
640699
rootPath: tmp,
@@ -652,7 +711,7 @@ describe("handleControlUiHttpRequest", () => {
652711
fn: async (tmp) => {
653712
await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
654713

655-
const { res, end, handled } = runControlUiRequest({
714+
const { res, end, handled } = await runControlUiRequest({
656715
url: "/assets/actual.txt",
657716
method: "HEAD",
658717
rootPath: tmp,
@@ -675,7 +734,7 @@ describe("handleControlUiHttpRequest", () => {
675734
await fs.rm(path.join(tmp, "index.html"));
676735
await fs.symlink(outsideIndex, path.join(tmp, "index.html"));
677736

678-
const { res, end, handled } = runControlUiRequest({
737+
const { res, end, handled } = await runControlUiRequest({
679738
url: "/app/route",
680739
method: "GET",
681740
rootPath: tmp,
@@ -698,7 +757,7 @@ describe("handleControlUiHttpRequest", () => {
698757
await fs.rm(path.join(tmp, "index.html"));
699758
await fs.link(outsideIndex, path.join(tmp, "index.html"));
700759

701-
const { res, end, handled } = runControlUiRequest({
760+
const { res, end, handled } = await runControlUiRequest({
702761
url: "/",
703762
method: "GET",
704763
rootPath: tmp,
@@ -716,7 +775,7 @@ describe("handleControlUiHttpRequest", () => {
716775
fn: async (tmp) => {
717776
await createHardlinkedAssetFile(tmp);
718777

719-
const { res, end, handled } = runControlUiRequest({
778+
const { res, end, handled } = await runControlUiRequest({
720779
url: "/assets/app.hl.js",
721780
method: "GET",
722781
rootPath: tmp,
@@ -734,7 +793,7 @@ describe("handleControlUiHttpRequest", () => {
734793
fn: async (tmp) => {
735794
await createHardlinkedAssetFile(tmp);
736795

737-
const { res, end, handled } = runControlUiRequest({
796+
const { res, end, handled } = await runControlUiRequest({
738797
url: "/assets/app.hl.js",
739798
method: "GET",
740799
rootPath: tmp,
@@ -753,7 +812,7 @@ describe("handleControlUiHttpRequest", () => {
753812
fn: async (tmp) => {
754813
for (const webhookPath of ["/bluebubbles-webhook", "/custom-webhook", "/callback"]) {
755814
const { res } = makeMockHttpResponse();
756-
const handled = handleControlUiHttpRequest(
815+
const handled = await handleControlUiHttpRequest(
757816
{ url: webhookPath, method: "POST" } as IncomingMessage,
758817
res,
759818
{ root: { kind: "resolved", path: tmp } },
@@ -770,7 +829,7 @@ describe("handleControlUiHttpRequest", () => {
770829
await withControlUiRoot({
771830
fn: async (tmp) => {
772831
const { res } = makeMockHttpResponse();
773-
const handled = handleControlUiHttpRequest(
832+
const handled = await handleControlUiHttpRequest(
774833
{ url: "/bluebubbles-webhook", method: "POST" } as IncomingMessage,
775834
res,
776835
{ basePath: "/openclaw", root: { kind: "resolved", path: tmp } },
@@ -784,7 +843,7 @@ describe("handleControlUiHttpRequest", () => {
784843
await withControlUiRoot({
785844
fn: async (tmp) => {
786845
for (const apiPath of ["/api", "/api/sessions", "/api/channels/nostr"]) {
787-
const { handled } = runControlUiRequest({
846+
const { handled } = await runControlUiRequest({
788847
url: apiPath,
789848
method: "GET",
790849
rootPath: tmp,
@@ -799,7 +858,7 @@ describe("handleControlUiHttpRequest", () => {
799858
await withControlUiRoot({
800859
fn: async (tmp) => {
801860
for (const pluginPath of ["/plugins", "/plugins/diffs/view/abc/def"]) {
802-
const { handled } = runControlUiRequest({
861+
const { handled } = await runControlUiRequest({
803862
url: pluginPath,
804863
method: "GET",
805864
rootPath: tmp,
@@ -813,7 +872,7 @@ describe("handleControlUiHttpRequest", () => {
813872
it("falls through POST requests when basePath is empty", async () => {
814873
await withControlUiRoot({
815874
fn: async (tmp) => {
816-
const { handled, end } = runControlUiRequest({
875+
const { handled, end } = await runControlUiRequest({
817876
url: "/webhook/bluebubbles",
818877
method: "POST",
819878
rootPath: tmp,
@@ -828,7 +887,7 @@ describe("handleControlUiHttpRequest", () => {
828887
await withControlUiRoot({
829888
fn: async (tmp) => {
830889
for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) {
831-
const { handled, end } = runControlUiRequest({
890+
const { handled, end } = await runControlUiRequest({
832891
url: route,
833892
method: "POST",
834893
rootPath: tmp,
@@ -850,7 +909,7 @@ describe("handleControlUiHttpRequest", () => {
850909

851910
const secretPathUrl = secretPath.split(path.sep).join("/");
852911
const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`;
853-
const { res, end, handled } = runControlUiRequest({
912+
const { res, end, handled } = await runControlUiRequest({
854913
url: `/openclaw/${absolutePathUrl}`,
855914
method: "GET",
856915
rootPath: root,
@@ -879,7 +938,7 @@ describe("handleControlUiHttpRequest", () => {
879938
throw error;
880939
}
881940

882-
const { res, end, handled } = runControlUiRequest({
941+
const { res, end, handled } = await runControlUiRequest({
883942
url: "/openclaw/assets/leak.txt",
884943
method: "GET",
885944
rootPath: root,

src/gateway/control-ui.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ export type ControlUiRequestOptions = {
5555
config?: OpenClawConfig;
5656
agentId?: string;
5757
root?: ControlUiRootState;
58+
auth?: ResolvedGatewayAuth;
59+
trustedProxies?: string[];
60+
allowRealIpFallback?: boolean;
61+
rateLimiter?: AuthRateLimiter;
5862
};
5963

6064
export type ControlUiRootState =
@@ -617,11 +621,11 @@ function isSafeRelativePath(relPath: string) {
617621
return true;
618622
}
619623

620-
export function handleControlUiHttpRequest(
624+
export async function handleControlUiHttpRequest(
621625
req: IncomingMessage,
622626
res: ServerResponse,
623627
opts?: ControlUiRequestOptions,
624-
): boolean {
628+
): Promise<boolean> {
625629
const urlRaw = req.url;
626630
if (!urlRaw) {
627631
return false;
@@ -657,6 +661,16 @@ export function handleControlUiHttpRequest(
657661
? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`
658662
: CONTROL_UI_BOOTSTRAP_CONFIG_PATH;
659663
if (pathname === bootstrapConfigPath) {
664+
if (
665+
!(await authorizeControlUiReadRequest(req, res, {
666+
auth: opts?.auth,
667+
trustedProxies: opts?.trustedProxies,
668+
allowRealIpFallback: opts?.allowRealIpFallback,
669+
rateLimiter: opts?.rateLimiter,
670+
}))
671+
) {
672+
return true;
673+
}
660674
const config = opts?.config;
661675
const identity = config
662676
? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId })

src/gateway/gateway-misc.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe("GatewayClient", () => {
8383
it("returns 404 for missing static asset paths instead of SPA fallback", async () => {
8484
await withControlUiRoot({ faviconSvg: "<svg/>" }, async (tmp) => {
8585
const { res } = makeControlUiResponse();
86-
const handled = handleControlUiHttpRequest(
86+
const handled = await handleControlUiHttpRequest(
8787
{ url: "/webchat/favicon.svg", method: "GET" } as IncomingMessage,
8888
res,
8989
{ root: { kind: "resolved", path: tmp } },
@@ -96,7 +96,7 @@ describe("GatewayClient", () => {
9696
it("returns 404 for missing static assets with query strings", async () => {
9797
await withControlUiRoot({}, async (tmp) => {
9898
const { res } = makeControlUiResponse();
99-
const handled = handleControlUiHttpRequest(
99+
const handled = await handleControlUiHttpRequest(
100100
{ url: "/webchat/favicon.svg?v=1", method: "GET" } as IncomingMessage,
101101
res,
102102
{ root: { kind: "resolved", path: tmp } },
@@ -109,7 +109,7 @@ describe("GatewayClient", () => {
109109
it("still serves SPA fallback for extensionless paths", async () => {
110110
await withControlUiRoot({}, async (tmp) => {
111111
const { res } = makeControlUiResponse();
112-
const handled = handleControlUiHttpRequest(
112+
const handled = await handleControlUiHttpRequest(
113113
{ url: "/webchat/chat", method: "GET" } as IncomingMessage,
114114
res,
115115
{ root: { kind: "resolved", path: tmp } },
@@ -122,7 +122,7 @@ describe("GatewayClient", () => {
122122
it("HEAD returns 404 for missing static assets consistent with GET", async () => {
123123
await withControlUiRoot({}, async (tmp) => {
124124
const { res } = makeControlUiResponse();
125-
const handled = handleControlUiHttpRequest(
125+
const handled = await handleControlUiHttpRequest(
126126
{ url: "/webchat/favicon.svg", method: "HEAD" } as IncomingMessage,
127127
res,
128128
{ root: { kind: "resolved", path: tmp } },
@@ -136,7 +136,7 @@ describe("GatewayClient", () => {
136136
await withControlUiRoot({}, async (tmp) => {
137137
for (const route of ["/webchat/user/jane.doe", "/webchat/v2.0", "/settings/v1.2"]) {
138138
const { res } = makeControlUiResponse();
139-
const handled = handleControlUiHttpRequest(
139+
const handled = await handleControlUiHttpRequest(
140140
{ url: route, method: "GET" } as IncomingMessage,
141141
res,
142142
{ root: { kind: "resolved", path: tmp } },
@@ -150,7 +150,7 @@ describe("GatewayClient", () => {
150150
it("serves SPA fallback for .html paths that do not exist on disk", async () => {
151151
await withControlUiRoot({}, async (tmp) => {
152152
const { res } = makeControlUiResponse();
153-
const handled = handleControlUiHttpRequest(
153+
const handled = await handleControlUiHttpRequest(
154154
{ url: "/webchat/foo.html", method: "GET" } as IncomingMessage,
155155
res,
156156
{ root: { kind: "resolved", path: tmp } },

0 commit comments

Comments
 (0)