diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f0f0b112..72d8183122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. - Security/Google Chat: deprecate `users/` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. - Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc. +- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc. - Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc. - Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc. - Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc. diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index 4ccee61ef8..d94d4ec604 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -29,12 +29,21 @@ import { importProfileFromRelays } from "./nostr-profile-import.js"; // Test Helpers // ============================================================================ -function createMockRequest(method: string, url: string, body?: unknown): IncomingMessage { +function createMockRequest( + method: string, + url: string, + body?: unknown, + opts?: { headers?: Record; remoteAddress?: string }, +): IncomingMessage { const socket = new Socket(); + Object.defineProperty(socket, "remoteAddress", { + value: opts?.remoteAddress ?? "127.0.0.1", + configurable: true, + }); const req = new IncomingMessage(socket); req.method = method; req.url = url; - req.headers = { host: "localhost:3000" }; + req.headers = { host: "localhost:3000", ...(opts?.headers ?? {}) }; if (body) { const bodyStr = JSON.stringify(body); @@ -206,6 +215,36 @@ describe("nostr-profile-http", () => { expect(ctx.updateConfigProfile).toHaveBeenCalled(); }); + it("rejects profile mutation from non-loopback remote address", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { remoteAddress: "198.51.100.10" }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + + it("rejects cross-origin profile mutation attempts", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "PUT", + "/api/channels/nostr/default/profile", + { name: "attacker" }, + { headers: { origin: "https://evil.example" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("rejects private IP in picture URL (SSRF protection)", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -327,6 +366,36 @@ describe("nostr-profile-http", () => { expect(data.saved).toBe(false); // autoMerge not requested }); + it("rejects import mutation from non-loopback remote address", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "POST", + "/api/channels/nostr/default/profile/import", + {}, + { remoteAddress: "203.0.113.10" }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + + it("rejects cross-origin import mutation attempts", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest( + "POST", + "/api/channels/nostr/default/profile/import", + {}, + { headers: { origin: "https://evil.example" } }, + ); + const res = createMockResponse(); + + await handler(req, res); + expect(res._getStatusCode()).toBe(403); + }); + it("auto-merges when requested", async () => { const ctx = createMockContext({ getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }), diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index 57098fd7f4..b6887a01b0 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -261,6 +261,73 @@ function parseAccountIdFromPath(pathname: string): string | null { return match?.[1] ?? null; } +function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean { + if (!remoteAddress) { + return false; + } + + const ipLower = remoteAddress.toLowerCase().replace(/^\[|\]$/g, ""); + + // IPv6 loopback + if (ipLower === "::1") { + return true; + } + + // IPv4 loopback (127.0.0.0/8) + if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) { + return true; + } + + // IPv4-mapped IPv6 + const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4Mapped) { + return isLoopbackRemoteAddress(v4Mapped[1]); + } + + return false; +} + +function isLoopbackOriginLike(value: string): boolean { + try { + const url = new URL(value); + const hostname = url.hostname.toLowerCase(); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + } catch { + return false; + } +} + +function enforceLoopbackMutationGuards( + ctx: NostrProfileHttpContext, + req: IncomingMessage, + res: ServerResponse, +): boolean { + // Mutation endpoints are local-control-plane only. + const remoteAddress = req.socket.remoteAddress; + if (!isLoopbackRemoteAddress(remoteAddress)) { + ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + + // CSRF guard: browsers send Origin/Referer on cross-site requests. + const origin = req.headers.origin; + if (typeof origin === "string" && !isLoopbackOriginLike(origin)) { + ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + + const referer = req.headers.referer ?? req.headers.referrer; + if (typeof referer === "string" && !isLoopbackOriginLike(referer)) { + ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`); + sendJson(res, 403, { ok: false, error: "Forbidden" }); + return false; + } + + return true; +} + // ============================================================================ // HTTP Handler // ============================================================================ @@ -343,6 +410,10 @@ async function handleUpdateProfile( req: IncomingMessage, res: ServerResponse, ): Promise { + if (!enforceLoopbackMutationGuards(ctx, req, res)) { + return true; + } + // Rate limiting if (!checkRateLimit(accountId)) { sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" }); @@ -442,6 +513,10 @@ async function handleImportProfile( req: IncomingMessage, res: ServerResponse, ): Promise { + if (!enforceLoopbackMutationGuards(ctx, req, res)) { + return true; + } + // Get account info const accountInfo = ctx.getAccountInfo(accountId); if (!accountInfo) {