@openclaw/nostr 2026.3.2 → 2026.3.7

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.3.7
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.3
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.2
4
16
 
5
17
  ### Changes
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/nostr";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/nostr";
3
3
  import { nostrPlugin } from "./src/channel.js";
4
4
  import type { NostrProfile } from "./src/config-schema.js";
5
5
  import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/nostr",
3
- "version": "2026.3.2",
3
+ "version": "2026.3.7",
4
4
  "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,88 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createStartAccountContext } from "../../test-utils/start-account-context.js";
4
+ import { nostrPlugin } from "./channel.js";
5
+ import { setNostrRuntime } from "./runtime.js";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ normalizePubkey: vi.fn((value: string) => `normalized-${value.toLowerCase()}`),
9
+ startNostrBus: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("./nostr-bus.js", () => ({
13
+ DEFAULT_RELAYS: ["wss://relay.example.com"],
14
+ getPublicKeyFromPrivate: vi.fn(() => "pubkey"),
15
+ normalizePubkey: mocks.normalizePubkey,
16
+ startNostrBus: mocks.startNostrBus,
17
+ }));
18
+
19
+ describe("nostr outbound cfg threading", () => {
20
+ afterEach(() => {
21
+ mocks.normalizePubkey.mockClear();
22
+ mocks.startNostrBus.mockReset();
23
+ });
24
+
25
+ it("uses resolved cfg when converting markdown tables before send", async () => {
26
+ const resolveMarkdownTableMode = vi.fn(() => "off");
27
+ const convertMarkdownTables = vi.fn((text: string) => `converted:${text}`);
28
+ setNostrRuntime({
29
+ channel: {
30
+ text: {
31
+ resolveMarkdownTableMode,
32
+ convertMarkdownTables,
33
+ },
34
+ },
35
+ reply: {},
36
+ } as unknown as PluginRuntime);
37
+
38
+ const sendDm = vi.fn(async () => {});
39
+ const bus = {
40
+ sendDm,
41
+ close: vi.fn(),
42
+ getMetrics: vi.fn(() => ({ counters: {} })),
43
+ publishProfile: vi.fn(),
44
+ getProfileState: vi.fn(async () => null),
45
+ };
46
+ mocks.startNostrBus.mockResolvedValueOnce(bus as any);
47
+
48
+ const cleanup = (await nostrPlugin.gateway!.startAccount!(
49
+ createStartAccountContext({
50
+ account: {
51
+ accountId: "default",
52
+ enabled: true,
53
+ configured: true,
54
+ privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // pragma: allowlist secret
55
+ publicKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", // pragma: allowlist secret
56
+ relays: ["wss://relay.example.com"],
57
+ config: {},
58
+ },
59
+ abortSignal: new AbortController().signal,
60
+ }),
61
+ )) as { stop: () => void };
62
+
63
+ const cfg = {
64
+ channels: {
65
+ nostr: {
66
+ privateKey: "resolved-nostr-private-key", // pragma: allowlist secret
67
+ },
68
+ },
69
+ };
70
+ await nostrPlugin.outbound!.sendText!({
71
+ cfg: cfg as any,
72
+ to: "NPUB123",
73
+ text: "|a|b|",
74
+ accountId: "default",
75
+ });
76
+
77
+ expect(resolveMarkdownTableMode).toHaveBeenCalledWith({
78
+ cfg,
79
+ channel: "nostr",
80
+ accountId: "default",
81
+ });
82
+ expect(convertMarkdownTables).toHaveBeenCalledWith("|a|b|", "off");
83
+ expect(mocks.normalizePubkey).toHaveBeenCalledWith("NPUB123");
84
+ expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "converted:|a|b|");
85
+
86
+ cleanup.stop();
87
+ });
88
+ });
package/src/channel.ts CHANGED
@@ -4,8 +4,9 @@ import {
4
4
  createDefaultChannelRuntimeState,
5
5
  DEFAULT_ACCOUNT_ID,
6
6
  formatPairingApproveHint,
7
+ mapAllowFromEntries,
7
8
  type ChannelPlugin,
8
- } from "openclaw/plugin-sdk";
9
+ } from "openclaw/plugin-sdk/nostr";
9
10
  import type { NostrProfile } from "./config-schema.js";
10
11
  import { NostrConfigSchema } from "./config-schema.js";
11
12
  import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
@@ -56,9 +57,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
56
57
  publicKey: account.publicKey,
57
58
  }),
58
59
  resolveAllowFrom: ({ cfg, accountId }) =>
59
- (resolveNostrAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
60
- String(entry),
61
- ),
60
+ mapAllowFromEntries(resolveNostrAccount({ cfg, accountId }).config.allowFrom),
62
61
  formatAllowFrom: ({ allowFrom }) =>
63
62
  allowFrom
64
63
  .map((entry) => String(entry).trim())
@@ -135,7 +134,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
135
134
  outbound: {
136
135
  deliveryMode: "direct",
137
136
  textChunkLimit: 4000,
138
- sendText: async ({ to, text, accountId }) => {
137
+ sendText: async ({ cfg, to, text, accountId }) => {
139
138
  const core = getNostrRuntime();
140
139
  const aid = accountId ?? DEFAULT_ACCOUNT_ID;
141
140
  const bus = activeBuses.get(aid);
@@ -143,7 +142,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
143
142
  throw new Error(`Nostr bus not running for account ${aid}`);
144
143
  }
145
144
  const tableMode = core.channel.text.resolveMarkdownTableMode({
146
- cfg: core.config.loadConfig(),
145
+ cfg,
147
146
  channel: "nostr",
148
147
  accountId: aid,
149
148
  });
@@ -1,4 +1,4 @@
1
- import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk";
1
+ import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr";
2
2
  import { z } from "zod";
3
3
 
4
4
  const allowFromEntry = z.union([z.string(), z.number()]);
@@ -283,6 +283,36 @@ describe("nostr-profile-http", () => {
283
283
  expect(res._getStatusCode()).toBe(403);
284
284
  });
285
285
 
286
+ it("rejects profile mutation with cross-site sec-fetch-site header", async () => {
287
+ const ctx = createMockContext();
288
+ const handler = createNostrProfileHttpHandler(ctx);
289
+ const req = createMockRequest(
290
+ "PUT",
291
+ "/api/channels/nostr/default/profile",
292
+ { name: "attacker" },
293
+ { headers: { "sec-fetch-site": "cross-site" } },
294
+ );
295
+ const res = createMockResponse();
296
+
297
+ await handler(req, res);
298
+ expect(res._getStatusCode()).toBe(403);
299
+ });
300
+
301
+ it("rejects profile mutation when forwarded client ip is non-loopback", async () => {
302
+ const ctx = createMockContext();
303
+ const handler = createNostrProfileHttpHandler(ctx);
304
+ const req = createMockRequest(
305
+ "PUT",
306
+ "/api/channels/nostr/default/profile",
307
+ { name: "attacker" },
308
+ { headers: { "x-forwarded-for": "203.0.113.99, 127.0.0.1" } },
309
+ );
310
+ const res = createMockResponse();
311
+
312
+ await handler(req, res);
313
+ expect(res._getStatusCode()).toBe(403);
314
+ });
315
+
286
316
  it("rejects private IP in picture URL (SSRF protection)", async () => {
287
317
  await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg");
288
318
  });
@@ -431,6 +461,21 @@ describe("nostr-profile-http", () => {
431
461
  expect(res._getStatusCode()).toBe(403);
432
462
  });
433
463
 
464
+ it("rejects import mutation when x-real-ip is non-loopback", async () => {
465
+ const ctx = createMockContext();
466
+ const handler = createNostrProfileHttpHandler(ctx);
467
+ const req = createMockRequest(
468
+ "POST",
469
+ "/api/channels/nostr/default/profile/import",
470
+ {},
471
+ { headers: { "x-real-ip": "198.51.100.55" } },
472
+ );
473
+ const res = createMockResponse();
474
+
475
+ await handler(req, res);
476
+ expect(res._getStatusCode()).toBe(403);
477
+ });
478
+
434
479
  it("auto-merges when requested", async () => {
435
480
  const ctx = createMockContext({
436
481
  getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }),
@@ -13,7 +13,7 @@ import {
13
13
  isBlockedHostnameOrIp,
14
14
  readJsonBodyWithLimit,
15
15
  requestBodyErrorToText,
16
- } from "openclaw/plugin-sdk";
16
+ } from "openclaw/plugin-sdk/nostr";
17
17
  import { z } from "zod";
18
18
  import { publishNostrProfile, getNostrProfileState } from "./channel.js";
19
19
  import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
@@ -224,6 +224,51 @@ function isLoopbackOriginLike(value: string): boolean {
224
224
  }
225
225
  }
226
226
 
227
+ function firstHeaderValue(value: string | string[] | undefined): string | undefined {
228
+ if (Array.isArray(value)) {
229
+ return value[0];
230
+ }
231
+ return typeof value === "string" ? value : undefined;
232
+ }
233
+
234
+ function normalizeIpCandidate(raw: string): string {
235
+ const unquoted = raw.trim().replace(/^"|"$/g, "");
236
+ const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/);
237
+ if (bracketedWithOptionalPort) {
238
+ return bracketedWithOptionalPort[1] ?? "";
239
+ }
240
+ const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/);
241
+ if (ipv4WithPort) {
242
+ return ipv4WithPort[1] ?? "";
243
+ }
244
+ return unquoted;
245
+ }
246
+
247
+ function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean {
248
+ const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]);
249
+ if (forwardedFor) {
250
+ for (const hop of forwardedFor.split(",")) {
251
+ const candidate = normalizeIpCandidate(hop);
252
+ if (!candidate) {
253
+ continue;
254
+ }
255
+ if (!isLoopbackRemoteAddress(candidate)) {
256
+ return true;
257
+ }
258
+ }
259
+ }
260
+
261
+ const realIp = firstHeaderValue(req.headers["x-real-ip"]);
262
+ if (realIp) {
263
+ const candidate = normalizeIpCandidate(realIp);
264
+ if (candidate && !isLoopbackRemoteAddress(candidate)) {
265
+ return true;
266
+ }
267
+ }
268
+
269
+ return false;
270
+ }
271
+
227
272
  function enforceLoopbackMutationGuards(
228
273
  ctx: NostrProfileHttpContext,
229
274
  req: IncomingMessage,
@@ -237,15 +282,30 @@ function enforceLoopbackMutationGuards(
237
282
  return false;
238
283
  }
239
284
 
285
+ // If a proxy exposes client-origin headers showing a non-loopback client,
286
+ // treat this as a remote request and deny mutation.
287
+ if (hasNonLoopbackForwardedClient(req)) {
288
+ ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
289
+ sendJson(res, 403, { ok: false, error: "Forbidden" });
290
+ return false;
291
+ }
292
+
293
+ const secFetchSite = firstHeaderValue(req.headers["sec-fetch-site"])?.trim().toLowerCase();
294
+ if (secFetchSite === "cross-site") {
295
+ ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
296
+ sendJson(res, 403, { ok: false, error: "Forbidden" });
297
+ return false;
298
+ }
299
+
240
300
  // CSRF guard: browsers send Origin/Referer on cross-site requests.
241
- const origin = req.headers.origin;
301
+ const origin = firstHeaderValue(req.headers.origin);
242
302
  if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
243
303
  ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
244
304
  sendJson(res, 403, { ok: false, error: "Forbidden" });
245
305
  return false;
246
306
  }
247
307
 
248
- const referer = req.headers.referer ?? req.headers.referrer;
308
+ const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer);
249
309
  if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
250
310
  ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
251
311
  sendJson(res, 403, { ok: false, error: "Forbidden" });
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import type { PluginRuntime } from "openclaw/plugin-sdk";
4
+ import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
5
5
  import { describe, expect, it } from "vitest";
6
6
  import {
7
7
  readNostrBusState,
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4
 
package/src/types.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
1
  import {
3
2
  DEFAULT_ACCOUNT_ID,
4
3
  normalizeAccountId,
5
4
  normalizeOptionalAccountId,
6
5
  } from "openclaw/plugin-sdk/account-id";
6
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr";
7
7
  import type { NostrProfile } from "./config-schema.js";
8
8
  import { getPublicKeyFromPrivate } from "./nostr-bus.js";
9
9
  import { DEFAULT_RELAYS } from "./nostr-bus.js";