From dfa2bdd4aa41f2da30a496d932849f766755bae2 Mon Sep 17 00:00:00 2001 From: wcy12378 Date: Sat, 12 Sep 2026 09:36:19 +0800 Subject: [PATCH] fix(ui): reject protocol-relative URLs in safeUrl --- .../components/chat/MarkdownContent.test.tsx | 19 +++++++++++++++++++ .../src/components/chat/MarkdownContent.tsx | 7 +++++++ 2 files changed, 26 insertions(+) diff --git a/packages/ui/src/components/chat/MarkdownContent.test.tsx b/packages/ui/src/components/chat/MarkdownContent.test.tsx index 0f1d180..9b5b3fc 100644 --- a/packages/ui/src/components/chat/MarkdownContent.test.tsx +++ b/packages/ui/src/components/chat/MarkdownContent.test.tsx @@ -54,6 +54,25 @@ describe('MarkdownContent', () => { expect(container.textContent).toContain('Unsafe'); }); + it('blocks protocol-relative URLs that bypass the scheme allowlist', async () => { + const container = document.createElement('div'); + const root = createRoot(container); + + await act(async () => { + root.render( + + ); + }); + + // Protocol-relative links must not produce a clickable //host href; the + // label is still shown as plain text so the answer stays readable. + expect(container.querySelector('a[href^="//"]')).toBeNull(); + expect(container.querySelector('a[href="https://example.com"]')?.textContent).toBe('Ok'); + expect(container.textContent).toContain('Phish'); + }); + it('does not render raw HTML from agent output', async () => { const container = document.createElement('div'); const root = createRoot(container); diff --git a/packages/ui/src/components/chat/MarkdownContent.tsx b/packages/ui/src/components/chat/MarkdownContent.tsx index 17422cd..0c01317 100644 --- a/packages/ui/src/components/chat/MarkdownContent.tsx +++ b/packages/ui/src/components/chat/MarkdownContent.tsx @@ -12,6 +12,13 @@ const SAFE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']); function safeUrl(value: string): string { const candidate = value.trim(); if (!candidate) return ''; + // Reject protocol-relative URLs (//host/path). They slip past the relative + // prefix check below and `new URL` then resolves them to an + // attacker-controlled host over https, which is exactly the link-injection + // vector tracked in #16 (prompt injection from untrusted sources). External + // links must go through the scheme allowlist, so there is no safe reason to + // keep a protocol-relative URL here. + if (candidate.startsWith('//')) return ''; if (candidate.startsWith('#') || candidate.startsWith('/') || candidate.startsWith('./') || candidate.startsWith('../')) { return candidate; }