Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/ui/src/components/chat/MarkdownContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MarkdownContent
content={'[Phish](//evil.com/steal) [Also](//github.com/helsome/folio) [Ok](https://example.com)'}
/>
);
});

// 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);
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/components/chat/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down