mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(nostr): guard profile mutations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user