mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:28:37 +00: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.
|
- 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/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/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/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/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.
|
- 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
|
// 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();
|
const socket = new Socket();
|
||||||
|
Object.defineProperty(socket, "remoteAddress", {
|
||||||
|
value: opts?.remoteAddress ?? "127.0.0.1",
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
const req = new IncomingMessage(socket);
|
const req = new IncomingMessage(socket);
|
||||||
req.method = method;
|
req.method = method;
|
||||||
req.url = url;
|
req.url = url;
|
||||||
req.headers = { host: "localhost:3000" };
|
req.headers = { host: "localhost:3000", ...(opts?.headers ?? {}) };
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
@@ -206,6 +215,36 @@ describe("nostr-profile-http", () => {
|
|||||||
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
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 () => {
|
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
||||||
const ctx = createMockContext();
|
const ctx = createMockContext();
|
||||||
const handler = createNostrProfileHttpHandler(ctx);
|
const handler = createNostrProfileHttpHandler(ctx);
|
||||||
@@ -327,6 +366,36 @@ describe("nostr-profile-http", () => {
|
|||||||
expect(data.saved).toBe(false); // autoMerge not requested
|
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 () => {
|
it("auto-merges when requested", async () => {
|
||||||
const ctx = createMockContext({
|
const ctx = createMockContext({
|
||||||
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
||||||
|
|||||||
@@ -261,6 +261,73 @@ function parseAccountIdFromPath(pathname: string): string | null {
|
|||||||
return match?.[1] ?? 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
|
// HTTP Handler
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -343,6 +410,10 @@ async function handleUpdateProfile(
|
|||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
): Promise<true> {
|
): Promise<true> {
|
||||||
|
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
if (!checkRateLimit(accountId)) {
|
if (!checkRateLimit(accountId)) {
|
||||||
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
|
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
|
||||||
@@ -442,6 +513,10 @@ async function handleImportProfile(
|
|||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
): Promise<true> {
|
): Promise<true> {
|
||||||
|
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Get account info
|
// Get account info
|
||||||
const accountInfo = ctx.getAccountInfo(accountId);
|
const accountInfo = ctx.getAccountInfo(accountId);
|
||||||
if (!accountInfo) {
|
if (!accountInfo) {
|
||||||
|
|||||||
Reference in New Issue
Block a user