OpenClaw < 2026.2.12 - Unauthenticated Profile Tampering via Nostr Plugin HTTP Endpoints
Description
OpenClaw versions prior to 2026.2.12 with the optional Nostr plugin enabled expose unauthenticated HTTP endpoints at /api/channels/nostr/:accountId/profile and /api/channels/nostr/:accountId/profile/import that allow reading and modifying Nostr profiles without gateway authentication. Remote attackers can exploit these endpoints to read sensitive profile data, modify Nostr profiles, persist malicious changes to gateway configuration, and publish signed Nostr events using the bot's private key when the gateway HTTP port is accessible beyond localhost.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.12 | 2026.2.12 |
Affected products
1Patches
1647d929c9d0ffix: Unauthenticated Nostr profile API allows remote config tampering (#13719)
3 files changed · +219 −4
src/gateway/server-http.ts+22 −4 modified@@ -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,
src/gateway/server.plugin-http-auth.test.ts+174 −0 added@@ -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<void> }): Promise<void> { + 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<string, string> = { + 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<typeof vi.fn>; + end: ReturnType<typeof vi.fn>; + 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<typeof createGatewayHttpServer>, + req: IncomingMessage, + res: ServerResponse, +): Promise<void> { + 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); + }, + }); + }); +});
ui/src/ui/app-channels.ts+23 −0 modified@@ -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<string, string> { + 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 }), });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/647d929c9d0fd114249230d939a5cb3b36dc70e7ghsapatchWEB
- github.com/advisories/GHSA-mv9j-6xhh-g383ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-mv9j-6xhh-g383ghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28450ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-unauthenticated-profile-tampering-via-nostr-plugin-http-endpointsghsathird-party-advisoryWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.12ghsaWEB
News mentions
0No linked articles in our index yet.