480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { getPublicKey } from "nostr-tools";
|
||
import {
|
||
createProfileEvent,
|
||
profileToContent,
|
||
validateProfile,
|
||
sanitizeProfileForDisplay,
|
||
} from "./nostr-profile.js";
|
||
import type { NostrProfile } from "./config-schema.js";
|
||
|
||
// Test private key
|
||
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||
const TEST_SK = new Uint8Array(
|
||
TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
|
||
);
|
||
|
||
// ============================================================================
|
||
// Unicode Attack Vectors
|
||
// ============================================================================
|
||
|
||
describe("profile unicode attacks", () => {
|
||
describe("zero-width characters", () => {
|
||
it("handles zero-width space in name", () => {
|
||
const profile: NostrProfile = {
|
||
name: "test\u200Buser", // Zero-width space
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
// The character should be preserved (not stripped)
|
||
expect(result.profile?.name).toBe("test\u200Buser");
|
||
});
|
||
|
||
it("handles zero-width joiner in name", () => {
|
||
const profile: NostrProfile = {
|
||
name: "test\u200Duser", // Zero-width joiner
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles zero-width non-joiner in about", () => {
|
||
const profile: NostrProfile = {
|
||
about: "test\u200Cabout", // Zero-width non-joiner
|
||
};
|
||
const content = profileToContent(profile);
|
||
expect(content.about).toBe("test\u200Cabout");
|
||
});
|
||
});
|
||
|
||
describe("RTL override attacks", () => {
|
||
it("handles RTL override in name", () => {
|
||
const profile: NostrProfile = {
|
||
name: "\u202Eevil\u202C", // Right-to-left override + pop direction
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
|
||
// UI should escape or handle this
|
||
const sanitized = sanitizeProfileForDisplay(result.profile!);
|
||
expect(sanitized.name).toBeDefined();
|
||
});
|
||
|
||
it("handles bidi embedding in about", () => {
|
||
const profile: NostrProfile = {
|
||
about: "Normal \u202Breversed\u202C text", // LTR embedding
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("homoglyph attacks", () => {
|
||
it("handles Cyrillic homoglyphs", () => {
|
||
const profile: NostrProfile = {
|
||
// Cyrillic 'а' (U+0430) looks like Latin 'a'
|
||
name: "\u0430dmin", // Fake "admin"
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
// Profile is accepted but apps should be aware
|
||
});
|
||
|
||
it("handles Greek homoglyphs", () => {
|
||
const profile: NostrProfile = {
|
||
// Greek 'ο' (U+03BF) looks like Latin 'o'
|
||
name: "b\u03BFt", // Looks like "bot"
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("combining characters", () => {
|
||
it("handles combining diacritics", () => {
|
||
const profile: NostrProfile = {
|
||
name: "cafe\u0301", // 'e' + combining acute = 'é'
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
expect(result.profile?.name).toBe("cafe\u0301");
|
||
});
|
||
|
||
it("handles excessive combining characters (Zalgo text)", () => {
|
||
const zalgo =
|
||
"t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t";
|
||
const profile: NostrProfile = {
|
||
name: zalgo.slice(0, 256), // Truncate to fit limit
|
||
};
|
||
const result = validateProfile(profile);
|
||
// Should be valid but may look weird
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("CJK and other scripts", () => {
|
||
it("handles Chinese characters", () => {
|
||
const profile: NostrProfile = {
|
||
name: "中文用户",
|
||
about: "我是一个机器人",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles Japanese hiragana and katakana", () => {
|
||
const profile: NostrProfile = {
|
||
name: "ボット",
|
||
about: "これはテストです",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles Korean characters", () => {
|
||
const profile: NostrProfile = {
|
||
name: "한국어사용자",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles Arabic text", () => {
|
||
const profile: NostrProfile = {
|
||
name: "مستخدم",
|
||
about: "مرحبا بالعالم",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles Hebrew text", () => {
|
||
const profile: NostrProfile = {
|
||
name: "משתמש",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles Thai text", () => {
|
||
const profile: NostrProfile = {
|
||
name: "ผู้ใช้",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("emoji edge cases", () => {
|
||
it("handles emoji sequences (ZWJ)", () => {
|
||
const profile: NostrProfile = {
|
||
name: "👨👩👧👦", // Family emoji using ZWJ
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles flag emojis", () => {
|
||
const profile: NostrProfile = {
|
||
name: "🇺🇸🇯🇵🇬🇧",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("handles skin tone modifiers", () => {
|
||
const profile: NostrProfile = {
|
||
name: "👋🏻👋🏽👋🏿",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// XSS Attack Vectors
|
||
// ============================================================================
|
||
|
||
describe("profile XSS attacks", () => {
|
||
describe("script injection", () => {
|
||
it("escapes script tags", () => {
|
||
const profile: NostrProfile = {
|
||
name: '<script>alert("xss")</script>',
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.name).not.toContain("<script>");
|
||
expect(sanitized.name).toContain("<script>");
|
||
});
|
||
|
||
it("escapes nested script tags", () => {
|
||
const profile: NostrProfile = {
|
||
about: '<<script>script>alert("xss")<</script>/script>',
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.about).not.toContain("<script>");
|
||
});
|
||
});
|
||
|
||
describe("event handler injection", () => {
|
||
it("escapes img onerror", () => {
|
||
const profile: NostrProfile = {
|
||
about: '<img src="x" onerror="alert(1)">',
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.about).toContain("<img");
|
||
expect(sanitized.about).not.toContain('onerror="alert');
|
||
});
|
||
|
||
it("escapes svg onload", () => {
|
||
const profile: NostrProfile = {
|
||
about: '<svg onload="alert(1)">',
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.about).toContain("<svg");
|
||
});
|
||
|
||
it("escapes body onload", () => {
|
||
const profile: NostrProfile = {
|
||
about: '<body onload="alert(1)">',
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.about).toContain("<body");
|
||
});
|
||
});
|
||
|
||
describe("URL-based attacks", () => {
|
||
it("rejects javascript: URL in picture", () => {
|
||
const profile = {
|
||
picture: "javascript:alert('xss')",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects javascript: URL with encoding", () => {
|
||
const profile = {
|
||
picture: "javascript:alert('xss')",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects data: URL", () => {
|
||
const profile = {
|
||
picture: "data:text/html,<script>alert('xss')</script>",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects vbscript: URL", () => {
|
||
const profile = {
|
||
website: "vbscript:msgbox('xss')",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects file: URL", () => {
|
||
const profile = {
|
||
picture: "file:///etc/passwd",
|
||
};
|
||
const result = validateProfile(profile);
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("HTML attribute injection", () => {
|
||
it("escapes double quotes in fields", () => {
|
||
const profile: NostrProfile = {
|
||
name: '" onclick="alert(1)" data-x="',
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.name).toContain(""");
|
||
expect(sanitized.name).not.toContain('onclick="alert');
|
||
});
|
||
|
||
it("escapes single quotes in fields", () => {
|
||
const profile: NostrProfile = {
|
||
name: "' onclick='alert(1)' data-x='",
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.name).toContain("'");
|
||
});
|
||
});
|
||
|
||
describe("CSS injection", () => {
|
||
it("escapes style tags", () => {
|
||
const profile: NostrProfile = {
|
||
about: '<style>body{background:url("javascript:alert(1)")}</style>',
|
||
};
|
||
const sanitized = sanitizeProfileForDisplay(profile);
|
||
expect(sanitized.about).toContain("<style>");
|
||
});
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// Length Boundary Tests
|
||
// ============================================================================
|
||
|
||
describe("profile length boundaries", () => {
|
||
describe("name field (max 256)", () => {
|
||
it("accepts exactly 256 characters", () => {
|
||
const result = validateProfile({ name: "a".repeat(256) });
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("rejects 257 characters", () => {
|
||
const result = validateProfile({ name: "a".repeat(257) });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("accepts empty string", () => {
|
||
const result = validateProfile({ name: "" });
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("displayName field (max 256)", () => {
|
||
it("accepts exactly 256 characters", () => {
|
||
const result = validateProfile({ displayName: "b".repeat(256) });
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("rejects 257 characters", () => {
|
||
const result = validateProfile({ displayName: "b".repeat(257) });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("about field (max 2000)", () => {
|
||
it("accepts exactly 2000 characters", () => {
|
||
const result = validateProfile({ about: "c".repeat(2000) });
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("rejects 2001 characters", () => {
|
||
const result = validateProfile({ about: "c".repeat(2001) });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("URL fields", () => {
|
||
it("accepts long valid HTTPS URLs", () => {
|
||
const longPath = "a".repeat(1000);
|
||
const result = validateProfile({
|
||
picture: `https://example.com/${longPath}.png`,
|
||
});
|
||
expect(result.valid).toBe(true);
|
||
});
|
||
|
||
it("rejects invalid URL format", () => {
|
||
const result = validateProfile({
|
||
picture: "not-a-url",
|
||
});
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects URL without protocol", () => {
|
||
const result = validateProfile({
|
||
picture: "example.com/pic.png",
|
||
});
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// Type Confusion Tests
|
||
// ============================================================================
|
||
|
||
describe("profile type confusion", () => {
|
||
it("rejects number as name", () => {
|
||
const result = validateProfile({ name: 123 as unknown as string });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects array as about", () => {
|
||
const result = validateProfile({ about: ["hello"] as unknown as string });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects object as picture", () => {
|
||
const result = validateProfile({ picture: { url: "https://example.com" } as unknown as string });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects null as name", () => {
|
||
const result = validateProfile({ name: null as unknown as string });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects boolean as about", () => {
|
||
const result = validateProfile({ about: true as unknown as string });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("rejects function as name", () => {
|
||
const result = validateProfile({ name: (() => "test") as unknown as string });
|
||
expect(result.valid).toBe(false);
|
||
});
|
||
|
||
it("handles prototype pollution attempt", () => {
|
||
const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown;
|
||
const result = validateProfile(malicious);
|
||
// Should not pollute Object.prototype
|
||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ============================================================================
|
||
// Event Creation Edge Cases
|
||
// ============================================================================
|
||
|
||
describe("event creation edge cases", () => {
|
||
it("handles profile with all fields at max length", () => {
|
||
const profile: NostrProfile = {
|
||
name: "a".repeat(256),
|
||
displayName: "b".repeat(256),
|
||
about: "c".repeat(2000),
|
||
nip05: "d".repeat(200) + "@example.com",
|
||
lud16: "e".repeat(200) + "@example.com",
|
||
};
|
||
|
||
const event = createProfileEvent(TEST_SK, profile);
|
||
expect(event.kind).toBe(0);
|
||
|
||
// Content should be parseable JSON
|
||
expect(() => JSON.parse(event.content)).not.toThrow();
|
||
});
|
||
|
||
it("handles rapid sequential events with monotonic timestamps", () => {
|
||
const profile: NostrProfile = { name: "rapid" };
|
||
|
||
// Create events in quick succession
|
||
let lastTimestamp = 0;
|
||
for (let i = 0; i < 100; i++) {
|
||
const event = createProfileEvent(TEST_SK, profile, lastTimestamp);
|
||
expect(event.created_at).toBeGreaterThan(lastTimestamp);
|
||
lastTimestamp = event.created_at;
|
||
}
|
||
});
|
||
|
||
it("handles JSON special characters in content", () => {
|
||
const profile: NostrProfile = {
|
||
name: 'test"user',
|
||
about: "line1\nline2\ttab\\backslash",
|
||
};
|
||
|
||
const event = createProfileEvent(TEST_SK, profile);
|
||
const parsed = JSON.parse(event.content) as { name: string; about: string };
|
||
|
||
expect(parsed.name).toBe('test"user');
|
||
expect(parsed.about).toContain("\n");
|
||
expect(parsed.about).toContain("\t");
|
||
expect(parsed.about).toContain("\\");
|
||
});
|
||
});
|