diff --git a/src/components/chat/message-bubble.test.tsx b/src/components/chat/message-bubble.test.tsx index 39a47a9..2484f53 100644 --- a/src/components/chat/message-bubble.test.tsx +++ b/src/components/chat/message-bubble.test.tsx @@ -79,4 +79,29 @@ describe("MessageBubble Link Sanitization", () => { expect(links[1].getAttribute("href")).toBe("#"); expect(links[2].getAttribute("href")).toBe("#"); }); + + it("should sanitize obfuscated protocols and non-allowlisted schemes to #", () => { + const message = { + id: "3", + role: "assistant", + content: + "Obfuscated: [Tab XSS](), [Blob](blob:text/html,test), and [File](file:///etc/passwd).", + parts: [ + { + type: "text", + text: "Obfuscated: [Tab XSS](), [Blob](blob:text/html,test), and [File](file:///etc/passwd).", + }, + ], + } as any; + + const { container } = render( + , + ); + + const links = container.querySelectorAll("a"); + expect(links.length).toBe(3); + expect(links[0].getAttribute("href")).toBe("#"); + expect(links[1].getAttribute("href")).toBe("#"); + expect(links[2].getAttribute("href")).toBe("#"); + }); }); diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index 983e0f6..ad891ed 100644 --- a/src/components/chat/message-bubble.tsx +++ b/src/components/chat/message-bubble.tsx @@ -17,11 +17,24 @@ const selectElementText = (target: HTMLElement) => { selection?.addRange(range); }; +const isSafeUrl = (href?: string): boolean => { + if (!href) return false; + // Normalize URL by stripping control characters and whitespace before protocol validation + /* eslint-disable-next-line no-control-regex */ + const controlChars = /[\u0000-\u0020\u007F-\u009F]/g; + const normalized = href.replace(controlChars, ""); + return ( + normalized.startsWith("#") || + normalized.startsWith("/") || + /^(https?|mailto|tel):/i.test(normalized) + ); +}; + const markdownComponents = { a: ({ href, children }: { href?: string; children?: React.ReactNode }) => { const isHash = href?.startsWith("#"); - // Prevent prompt injection leading to XSS via javascript:, data:, or vbscript: protocols - const isSafe = href && !/^(javascript|data|vbscript):/i.test(href.trim()); + // Prevent prompt injection leading to XSS by enforcing a strict protocol allowlist + const isSafe = isSafeUrl(href); const isExternal = !isHash; return (