@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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.14
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.13
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.6-3
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/nostr",
3
- "version": "2026.2.12",
3
+ "version": "2026.2.14",
4
4
  "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -29,12 +29,21 @@ import { importProfileFromRelays } from "./nostr-profile-import.js";
29
29
  // Test Helpers
30
30
  // ============================================================================
31
31
 
32
- function createMockRequest(method: string, url: string, body?: unknown): IncomingMessage {
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
- return new Promise((resolve, reject) => {
238
- let done = false;
239
- const finish = (fn: () => void) => {
240
- if (done) {
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
- const zalgo = "t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
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 < 100; 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;