fix(nostr): guard profile mutations

This commit is contained in:
Peter Steinberger
2026-02-14 16:49:40 +01:00
parent eb60e2e1b2
commit 3e0e78f82a
3 changed files with 147 additions and 2 deletions

View File

@@ -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/<email>` 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.

View File

@@ -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<string, string>; 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" }),

View File

@@ -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<true> {
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<true> {
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
return true;
}
// Get account info
const accountInfo = ctx.getAccountInfo(accountId);
if (!accountInfo) {