@openclaw/nostr 2026.1.29

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.
@@ -0,0 +1,479 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getPublicKey } from "nostr-tools";
3
+ import {
4
+ createProfileEvent,
5
+ profileToContent,
6
+ validateProfile,
7
+ sanitizeProfileForDisplay,
8
+ } from "./nostr-profile.js";
9
+ import type { NostrProfile } from "./config-schema.js";
10
+
11
+ // Test private key
12
+ const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
13
+ const TEST_SK = new Uint8Array(
14
+ TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
15
+ );
16
+
17
+ // ============================================================================
18
+ // Unicode Attack Vectors
19
+ // ============================================================================
20
+
21
+ describe("profile unicode attacks", () => {
22
+ describe("zero-width characters", () => {
23
+ it("handles zero-width space in name", () => {
24
+ const profile: NostrProfile = {
25
+ name: "test\u200Buser", // Zero-width space
26
+ };
27
+ const result = validateProfile(profile);
28
+ expect(result.valid).toBe(true);
29
+ // The character should be preserved (not stripped)
30
+ expect(result.profile?.name).toBe("test\u200Buser");
31
+ });
32
+
33
+ it("handles zero-width joiner in name", () => {
34
+ const profile: NostrProfile = {
35
+ name: "test\u200Duser", // Zero-width joiner
36
+ };
37
+ const result = validateProfile(profile);
38
+ expect(result.valid).toBe(true);
39
+ });
40
+
41
+ it("handles zero-width non-joiner in about", () => {
42
+ const profile: NostrProfile = {
43
+ about: "test\u200Cabout", // Zero-width non-joiner
44
+ };
45
+ const content = profileToContent(profile);
46
+ expect(content.about).toBe("test\u200Cabout");
47
+ });
48
+ });
49
+
50
+ describe("RTL override attacks", () => {
51
+ it("handles RTL override in name", () => {
52
+ const profile: NostrProfile = {
53
+ name: "\u202Eevil\u202C", // Right-to-left override + pop direction
54
+ };
55
+ const result = validateProfile(profile);
56
+ expect(result.valid).toBe(true);
57
+
58
+ // UI should escape or handle this
59
+ const sanitized = sanitizeProfileForDisplay(result.profile!);
60
+ expect(sanitized.name).toBeDefined();
61
+ });
62
+
63
+ it("handles bidi embedding in about", () => {
64
+ const profile: NostrProfile = {
65
+ about: "Normal \u202Breversed\u202C text", // LTR embedding
66
+ };
67
+ const result = validateProfile(profile);
68
+ expect(result.valid).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("homoglyph attacks", () => {
73
+ it("handles Cyrillic homoglyphs", () => {
74
+ const profile: NostrProfile = {
75
+ // Cyrillic 'а' (U+0430) looks like Latin 'a'
76
+ name: "\u0430dmin", // Fake "admin"
77
+ };
78
+ const result = validateProfile(profile);
79
+ expect(result.valid).toBe(true);
80
+ // Profile is accepted but apps should be aware
81
+ });
82
+
83
+ it("handles Greek homoglyphs", () => {
84
+ const profile: NostrProfile = {
85
+ // Greek 'ο' (U+03BF) looks like Latin 'o'
86
+ name: "b\u03BFt", // Looks like "bot"
87
+ };
88
+ const result = validateProfile(profile);
89
+ expect(result.valid).toBe(true);
90
+ });
91
+ });
92
+
93
+ describe("combining characters", () => {
94
+ it("handles combining diacritics", () => {
95
+ const profile: NostrProfile = {
96
+ name: "cafe\u0301", // 'e' + combining acute = 'é'
97
+ };
98
+ const result = validateProfile(profile);
99
+ expect(result.valid).toBe(true);
100
+ expect(result.profile?.name).toBe("cafe\u0301");
101
+ });
102
+
103
+ it("handles excessive combining characters (Zalgo text)", () => {
104
+ const zalgo =
105
+ "t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
106
+ const profile: NostrProfile = {
107
+ name: zalgo.slice(0, 256), // Truncate to fit limit
108
+ };
109
+ const result = validateProfile(profile);
110
+ // Should be valid but may look weird
111
+ expect(result.valid).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("CJK and other scripts", () => {
116
+ it("handles Chinese characters", () => {
117
+ const profile: NostrProfile = {
118
+ name: "中文用户",
119
+ about: "我是一个机器人",
120
+ };
121
+ const result = validateProfile(profile);
122
+ expect(result.valid).toBe(true);
123
+ });
124
+
125
+ it("handles Japanese hiragana and katakana", () => {
126
+ const profile: NostrProfile = {
127
+ name: "ボット",
128
+ about: "これはテストです",
129
+ };
130
+ const result = validateProfile(profile);
131
+ expect(result.valid).toBe(true);
132
+ });
133
+
134
+ it("handles Korean characters", () => {
135
+ const profile: NostrProfile = {
136
+ name: "한국어사용자",
137
+ };
138
+ const result = validateProfile(profile);
139
+ expect(result.valid).toBe(true);
140
+ });
141
+
142
+ it("handles Arabic text", () => {
143
+ const profile: NostrProfile = {
144
+ name: "مستخدم",
145
+ about: "مرحبا بالعالم",
146
+ };
147
+ const result = validateProfile(profile);
148
+ expect(result.valid).toBe(true);
149
+ });
150
+
151
+ it("handles Hebrew text", () => {
152
+ const profile: NostrProfile = {
153
+ name: "משתמש",
154
+ };
155
+ const result = validateProfile(profile);
156
+ expect(result.valid).toBe(true);
157
+ });
158
+
159
+ it("handles Thai text", () => {
160
+ const profile: NostrProfile = {
161
+ name: "ผู้ใช้",
162
+ };
163
+ const result = validateProfile(profile);
164
+ expect(result.valid).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe("emoji edge cases", () => {
169
+ it("handles emoji sequences (ZWJ)", () => {
170
+ const profile: NostrProfile = {
171
+ name: "👨‍👩‍👧‍👦", // Family emoji using ZWJ
172
+ };
173
+ const result = validateProfile(profile);
174
+ expect(result.valid).toBe(true);
175
+ });
176
+
177
+ it("handles flag emojis", () => {
178
+ const profile: NostrProfile = {
179
+ name: "🇺🇸🇯🇵🇬🇧",
180
+ };
181
+ const result = validateProfile(profile);
182
+ expect(result.valid).toBe(true);
183
+ });
184
+
185
+ it("handles skin tone modifiers", () => {
186
+ const profile: NostrProfile = {
187
+ name: "👋🏻👋🏽👋🏿",
188
+ };
189
+ const result = validateProfile(profile);
190
+ expect(result.valid).toBe(true);
191
+ });
192
+ });
193
+ });
194
+
195
+ // ============================================================================
196
+ // XSS Attack Vectors
197
+ // ============================================================================
198
+
199
+ describe("profile XSS attacks", () => {
200
+ describe("script injection", () => {
201
+ it("escapes script tags", () => {
202
+ const profile: NostrProfile = {
203
+ name: '<script>alert("xss")</script>',
204
+ };
205
+ const sanitized = sanitizeProfileForDisplay(profile);
206
+ expect(sanitized.name).not.toContain("<script>");
207
+ expect(sanitized.name).toContain("&lt;script&gt;");
208
+ });
209
+
210
+ it("escapes nested script tags", () => {
211
+ const profile: NostrProfile = {
212
+ about: '<<script>script>alert("xss")<</script>/script>',
213
+ };
214
+ const sanitized = sanitizeProfileForDisplay(profile);
215
+ expect(sanitized.about).not.toContain("<script>");
216
+ });
217
+ });
218
+
219
+ describe("event handler injection", () => {
220
+ it("escapes img onerror", () => {
221
+ const profile: NostrProfile = {
222
+ about: '<img src="x" onerror="alert(1)">',
223
+ };
224
+ const sanitized = sanitizeProfileForDisplay(profile);
225
+ expect(sanitized.about).toContain("&lt;img");
226
+ expect(sanitized.about).not.toContain('onerror="alert');
227
+ });
228
+
229
+ it("escapes svg onload", () => {
230
+ const profile: NostrProfile = {
231
+ about: '<svg onload="alert(1)">',
232
+ };
233
+ const sanitized = sanitizeProfileForDisplay(profile);
234
+ expect(sanitized.about).toContain("&lt;svg");
235
+ });
236
+
237
+ it("escapes body onload", () => {
238
+ const profile: NostrProfile = {
239
+ about: '<body onload="alert(1)">',
240
+ };
241
+ const sanitized = sanitizeProfileForDisplay(profile);
242
+ expect(sanitized.about).toContain("&lt;body");
243
+ });
244
+ });
245
+
246
+ describe("URL-based attacks", () => {
247
+ it("rejects javascript: URL in picture", () => {
248
+ const profile = {
249
+ picture: "javascript:alert('xss')",
250
+ };
251
+ const result = validateProfile(profile);
252
+ expect(result.valid).toBe(false);
253
+ });
254
+
255
+ it("rejects javascript: URL with encoding", () => {
256
+ const profile = {
257
+ picture: "java&#115;cript:alert('xss')",
258
+ };
259
+ const result = validateProfile(profile);
260
+ expect(result.valid).toBe(false);
261
+ });
262
+
263
+ it("rejects data: URL", () => {
264
+ const profile = {
265
+ picture: "data:text/html,<script>alert('xss')</script>",
266
+ };
267
+ const result = validateProfile(profile);
268
+ expect(result.valid).toBe(false);
269
+ });
270
+
271
+ it("rejects vbscript: URL", () => {
272
+ const profile = {
273
+ website: "vbscript:msgbox('xss')",
274
+ };
275
+ const result = validateProfile(profile);
276
+ expect(result.valid).toBe(false);
277
+ });
278
+
279
+ it("rejects file: URL", () => {
280
+ const profile = {
281
+ picture: "file:///etc/passwd",
282
+ };
283
+ const result = validateProfile(profile);
284
+ expect(result.valid).toBe(false);
285
+ });
286
+ });
287
+
288
+ describe("HTML attribute injection", () => {
289
+ it("escapes double quotes in fields", () => {
290
+ const profile: NostrProfile = {
291
+ name: '" onclick="alert(1)" data-x="',
292
+ };
293
+ const sanitized = sanitizeProfileForDisplay(profile);
294
+ expect(sanitized.name).toContain("&quot;");
295
+ expect(sanitized.name).not.toContain('onclick="alert');
296
+ });
297
+
298
+ it("escapes single quotes in fields", () => {
299
+ const profile: NostrProfile = {
300
+ name: "' onclick='alert(1)' data-x='",
301
+ };
302
+ const sanitized = sanitizeProfileForDisplay(profile);
303
+ expect(sanitized.name).toContain("&#039;");
304
+ });
305
+ });
306
+
307
+ describe("CSS injection", () => {
308
+ it("escapes style tags", () => {
309
+ const profile: NostrProfile = {
310
+ about: '<style>body{background:url("javascript:alert(1)")}</style>',
311
+ };
312
+ const sanitized = sanitizeProfileForDisplay(profile);
313
+ expect(sanitized.about).toContain("&lt;style&gt;");
314
+ });
315
+ });
316
+ });
317
+
318
+ // ============================================================================
319
+ // Length Boundary Tests
320
+ // ============================================================================
321
+
322
+ describe("profile length boundaries", () => {
323
+ describe("name field (max 256)", () => {
324
+ it("accepts exactly 256 characters", () => {
325
+ const result = validateProfile({ name: "a".repeat(256) });
326
+ expect(result.valid).toBe(true);
327
+ });
328
+
329
+ it("rejects 257 characters", () => {
330
+ const result = validateProfile({ name: "a".repeat(257) });
331
+ expect(result.valid).toBe(false);
332
+ });
333
+
334
+ it("accepts empty string", () => {
335
+ const result = validateProfile({ name: "" });
336
+ expect(result.valid).toBe(true);
337
+ });
338
+ });
339
+
340
+ describe("displayName field (max 256)", () => {
341
+ it("accepts exactly 256 characters", () => {
342
+ const result = validateProfile({ displayName: "b".repeat(256) });
343
+ expect(result.valid).toBe(true);
344
+ });
345
+
346
+ it("rejects 257 characters", () => {
347
+ const result = validateProfile({ displayName: "b".repeat(257) });
348
+ expect(result.valid).toBe(false);
349
+ });
350
+ });
351
+
352
+ describe("about field (max 2000)", () => {
353
+ it("accepts exactly 2000 characters", () => {
354
+ const result = validateProfile({ about: "c".repeat(2000) });
355
+ expect(result.valid).toBe(true);
356
+ });
357
+
358
+ it("rejects 2001 characters", () => {
359
+ const result = validateProfile({ about: "c".repeat(2001) });
360
+ expect(result.valid).toBe(false);
361
+ });
362
+ });
363
+
364
+ describe("URL fields", () => {
365
+ it("accepts long valid HTTPS URLs", () => {
366
+ const longPath = "a".repeat(1000);
367
+ const result = validateProfile({
368
+ picture: `https://example.com/${longPath}.png`,
369
+ });
370
+ expect(result.valid).toBe(true);
371
+ });
372
+
373
+ it("rejects invalid URL format", () => {
374
+ const result = validateProfile({
375
+ picture: "not-a-url",
376
+ });
377
+ expect(result.valid).toBe(false);
378
+ });
379
+
380
+ it("rejects URL without protocol", () => {
381
+ const result = validateProfile({
382
+ picture: "example.com/pic.png",
383
+ });
384
+ expect(result.valid).toBe(false);
385
+ });
386
+ });
387
+ });
388
+
389
+ // ============================================================================
390
+ // Type Confusion Tests
391
+ // ============================================================================
392
+
393
+ describe("profile type confusion", () => {
394
+ it("rejects number as name", () => {
395
+ const result = validateProfile({ name: 123 as unknown as string });
396
+ expect(result.valid).toBe(false);
397
+ });
398
+
399
+ it("rejects array as about", () => {
400
+ const result = validateProfile({ about: ["hello"] as unknown as string });
401
+ expect(result.valid).toBe(false);
402
+ });
403
+
404
+ it("rejects object as picture", () => {
405
+ const result = validateProfile({ picture: { url: "https://example.com" } as unknown as string });
406
+ expect(result.valid).toBe(false);
407
+ });
408
+
409
+ it("rejects null as name", () => {
410
+ const result = validateProfile({ name: null as unknown as string });
411
+ expect(result.valid).toBe(false);
412
+ });
413
+
414
+ it("rejects boolean as about", () => {
415
+ const result = validateProfile({ about: true as unknown as string });
416
+ expect(result.valid).toBe(false);
417
+ });
418
+
419
+ it("rejects function as name", () => {
420
+ const result = validateProfile({ name: (() => "test") as unknown as string });
421
+ expect(result.valid).toBe(false);
422
+ });
423
+
424
+ it("handles prototype pollution attempt", () => {
425
+ const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown;
426
+ const result = validateProfile(malicious);
427
+ // Should not pollute Object.prototype
428
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
429
+ });
430
+ });
431
+
432
+ // ============================================================================
433
+ // Event Creation Edge Cases
434
+ // ============================================================================
435
+
436
+ describe("event creation edge cases", () => {
437
+ it("handles profile with all fields at max length", () => {
438
+ const profile: NostrProfile = {
439
+ name: "a".repeat(256),
440
+ displayName: "b".repeat(256),
441
+ about: "c".repeat(2000),
442
+ nip05: "d".repeat(200) + "@example.com",
443
+ lud16: "e".repeat(200) + "@example.com",
444
+ };
445
+
446
+ const event = createProfileEvent(TEST_SK, profile);
447
+ expect(event.kind).toBe(0);
448
+
449
+ // Content should be parseable JSON
450
+ expect(() => JSON.parse(event.content)).not.toThrow();
451
+ });
452
+
453
+ it("handles rapid sequential events with monotonic timestamps", () => {
454
+ const profile: NostrProfile = { name: "rapid" };
455
+
456
+ // Create events in quick succession
457
+ let lastTimestamp = 0;
458
+ for (let i = 0; i < 100; i++) {
459
+ const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
460
+ expect(event.created_at).toBeGreaterThan(lastTimestamp);
461
+ lastTimestamp = event.created_at;
462
+ }
463
+ });
464
+
465
+ it("handles JSON special characters in content", () => {
466
+ const profile: NostrProfile = {
467
+ name: 'test"user',
468
+ about: "line1\nline2\ttab\\backslash",
469
+ };
470
+
471
+ const event = createProfileEvent(TEST_SK, profile);
472
+ const parsed = JSON.parse(event.content) as { name: string; about: string };
473
+
474
+ expect(parsed.name).toBe('test"user');
475
+ expect(parsed.about).toContain("\n");
476
+ expect(parsed.about).toContain("\t");
477
+ expect(parsed.about).toContain("\\");
478
+ });
479
+ });