@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 +12 -0
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/channel.outbound.test.ts +88 -0
- package/src/channel.ts +5 -6
- package/src/config-schema.ts +1 -1
- package/src/nostr-profile-http.test.ts +45 -0
- package/src/nostr-profile-http.ts +63 -3
- package/src/nostr-state-store.test.ts +1 -1
- package/src/runtime.ts +1 -1
- package/src/types.ts +1 -1
package/CHANGELOG.md
CHANGED
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
|
@@ -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
|
|
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
|
|
145
|
+
cfg,
|
|
147
146
|
channel: "nostr",
|
|
148
147
|
accountId: aid,
|
|
149
148
|
});
|
package/src/config-schema.ts
CHANGED
|
@@ -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
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";
|