@openclaw/nostr 2026.2.12 → 2026.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/nostr-profile-http.test.ts +71 -2
- package/src/nostr-profile-http.ts +93 -47
- package/src/nostr-profile.fuzz.test.ts +5 -2
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -29,12 +29,21 @@ import { importProfileFromRelays } from "./nostr-profile-import.js";
|
|
|
29
29
|
// Test Helpers
|
|
30
30
|
// ============================================================================
|
|
31
31
|
|
|
32
|
-
function createMockRequest(
|
|
32
|
+
function createMockRequest(
|
|
33
|
+
method: string,
|
|
34
|
+
url: string,
|
|
35
|
+
body?: unknown,
|
|
36
|
+
opts?: { headers?: Record<string, string>; remoteAddress?: string },
|
|
37
|
+
): IncomingMessage {
|
|
33
38
|
const socket = new Socket();
|
|
39
|
+
Object.defineProperty(socket, "remoteAddress", {
|
|
40
|
+
value: opts?.remoteAddress ?? "127.0.0.1",
|
|
41
|
+
configurable: true,
|
|
42
|
+
});
|
|
34
43
|
const req = new IncomingMessage(socket);
|
|
35
44
|
req.method = method;
|
|
36
45
|
req.url = url;
|
|
37
|
-
req.headers = { host: "localhost:3000" };
|
|
46
|
+
req.headers = { host: "localhost:3000", ...(opts?.headers ?? {}) };
|
|
38
47
|
|
|
39
48
|
if (body) {
|
|
40
49
|
const bodyStr = JSON.stringify(body);
|
|
@@ -206,6 +215,36 @@ describe("nostr-profile-http", () => {
|
|
|
206
215
|
expect(ctx.updateConfigProfile).toHaveBeenCalled();
|
|
207
216
|
});
|
|
208
217
|
|
|
218
|
+
it("rejects profile mutation from non-loopback remote address", async () => {
|
|
219
|
+
const ctx = createMockContext();
|
|
220
|
+
const handler = createNostrProfileHttpHandler(ctx);
|
|
221
|
+
const req = createMockRequest(
|
|
222
|
+
"PUT",
|
|
223
|
+
"/api/channels/nostr/default/profile",
|
|
224
|
+
{ name: "attacker" },
|
|
225
|
+
{ remoteAddress: "198.51.100.10" },
|
|
226
|
+
);
|
|
227
|
+
const res = createMockResponse();
|
|
228
|
+
|
|
229
|
+
await handler(req, res);
|
|
230
|
+
expect(res._getStatusCode()).toBe(403);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("rejects cross-origin profile mutation attempts", async () => {
|
|
234
|
+
const ctx = createMockContext();
|
|
235
|
+
const handler = createNostrProfileHttpHandler(ctx);
|
|
236
|
+
const req = createMockRequest(
|
|
237
|
+
"PUT",
|
|
238
|
+
"/api/channels/nostr/default/profile",
|
|
239
|
+
{ name: "attacker" },
|
|
240
|
+
{ headers: { origin: "https://evil.example" } },
|
|
241
|
+
);
|
|
242
|
+
const res = createMockResponse();
|
|
243
|
+
|
|
244
|
+
await handler(req, res);
|
|
245
|
+
expect(res._getStatusCode()).toBe(403);
|
|
246
|
+
});
|
|
247
|
+
|
|
209
248
|
it("rejects private IP in picture URL (SSRF protection)", async () => {
|
|
210
249
|
const ctx = createMockContext();
|
|
211
250
|
const handler = createNostrProfileHttpHandler(ctx);
|
|
@@ -327,6 +366,36 @@ describe("nostr-profile-http", () => {
|
|
|
327
366
|
expect(data.saved).toBe(false); // autoMerge not requested
|
|
328
367
|
});
|
|
329
368
|
|
|
369
|
+
it("rejects import mutation from non-loopback remote address", async () => {
|
|
370
|
+
const ctx = createMockContext();
|
|
371
|
+
const handler = createNostrProfileHttpHandler(ctx);
|
|
372
|
+
const req = createMockRequest(
|
|
373
|
+
"POST",
|
|
374
|
+
"/api/channels/nostr/default/profile/import",
|
|
375
|
+
{},
|
|
376
|
+
{ remoteAddress: "203.0.113.10" },
|
|
377
|
+
);
|
|
378
|
+
const res = createMockResponse();
|
|
379
|
+
|
|
380
|
+
await handler(req, res);
|
|
381
|
+
expect(res._getStatusCode()).toBe(403);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("rejects cross-origin import mutation attempts", async () => {
|
|
385
|
+
const ctx = createMockContext();
|
|
386
|
+
const handler = createNostrProfileHttpHandler(ctx);
|
|
387
|
+
const req = createMockRequest(
|
|
388
|
+
"POST",
|
|
389
|
+
"/api/channels/nostr/default/profile/import",
|
|
390
|
+
{},
|
|
391
|
+
{ headers: { origin: "https://evil.example" } },
|
|
392
|
+
);
|
|
393
|
+
const res = createMockResponse();
|
|
394
|
+
|
|
395
|
+
await handler(req, res);
|
|
396
|
+
expect(res._getStatusCode()).toBe(403);
|
|
397
|
+
});
|
|
398
|
+
|
|
330
399
|
it("auto-merges when requested", async () => {
|
|
331
400
|
const ctx = createMockContext({
|
|
332
401
|
getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
+
import { readJsonBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
|
13
14
|
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
|
@@ -234,54 +235,24 @@ async function readJsonBody(
|
|
|
234
235
|
maxBytes = 64 * 1024,
|
|
235
236
|
timeoutMs = 30_000,
|
|
236
237
|
): Promise<unknown> {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
done = true;
|
|
244
|
-
clearTimeout(timer);
|
|
245
|
-
fn();
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
const timer = setTimeout(() => {
|
|
249
|
-
finish(() => {
|
|
250
|
-
const err = new Error("Request body timeout");
|
|
251
|
-
req.destroy(err);
|
|
252
|
-
reject(err);
|
|
253
|
-
});
|
|
254
|
-
}, timeoutMs);
|
|
255
|
-
|
|
256
|
-
const chunks: Buffer[] = [];
|
|
257
|
-
let totalBytes = 0;
|
|
258
|
-
|
|
259
|
-
req.on("data", (chunk: Buffer) => {
|
|
260
|
-
totalBytes += chunk.length;
|
|
261
|
-
if (totalBytes > maxBytes) {
|
|
262
|
-
finish(() => {
|
|
263
|
-
reject(new Error("Request body too large"));
|
|
264
|
-
req.destroy();
|
|
265
|
-
});
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
chunks.push(chunk);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
req.on("end", () => {
|
|
272
|
-
finish(() => {
|
|
273
|
-
try {
|
|
274
|
-
const body = Buffer.concat(chunks).toString("utf-8");
|
|
275
|
-
resolve(body ? JSON.parse(body) : {});
|
|
276
|
-
} catch {
|
|
277
|
-
reject(new Error("Invalid JSON"));
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
req.on("error", (err) => finish(() => reject(err)));
|
|
283
|
-
req.on("close", () => finish(() => reject(new Error("Connection closed"))));
|
|
238
|
+
const result = await readJsonBodyWithLimit(req, {
|
|
239
|
+
maxBytes,
|
|
240
|
+
timeoutMs,
|
|
241
|
+
emptyObjectOnEmpty: true,
|
|
284
242
|
});
|
|
243
|
+
if (result.ok) {
|
|
244
|
+
return result.value;
|
|
245
|
+
}
|
|
246
|
+
if (result.code === "PAYLOAD_TOO_LARGE") {
|
|
247
|
+
throw new Error("Request body too large");
|
|
248
|
+
}
|
|
249
|
+
if (result.code === "REQUEST_BODY_TIMEOUT") {
|
|
250
|
+
throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
251
|
+
}
|
|
252
|
+
if (result.code === "CONNECTION_CLOSED") {
|
|
253
|
+
throw new Error(requestBodyErrorToText("CONNECTION_CLOSED"));
|
|
254
|
+
}
|
|
255
|
+
throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error);
|
|
285
256
|
}
|
|
286
257
|
|
|
287
258
|
function parseAccountIdFromPath(pathname: string): string | null {
|
|
@@ -290,6 +261,73 @@ function parseAccountIdFromPath(pathname: string): string | null {
|
|
|
290
261
|
return match?.[1] ?? null;
|
|
291
262
|
}
|
|
292
263
|
|
|
264
|
+
function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
|
|
265
|
+
if (!remoteAddress) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const ipLower = remoteAddress.toLowerCase().replace(/^\[|\]$/g, "");
|
|
270
|
+
|
|
271
|
+
// IPv6 loopback
|
|
272
|
+
if (ipLower === "::1") {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// IPv4 loopback (127.0.0.0/8)
|
|
277
|
+
if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// IPv4-mapped IPv6
|
|
282
|
+
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
283
|
+
if (v4Mapped) {
|
|
284
|
+
return isLoopbackRemoteAddress(v4Mapped[1]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isLoopbackOriginLike(value: string): boolean {
|
|
291
|
+
try {
|
|
292
|
+
const url = new URL(value);
|
|
293
|
+
const hostname = url.hostname.toLowerCase();
|
|
294
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function enforceLoopbackMutationGuards(
|
|
301
|
+
ctx: NostrProfileHttpContext,
|
|
302
|
+
req: IncomingMessage,
|
|
303
|
+
res: ServerResponse,
|
|
304
|
+
): boolean {
|
|
305
|
+
// Mutation endpoints are local-control-plane only.
|
|
306
|
+
const remoteAddress = req.socket.remoteAddress;
|
|
307
|
+
if (!isLoopbackRemoteAddress(remoteAddress)) {
|
|
308
|
+
ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
|
|
309
|
+
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// CSRF guard: browsers send Origin/Referer on cross-site requests.
|
|
314
|
+
const origin = req.headers.origin;
|
|
315
|
+
if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
|
|
316
|
+
ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
|
|
317
|
+
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const referer = req.headers.referer ?? req.headers.referrer;
|
|
322
|
+
if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
|
|
323
|
+
ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
|
|
324
|
+
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
293
331
|
// ============================================================================
|
|
294
332
|
// HTTP Handler
|
|
295
333
|
// ============================================================================
|
|
@@ -372,6 +410,10 @@ async function handleUpdateProfile(
|
|
|
372
410
|
req: IncomingMessage,
|
|
373
411
|
res: ServerResponse,
|
|
374
412
|
): Promise<true> {
|
|
413
|
+
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
375
417
|
// Rate limiting
|
|
376
418
|
if (!checkRateLimit(accountId)) {
|
|
377
419
|
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
|
|
@@ -471,6 +513,10 @@ async function handleImportProfile(
|
|
|
471
513
|
req: IncomingMessage,
|
|
472
514
|
res: ServerResponse,
|
|
473
515
|
): Promise<true> {
|
|
516
|
+
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
474
520
|
// Get account info
|
|
475
521
|
const accountInfo = ctx.getAccountInfo(accountId);
|
|
476
522
|
if (!accountInfo) {
|
|
@@ -98,7 +98,10 @@ describe("profile unicode attacks", () => {
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
it("handles excessive combining characters (Zalgo text)", () => {
|
|
101
|
-
|
|
101
|
+
// Keep the source small (faster transforms) while still exercising
|
|
102
|
+
// "lots of combining marks" behavior.
|
|
103
|
+
const marks = "\u0301\u0300\u0336\u034f\u035c\u0360";
|
|
104
|
+
const zalgo = `t${marks.repeat(256)}e${marks.repeat(256)}s${marks.repeat(256)}t`;
|
|
102
105
|
const profile: NostrProfile = {
|
|
103
106
|
name: zalgo.slice(0, 256), // Truncate to fit limit
|
|
104
107
|
};
|
|
@@ -453,7 +456,7 @@ describe("event creation edge cases", () => {
|
|
|
453
456
|
|
|
454
457
|
// Create events in quick succession
|
|
455
458
|
let lastTimestamp = 0;
|
|
456
|
-
for (let i = 0; i <
|
|
459
|
+
for (let i = 0; i < 25; i++) {
|
|
457
460
|
const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
|
|
458
461
|
expect(event.created_at).toBeGreaterThan(lastTimestamp);
|
|
459
462
|
lastTimestamp = event.created_at;
|