481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
|
|
import { describe, expect, it } from "vitest";
|
|||
|
|
import type { NostrProfile } from "./config-schema.js";
|
|||
|
|
import {
|
|||
|
|
createProfileEvent,
|
|||
|
|
profileToContent,
|
|||
|
|
validateProfile,
|
|||
|
|
sanitizeProfileForDisplay,
|
|||
|
|
} from "./nostr-profile.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)", () => {
|
|||
|
|
// Keep the source small (faster transforms) while still exercising
|
|||
|
|
// "lots of combining marks" behavior.
|
|||
|
|
const marks = "\u0301\u0300\u0336\u034f\u035c\u0360";
|
|||
|
|
const zalgo = `t${marks.repeat(256)}e${marks.repeat(256)}s${marks.repeat(256)}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;
|
|||
|
|
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 < 25; 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("\\");
|
|||
|
|
});
|
|||
|
|
});
|