@openclaw/nostr 2026.2.13 → 2026.2.15
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/channel.ts +4 -22
- package/src/nostr-profile-http.test.ts +71 -2
- package/src/nostr-profile-http.ts +75 -0
- package/src/nostr-profile.fuzz.test.ts +5 -2
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildChannelConfigSchema,
|
|
3
|
+
collectStatusIssuesFromLastError,
|
|
4
|
+
createDefaultChannelRuntimeState,
|
|
3
5
|
DEFAULT_ACCOUNT_ID,
|
|
4
6
|
formatPairingApproveHint,
|
|
5
7
|
type ChannelPlugin,
|
|
@@ -157,28 +159,8 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|
|
157
159
|
},
|
|
158
160
|
|
|
159
161
|
status: {
|
|
160
|
-
defaultRuntime:
|
|
161
|
-
|
|
162
|
-
running: false,
|
|
163
|
-
lastStartAt: null,
|
|
164
|
-
lastStopAt: null,
|
|
165
|
-
lastError: null,
|
|
166
|
-
},
|
|
167
|
-
collectStatusIssues: (accounts) =>
|
|
168
|
-
accounts.flatMap((account) => {
|
|
169
|
-
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
|
170
|
-
if (!lastError) {
|
|
171
|
-
return [];
|
|
172
|
-
}
|
|
173
|
-
return [
|
|
174
|
-
{
|
|
175
|
-
channel: "nostr",
|
|
176
|
-
accountId: account.accountId,
|
|
177
|
-
kind: "runtime" as const,
|
|
178
|
-
message: `Channel error: ${lastError}`,
|
|
179
|
-
},
|
|
180
|
-
];
|
|
181
|
-
}),
|
|
162
|
+
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
|
163
|
+
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("nostr", accounts),
|
|
182
164
|
buildChannelSummary: ({ snapshot }) => ({
|
|
183
165
|
configured: snapshot.configured ?? false,
|
|
184
166
|
publicKey: snapshot.publicKey ?? null,
|
|
@@ -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" }),
|
|
@@ -261,6 +261,73 @@ function parseAccountIdFromPath(pathname: string): string | null {
|
|
|
261
261
|
return match?.[1] ?? null;
|
|
262
262
|
}
|
|
263
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
|
+
|
|
264
331
|
// ============================================================================
|
|
265
332
|
// HTTP Handler
|
|
266
333
|
// ============================================================================
|
|
@@ -343,6 +410,10 @@ async function handleUpdateProfile(
|
|
|
343
410
|
req: IncomingMessage,
|
|
344
411
|
res: ServerResponse,
|
|
345
412
|
): Promise<true> {
|
|
413
|
+
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
346
417
|
// Rate limiting
|
|
347
418
|
if (!checkRateLimit(accountId)) {
|
|
348
419
|
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
|
|
@@ -442,6 +513,10 @@ async function handleImportProfile(
|
|
|
442
513
|
req: IncomingMessage,
|
|
443
514
|
res: ServerResponse,
|
|
444
515
|
): Promise<true> {
|
|
516
|
+
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
445
520
|
// Get account info
|
|
446
521
|
const accountInfo = ctx.getAccountInfo(accountId);
|
|
447
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;
|