From 647d929c9d0fd114249230d939a5cb3b36dc70e7 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:55:22 -0800 Subject: [PATCH] fix: Unauthenticated Nostr profile API allows remote config tampering (#13719) * fix(an-07): apply security fix Generated by staged fix workflow. * fix(an-07): apply security fix Generated by staged fix workflow. * fix(an-07): satisfy lint in plugin auth regression test Replace unsafe unknown-to-string coercion in the gateway plugin auth test helper with explicit string/null/JSON handling so pnpm check passes. --- src/gateway/server-http.ts | 26 ++- src/gateway/server.plugin-http-auth.test.ts | 174 ++++++++++++++++++++ ui/src/ui/app-channels.ts | 23 +++ 3 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 src/gateway/server.plugin-http-auth.test.ts diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index d3f0cc24618..b6c4019f911 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -333,6 +333,7 @@ export function createGatewayHttpServer(opts: { try { const configSnapshot = loadConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; + const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; if (await handleHooksRequest(req, res)) { return; } @@ -347,8 +348,26 @@ export function createGatewayHttpServer(opts: { if (await handleSlackHttpRequest(req, res)) { return; } - if (handlePluginRequest && (await handlePluginRequest(req, res))) { - return; + if (handlePluginRequest) { + // Channel HTTP endpoints are gateway-auth protected by default. + // Non-channel plugin routes remain plugin-owned and must enforce + // their own auth when exposing sensitive functionality. + if (requestPath.startsWith("/api/channels/")) { + const token = getBearerToken(req); + const authResult = await authorizeGatewayConnect({ + auth: resolvedAuth, + connectAuth: token ? { token, password: token } : null, + req, + trustedProxies, + }); + if (!authResult.ok) { + sendUnauthorized(res); + return; + } + } + if (await handlePluginRequest(req, res)) { + return; + } } if (openResponsesEnabled) { if ( @@ -372,8 +391,7 @@ export function createGatewayHttpServer(opts: { } } if (canvasHost) { - const url = new URL(req.url ?? "/", "http://localhost"); - if (isCanvasPath(url.pathname)) { + if (isCanvasPath(requestPath)) { const ok = await authorizeCanvasRequest({ req, auth: resolvedAuth, diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts new file mode 100644 index 00000000000..b91e901845f --- /dev/null +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -0,0 +1,174 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test, vi } from "vitest"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { createGatewayHttpServer } from "./server-http.js"; + +async function withTempConfig(params: { cfg: unknown; run: () => Promise }): Promise { + const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH; + const prevDisableCache = process.env.OPENCLAW_DISABLE_CONFIG_CACHE; + + const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-http-auth-test-")); + const configPath = path.join(dir, "openclaw.json"); + + process.env.OPENCLAW_CONFIG_PATH = configPath; + process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1"; + + try { + await writeFile(configPath, JSON.stringify(params.cfg, null, 2), "utf-8"); + await params.run(); + } finally { + if (prevConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = prevConfigPath; + } + if (prevDisableCache === undefined) { + delete process.env.OPENCLAW_DISABLE_CONFIG_CACHE; + } else { + process.env.OPENCLAW_DISABLE_CONFIG_CACHE = prevDisableCache; + } + await rm(dir, { recursive: true, force: true }); + } +} + +function createRequest(params: { + path: string; + authorization?: string; + method?: string; +}): IncomingMessage { + const headers: Record = { + host: "localhost:18789", + }; + if (params.authorization) { + headers.authorization = params.authorization; + } + return { + method: params.method ?? "GET", + url: params.path, + headers, + socket: { remoteAddress: "127.0.0.1" }, + } as IncomingMessage; +} + +function createResponse(): { + res: ServerResponse; + setHeader: ReturnType; + end: ReturnType; + getBody: () => string; +} { + const setHeader = vi.fn(); + let body = ""; + const end = vi.fn((chunk?: unknown) => { + if (typeof chunk === "string") { + body = chunk; + return; + } + if (chunk == null) { + body = ""; + return; + } + body = JSON.stringify(chunk); + }); + const res = { + headersSent: false, + statusCode: 200, + setHeader, + end, + } as unknown as ServerResponse; + return { + res, + setHeader, + end, + getBody: () => body, + }; +} + +async function dispatchRequest( + server: ReturnType, + req: IncomingMessage, + res: ServerResponse, +): Promise { + server.emit("request", req, res); + await new Promise((resolve) => setImmediate(resolve)); +} + +describe("gateway plugin HTTP auth boundary", () => { + test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { gateway: { trustedProxies: [] } }, + run: async () => { + const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + if (pathname === "/api/channels/nostr/default/profile") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "channel" })); + return true; + } + if (pathname === "/plugin/public") { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: true, route: "public" })); + return true; + } + return false; + }); + + const server = createGatewayHttpServer({ + canvasHost: null, + clients: new Set(), + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + handlePluginRequest, + resolvedAuth, + }); + + const unauthenticated = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/api/channels/nostr/default/profile" }), + unauthenticated.res, + ); + expect(unauthenticated.res.statusCode).toBe(401); + expect(unauthenticated.getBody()).toContain("Unauthorized"); + expect(handlePluginRequest).not.toHaveBeenCalled(); + + const authenticated = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/api/channels/nostr/default/profile", + authorization: "Bearer test-token", + }), + authenticated.res, + ); + expect(authenticated.res.statusCode).toBe(200); + expect(authenticated.getBody()).toContain('"route":"channel"'); + + const unauthenticatedPublic = createResponse(); + await dispatchRequest( + server, + createRequest({ path: "/plugin/public" }), + unauthenticatedPublic.res, + ); + expect(unauthenticatedPublic.res.statusCode).toBe(200); + expect(unauthenticatedPublic.getBody()).toContain('"route":"public"'); + + expect(handlePluginRequest).toHaveBeenCalledTimes(2); + }, + }); + }); +}); diff --git a/ui/src/ui/app-channels.ts b/ui/src/ui/app-channels.ts index 86d53fe15a9..28840af0528 100644 --- a/ui/src/ui/app-channels.ts +++ b/ui/src/ui/app-channels.ts @@ -66,6 +66,27 @@ function buildNostrProfileUrl(accountId: string, suffix = ""): string { return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`; } +function resolveGatewayHttpAuthHeader(host: OpenClawApp): string | null { + const deviceToken = host.hello?.auth?.deviceToken?.trim(); + if (deviceToken) { + return `Bearer ${deviceToken}`; + } + const token = host.settings.token.trim(); + if (token) { + return `Bearer ${token}`; + } + const password = host.password.trim(); + if (password) { + return `Bearer ${password}`; + } + return null; +} + +function buildGatewayHttpHeaders(host: OpenClawApp): Record { + const authorization = resolveGatewayHttpAuthHeader(host); + return authorization ? { Authorization: authorization } : {}; +} + export function handleNostrProfileEdit( host: OpenClawApp, accountId: string, @@ -133,6 +154,7 @@ export async function handleNostrProfileSave(host: OpenClawApp) { method: "PUT", headers: { "Content-Type": "application/json", + ...buildGatewayHttpHeaders(host), }, body: JSON.stringify(state.values), }); @@ -203,6 +225,7 @@ export async function handleNostrProfileImport(host: OpenClawApp) { method: "POST", headers: { "Content-Type": "application/json", + ...buildGatewayHttpHeaders(host), }, body: JSON.stringify({ autoMerge: true }), });