@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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.15
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.14
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.13
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/nostr",
3
- "version": "2026.2.13",
3
+ "version": "2026.2.15",
4
4
  "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
5
5
  "type": "module",
6
6
  "dependencies": {
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
- accountId: DEFAULT_ACCOUNT_ID,
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(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" }),
@@ -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
- 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;