@openclaw/nostr 2026.5.2 → 2026.5.3-beta.2

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.
Files changed (59) hide show
  1. package/dist/api.js +532 -0
  2. package/dist/channel-DfEqBtUh.js +1466 -0
  3. package/dist/channel-plugin-api.js +2 -0
  4. package/dist/config-schema-DIk4jlBg.js +64 -0
  5. package/dist/default-relays-DLwdWOTu.js +4 -0
  6. package/dist/inbound-direct-dm-runtime-22bZWcIW.js +2 -0
  7. package/dist/index.js +84 -0
  8. package/dist/runtime-api.js +2 -0
  9. package/dist/setup-api.js +2 -0
  10. package/dist/setup-entry.js +11 -0
  11. package/dist/setup-plugin-api.js +165 -0
  12. package/dist/setup-surface-DxAaUTyC.js +336 -0
  13. package/dist/test-api.js +2 -0
  14. package/package.json +15 -6
  15. package/api.ts +0 -10
  16. package/channel-plugin-api.ts +0 -1
  17. package/index.ts +0 -97
  18. package/runtime-api.ts +0 -6
  19. package/setup-api.ts +0 -1
  20. package/setup-entry.ts +0 -9
  21. package/setup-plugin-api.ts +0 -3
  22. package/src/channel-api.ts +0 -15
  23. package/src/channel.inbound.test.ts +0 -176
  24. package/src/channel.outbound.test.ts +0 -128
  25. package/src/channel.setup.ts +0 -231
  26. package/src/channel.test.ts +0 -519
  27. package/src/channel.ts +0 -207
  28. package/src/config-schema.ts +0 -98
  29. package/src/default-relays.ts +0 -1
  30. package/src/gateway.ts +0 -302
  31. package/src/inbound-direct-dm-runtime.ts +0 -1
  32. package/src/metrics.ts +0 -458
  33. package/src/nostr-bus.fuzz.test.ts +0 -360
  34. package/src/nostr-bus.inbound.test.ts +0 -526
  35. package/src/nostr-bus.integration.test.ts +0 -472
  36. package/src/nostr-bus.test.ts +0 -190
  37. package/src/nostr-bus.ts +0 -789
  38. package/src/nostr-key-utils.ts +0 -94
  39. package/src/nostr-profile-core.ts +0 -134
  40. package/src/nostr-profile-http-runtime.ts +0 -6
  41. package/src/nostr-profile-http.test.ts +0 -632
  42. package/src/nostr-profile-http.ts +0 -594
  43. package/src/nostr-profile-import.test.ts +0 -119
  44. package/src/nostr-profile-import.ts +0 -262
  45. package/src/nostr-profile-url-safety.ts +0 -21
  46. package/src/nostr-profile.fuzz.test.ts +0 -430
  47. package/src/nostr-profile.test.ts +0 -412
  48. package/src/nostr-profile.ts +0 -144
  49. package/src/nostr-state-store.test.ts +0 -237
  50. package/src/nostr-state-store.ts +0 -223
  51. package/src/runtime.ts +0 -9
  52. package/src/seen-tracker.ts +0 -289
  53. package/src/session-route.ts +0 -25
  54. package/src/setup-surface.ts +0 -265
  55. package/src/test-fixtures.ts +0 -45
  56. package/src/types.ts +0 -117
  57. package/test/setup.ts +0 -5
  58. package/test-api.ts +0 -1
  59. package/tsconfig.json +0 -16
@@ -1,262 +0,0 @@
1
- /**
2
- * Nostr Profile Import
3
- *
4
- * Fetches and verifies kind:0 profile events from relays.
5
- * Used to import existing profiles before editing.
6
- */
7
-
8
- import { SimplePool, verifyEvent, type Event } from "nostr-tools";
9
- import type { NostrProfile } from "./config-schema.js";
10
- import { validateUrlSafety } from "./nostr-profile-url-safety.js";
11
- import { contentToProfile, type ProfileContent } from "./nostr-profile.js";
12
-
13
- // ============================================================================
14
- // Types
15
- // ============================================================================
16
-
17
- interface ProfileImportResult {
18
- /** Whether the import was successful */
19
- ok: boolean;
20
- /** The imported profile (if found and valid) */
21
- profile?: NostrProfile;
22
- /** The raw event (for advanced users) */
23
- event?: {
24
- id: string;
25
- pubkey: string;
26
- created_at: number;
27
- };
28
- /** Error message if import failed */
29
- error?: string;
30
- /** Which relays responded */
31
- relaysQueried: string[];
32
- /** Which relay provided the winning event */
33
- sourceRelay?: string;
34
- }
35
-
36
- interface ProfileImportOptions {
37
- /** The public key to fetch profile for */
38
- pubkey: string;
39
- /** Relay URLs to query */
40
- relays: string[];
41
- /** Timeout per relay in milliseconds (default: 5000) */
42
- timeoutMs?: number;
43
- }
44
-
45
- // ============================================================================
46
- // Constants
47
- // ============================================================================
48
-
49
- const DEFAULT_TIMEOUT_MS = 5000;
50
-
51
- // ============================================================================
52
- // Profile Import
53
- // ============================================================================
54
-
55
- /**
56
- * Sanitize URLs in an imported profile to prevent SSRF attacks.
57
- * Removes any URLs that don't pass SSRF validation.
58
- */
59
- function sanitizeProfileUrls(profile: NostrProfile): NostrProfile {
60
- const result = { ...profile };
61
- const urlFields = ["picture", "banner", "website"] as const;
62
-
63
- for (const field of urlFields) {
64
- const value = result[field];
65
- if (value && typeof value === "string") {
66
- const validation = validateUrlSafety(value);
67
- if (!validation.ok) {
68
- // Remove unsafe URL
69
- delete result[field];
70
- }
71
- }
72
- }
73
-
74
- return result;
75
- }
76
-
77
- /**
78
- * Fetch the latest kind:0 profile event for a pubkey from relays.
79
- *
80
- * - Queries all relays in parallel
81
- * - Takes the event with the highest created_at
82
- * - Verifies the event signature
83
- * - Parses and returns the profile
84
- */
85
- export async function importProfileFromRelays(
86
- opts: ProfileImportOptions,
87
- ): Promise<ProfileImportResult> {
88
- const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
89
-
90
- if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) {
91
- return {
92
- ok: false,
93
- error: "Invalid pubkey format (must be 64 hex characters)",
94
- relaysQueried: [],
95
- };
96
- }
97
-
98
- if (relays.length === 0) {
99
- return {
100
- ok: false,
101
- error: "No relays configured",
102
- relaysQueried: [],
103
- };
104
- }
105
-
106
- const pool = new SimplePool();
107
- const relaysQueried: string[] = [];
108
-
109
- try {
110
- // Query all relays for kind:0 events from this pubkey
111
- const events: Array<{ event: Event; relay: string }> = [];
112
-
113
- // Create timeout promise
114
- const timeoutPromise = new Promise<void>((resolve) => {
115
- setTimeout(resolve, timeoutMs);
116
- });
117
-
118
- // Create subscription promise
119
- const subscriptionPromise = new Promise<void>((resolve) => {
120
- let completed = 0;
121
-
122
- for (const relay of relays) {
123
- relaysQueried.push(relay);
124
-
125
- const sub = pool.subscribeMany(
126
- [relay],
127
- [
128
- {
129
- kinds: [0],
130
- authors: [pubkey],
131
- limit: 1,
132
- },
133
- ] as unknown as Parameters<typeof pool.subscribeMany>[1],
134
- {
135
- onevent(event) {
136
- events.push({ event, relay });
137
- },
138
- oneose() {
139
- completed++;
140
- if (completed >= relays.length) {
141
- resolve();
142
- }
143
- },
144
- onclose() {
145
- completed++;
146
- if (completed >= relays.length) {
147
- resolve();
148
- }
149
- },
150
- },
151
- );
152
-
153
- // Clean up subscription after timeout
154
- setTimeout(() => {
155
- sub.close();
156
- }, timeoutMs);
157
- }
158
- });
159
-
160
- // Wait for either all relays to respond or timeout
161
- await Promise.race([subscriptionPromise, timeoutPromise]);
162
-
163
- // No events found
164
- if (events.length === 0) {
165
- return {
166
- ok: false,
167
- error: "No profile found on any relay",
168
- relaysQueried,
169
- };
170
- }
171
-
172
- // Find the event with the highest created_at (newest wins for replaceable events)
173
- let bestEvent: { event: Event; relay: string } | null = null;
174
- for (const item of events) {
175
- if (!bestEvent || item.event.created_at > bestEvent.event.created_at) {
176
- bestEvent = item;
177
- }
178
- }
179
-
180
- if (!bestEvent) {
181
- return {
182
- ok: false,
183
- error: "No valid profile event found",
184
- relaysQueried,
185
- };
186
- }
187
-
188
- // Verify the event signature
189
- const isValid = verifyEvent(bestEvent.event);
190
- if (!isValid) {
191
- return {
192
- ok: false,
193
- error: "Profile event has invalid signature",
194
- relaysQueried,
195
- sourceRelay: bestEvent.relay,
196
- };
197
- }
198
-
199
- // Parse the profile content
200
- let content: ProfileContent;
201
- try {
202
- content = JSON.parse(bestEvent.event.content) as ProfileContent;
203
- } catch {
204
- return {
205
- ok: false,
206
- error: "Profile event has invalid JSON content",
207
- relaysQueried,
208
- sourceRelay: bestEvent.relay,
209
- };
210
- }
211
-
212
- // Convert to our profile format
213
- const profile = contentToProfile(content);
214
-
215
- // Sanitize URLs from imported profile to prevent SSRF when auto-merging
216
- const sanitizedProfile = sanitizeProfileUrls(profile);
217
-
218
- return {
219
- ok: true,
220
- profile: sanitizedProfile,
221
- event: {
222
- id: bestEvent.event.id,
223
- pubkey: bestEvent.event.pubkey,
224
- created_at: bestEvent.event.created_at,
225
- },
226
- relaysQueried,
227
- sourceRelay: bestEvent.relay,
228
- };
229
- } finally {
230
- pool.close(relays);
231
- }
232
- }
233
-
234
- /**
235
- * Merge imported profile with local profile.
236
- *
237
- * Strategy:
238
- * - For each field, prefer local if set, otherwise use imported
239
- * - This preserves user customizations while filling in missing data
240
- */
241
- export function mergeProfiles(
242
- local: NostrProfile | undefined,
243
- imported: NostrProfile | undefined,
244
- ): NostrProfile {
245
- if (!imported) {
246
- return local ?? {};
247
- }
248
- if (!local) {
249
- return imported;
250
- }
251
-
252
- return {
253
- name: local.name ?? imported.name,
254
- displayName: local.displayName ?? imported.displayName,
255
- about: local.about ?? imported.about,
256
- picture: local.picture ?? imported.picture,
257
- banner: local.banner ?? imported.banner,
258
- website: local.website ?? imported.website,
259
- nip05: local.nip05 ?? imported.nip05,
260
- lud16: local.lud16 ?? imported.lud16,
261
- };
262
- }
@@ -1,21 +0,0 @@
1
- import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
2
-
3
- export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
4
- try {
5
- const url = new URL(urlStr);
6
-
7
- if (url.protocol !== "https:") {
8
- return { ok: false, error: "URL must use https:// protocol" };
9
- }
10
-
11
- const hostname = url.hostname.trim().toLowerCase();
12
-
13
- if (isBlockedHostnameOrIp(hostname)) {
14
- return { ok: false, error: "URL must not point to private/internal addresses" };
15
- }
16
-
17
- return { ok: true };
18
- } catch {
19
- return { ok: false, error: "Invalid URL format" };
20
- }
21
- }
@@ -1,430 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { NostrProfile } from "./config-schema.js";
3
- import {
4
- profileToContent,
5
- sanitizeProfileForDisplay,
6
- validateProfile,
7
- } from "./nostr-profile-core.js";
8
-
9
- // ============================================================================
10
- // Unicode Attack Vectors
11
- // ============================================================================
12
-
13
- describe("profile unicode attacks", () => {
14
- describe("zero-width characters", () => {
15
- it("handles zero-width space in name", () => {
16
- const profile: NostrProfile = {
17
- name: "test\u200Buser", // Zero-width space
18
- };
19
- const result = validateProfile(profile);
20
- expect(result.valid).toBe(true);
21
- // The character should be preserved (not stripped)
22
- expect(result.profile?.name).toBe("test\u200Buser");
23
- });
24
-
25
- it("handles zero-width joiner in name", () => {
26
- const profile: NostrProfile = {
27
- name: "test\u200Duser", // Zero-width joiner
28
- };
29
- const result = validateProfile(profile);
30
- expect(result.valid).toBe(true);
31
- });
32
-
33
- it("handles zero-width non-joiner in about", () => {
34
- const profile: NostrProfile = {
35
- about: "test\u200Cabout", // Zero-width non-joiner
36
- };
37
- const content = profileToContent(profile);
38
- expect(content.about).toBe("test\u200Cabout");
39
- });
40
- });
41
-
42
- describe("RTL override attacks", () => {
43
- it("handles RTL override in name", () => {
44
- const profile: NostrProfile = {
45
- name: "\u202Eevil\u202C", // Right-to-left override + pop direction
46
- };
47
- const result = validateProfile(profile);
48
- expect(result.valid).toBe(true);
49
- expect(result.profile).toBeDefined();
50
- if (!result.profile) {
51
- throw new Error("expected validated profile");
52
- }
53
-
54
- // UI should escape or handle this
55
- const sanitized = sanitizeProfileForDisplay(result.profile);
56
- expect(sanitized.name).toBeDefined();
57
- });
58
-
59
- it("handles bidi embedding in about", () => {
60
- const profile: NostrProfile = {
61
- about: "Normal \u202Breversed\u202C text", // LTR embedding
62
- };
63
- const result = validateProfile(profile);
64
- expect(result.valid).toBe(true);
65
- });
66
- });
67
-
68
- describe("homoglyph attacks", () => {
69
- it("handles Cyrillic homoglyphs", () => {
70
- const profile: NostrProfile = {
71
- // Cyrillic 'а' (U+0430) looks like Latin 'a'
72
- name: "\u0430dmin", // Fake "admin"
73
- };
74
- const result = validateProfile(profile);
75
- expect(result.valid).toBe(true);
76
- // Profile is accepted but apps should be aware
77
- });
78
-
79
- it("handles Greek homoglyphs", () => {
80
- const profile: NostrProfile = {
81
- // Greek 'ο' (U+03BF) looks like Latin 'o'
82
- name: "b\u03BFt", // Looks like "bot"
83
- };
84
- const result = validateProfile(profile);
85
- expect(result.valid).toBe(true);
86
- });
87
- });
88
-
89
- describe("combining characters", () => {
90
- it("handles combining diacritics", () => {
91
- const profile: NostrProfile = {
92
- name: "cafe\u0301", // 'e' + combining acute = 'é'
93
- };
94
- const result = validateProfile(profile);
95
- expect(result.valid).toBe(true);
96
- expect(result.profile?.name).toBe("cafe\u0301");
97
- });
98
-
99
- it("handles excessive combining characters (Zalgo text)", () => {
100
- // Keep the source small (faster transforms) while still exercising
101
- // "lots of combining marks" behavior.
102
- const marks = "\u0301\u0300\u0336\u034f\u035c\u0360";
103
- const zalgo = `t${marks.repeat(256)}e${marks.repeat(256)}s${marks.repeat(256)}t`;
104
- const profile: NostrProfile = {
105
- name: zalgo.slice(0, 256), // Truncate to fit limit
106
- };
107
- const result = validateProfile(profile);
108
- // Should be valid but may look weird
109
- expect(result.valid).toBe(true);
110
- });
111
- });
112
-
113
- describe("CJK and other scripts", () => {
114
- it("handles Chinese characters", () => {
115
- const profile: NostrProfile = {
116
- name: "中文用户",
117
- about: "我是一个机器人",
118
- };
119
- const result = validateProfile(profile);
120
- expect(result.valid).toBe(true);
121
- });
122
-
123
- it("handles Japanese hiragana and katakana", () => {
124
- const profile: NostrProfile = {
125
- name: "ボット",
126
- about: "これはテストです",
127
- };
128
- const result = validateProfile(profile);
129
- expect(result.valid).toBe(true);
130
- });
131
-
132
- it("handles Korean characters", () => {
133
- const profile: NostrProfile = {
134
- name: "한국어사용자",
135
- };
136
- const result = validateProfile(profile);
137
- expect(result.valid).toBe(true);
138
- });
139
-
140
- it("handles Arabic text", () => {
141
- const profile: NostrProfile = {
142
- name: "مستخدم",
143
- about: "مرحبا بالعالم",
144
- };
145
- const result = validateProfile(profile);
146
- expect(result.valid).toBe(true);
147
- });
148
-
149
- it("handles Hebrew text", () => {
150
- const profile: NostrProfile = {
151
- name: "משתמש",
152
- };
153
- const result = validateProfile(profile);
154
- expect(result.valid).toBe(true);
155
- });
156
-
157
- it("handles Thai text", () => {
158
- const profile: NostrProfile = {
159
- name: "ผู้ใช้",
160
- };
161
- const result = validateProfile(profile);
162
- expect(result.valid).toBe(true);
163
- });
164
- });
165
-
166
- describe("emoji edge cases", () => {
167
- it("handles emoji sequences (ZWJ)", () => {
168
- const profile: NostrProfile = {
169
- name: "👨‍👩‍👧‍👦", // Family emoji using ZWJ
170
- };
171
- const result = validateProfile(profile);
172
- expect(result.valid).toBe(true);
173
- });
174
-
175
- it("handles flag emojis", () => {
176
- const profile: NostrProfile = {
177
- name: "🇺🇸🇯🇵🇬🇧",
178
- };
179
- const result = validateProfile(profile);
180
- expect(result.valid).toBe(true);
181
- });
182
-
183
- it("handles skin tone modifiers", () => {
184
- const profile: NostrProfile = {
185
- name: "👋🏻👋🏽👋🏿",
186
- };
187
- const result = validateProfile(profile);
188
- expect(result.valid).toBe(true);
189
- });
190
- });
191
- });
192
-
193
- // ============================================================================
194
- // XSS Attack Vectors
195
- // ============================================================================
196
-
197
- describe("profile XSS attacks", () => {
198
- describe("script injection", () => {
199
- it("escapes script tags", () => {
200
- const profile: NostrProfile = {
201
- name: '<script>alert("xss")</script>',
202
- };
203
- const sanitized = sanitizeProfileForDisplay(profile);
204
- expect(sanitized.name).not.toContain("<script>");
205
- expect(sanitized.name).toContain("&lt;script&gt;");
206
- });
207
-
208
- it("escapes nested script tags", () => {
209
- const profile: NostrProfile = {
210
- about: '<<script>script>alert("xss")<</script>/script>',
211
- };
212
- const sanitized = sanitizeProfileForDisplay(profile);
213
- expect(sanitized.about).not.toContain("<script>");
214
- });
215
- });
216
-
217
- describe("event handler injection", () => {
218
- it("escapes img onerror", () => {
219
- const profile: NostrProfile = {
220
- about: '<img src="x" onerror="alert(1)">',
221
- };
222
- const sanitized = sanitizeProfileForDisplay(profile);
223
- expect(sanitized.about).toContain("&lt;img");
224
- expect(sanitized.about).not.toContain('onerror="alert');
225
- });
226
-
227
- it("escapes svg onload", () => {
228
- const profile: NostrProfile = {
229
- about: '<svg onload="alert(1)">',
230
- };
231
- const sanitized = sanitizeProfileForDisplay(profile);
232
- expect(sanitized.about).toContain("&lt;svg");
233
- });
234
-
235
- it("escapes body onload", () => {
236
- const profile: NostrProfile = {
237
- about: '<body onload="alert(1)">',
238
- };
239
- const sanitized = sanitizeProfileForDisplay(profile);
240
- expect(sanitized.about).toContain("&lt;body");
241
- });
242
- });
243
-
244
- describe("URL-based attacks", () => {
245
- it("rejects javascript: URL in picture", () => {
246
- const profile = {
247
- picture: "javascript:alert('xss')",
248
- };
249
- const result = validateProfile(profile);
250
- expect(result.valid).toBe(false);
251
- });
252
-
253
- it("rejects javascript: URL with encoding", () => {
254
- const profile = {
255
- picture: "java&#115;cript:alert('xss')",
256
- };
257
- const result = validateProfile(profile);
258
- expect(result.valid).toBe(false);
259
- });
260
-
261
- it("rejects data: URL", () => {
262
- const profile = {
263
- picture: "data:text/html,<script>alert('xss')</script>",
264
- };
265
- const result = validateProfile(profile);
266
- expect(result.valid).toBe(false);
267
- });
268
-
269
- it("rejects vbscript: URL", () => {
270
- const profile = {
271
- website: "vbscript:msgbox('xss')",
272
- };
273
- const result = validateProfile(profile);
274
- expect(result.valid).toBe(false);
275
- });
276
-
277
- it("rejects file: URL", () => {
278
- const profile = {
279
- picture: "file:///etc/passwd",
280
- };
281
- const result = validateProfile(profile);
282
- expect(result.valid).toBe(false);
283
- });
284
- });
285
-
286
- describe("HTML attribute injection", () => {
287
- it("escapes double quotes in fields", () => {
288
- const profile: NostrProfile = {
289
- name: '" onclick="alert(1)" data-x="',
290
- };
291
- const sanitized = sanitizeProfileForDisplay(profile);
292
- expect(sanitized.name).toContain("&quot;");
293
- expect(sanitized.name).not.toContain('onclick="alert');
294
- });
295
-
296
- it("escapes single quotes in fields", () => {
297
- const profile: NostrProfile = {
298
- name: "' onclick='alert(1)' data-x='",
299
- };
300
- const sanitized = sanitizeProfileForDisplay(profile);
301
- expect(sanitized.name).toContain("&#039;");
302
- });
303
- });
304
-
305
- describe("CSS injection", () => {
306
- it("escapes style tags", () => {
307
- const profile: NostrProfile = {
308
- about: '<style>body{background:url("javascript:alert(1)")}</style>',
309
- };
310
- const sanitized = sanitizeProfileForDisplay(profile);
311
- expect(sanitized.about).toContain("&lt;style&gt;");
312
- });
313
- });
314
- });
315
-
316
- // ============================================================================
317
- // Length Boundary Tests
318
- // ============================================================================
319
-
320
- describe("profile length boundaries", () => {
321
- describe("name field (max 256)", () => {
322
- it("accepts exactly 256 characters", () => {
323
- const result = validateProfile({ name: "a".repeat(256) });
324
- expect(result.valid).toBe(true);
325
- });
326
-
327
- it("rejects 257 characters", () => {
328
- const result = validateProfile({ name: "a".repeat(257) });
329
- expect(result.valid).toBe(false);
330
- });
331
-
332
- it("accepts empty string", () => {
333
- const result = validateProfile({ name: "" });
334
- expect(result.valid).toBe(true);
335
- });
336
- });
337
-
338
- describe("displayName field (max 256)", () => {
339
- it("accepts exactly 256 characters", () => {
340
- const result = validateProfile({ displayName: "b".repeat(256) });
341
- expect(result.valid).toBe(true);
342
- });
343
-
344
- it("rejects 257 characters", () => {
345
- const result = validateProfile({ displayName: "b".repeat(257) });
346
- expect(result.valid).toBe(false);
347
- });
348
- });
349
-
350
- describe("about field (max 2000)", () => {
351
- it("accepts exactly 2000 characters", () => {
352
- const result = validateProfile({ about: "c".repeat(2000) });
353
- expect(result.valid).toBe(true);
354
- });
355
-
356
- it("rejects 2001 characters", () => {
357
- const result = validateProfile({ about: "c".repeat(2001) });
358
- expect(result.valid).toBe(false);
359
- });
360
- });
361
-
362
- describe("URL fields", () => {
363
- it("accepts long valid HTTPS URLs", () => {
364
- const longPath = "a".repeat(1000);
365
- const result = validateProfile({
366
- picture: `https://example.com/${longPath}.png`,
367
- });
368
- expect(result.valid).toBe(true);
369
- });
370
-
371
- it("rejects invalid URL format", () => {
372
- const result = validateProfile({
373
- picture: "not-a-url",
374
- });
375
- expect(result.valid).toBe(false);
376
- });
377
-
378
- it("rejects URL without protocol", () => {
379
- const result = validateProfile({
380
- picture: "example.com/pic.png",
381
- });
382
- expect(result.valid).toBe(false);
383
- });
384
- });
385
- });
386
-
387
- // ============================================================================
388
- // Type Confusion Tests
389
- // ============================================================================
390
-
391
- describe("profile type confusion", () => {
392
- it("rejects number as name", () => {
393
- const result = validateProfile({ name: 123 as unknown as string });
394
- expect(result.valid).toBe(false);
395
- });
396
-
397
- it("rejects array as about", () => {
398
- const result = validateProfile({ about: ["hello"] as unknown as string });
399
- expect(result.valid).toBe(false);
400
- });
401
-
402
- it("rejects object as picture", () => {
403
- const result = validateProfile({
404
- picture: { url: "https://example.com" } as unknown as string,
405
- });
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
- validateProfile(malicious);
427
- // Should not pollute Object.prototype
428
- expect(({} as Record<string, unknown>).polluted).toBeUndefined();
429
- });
430
- });