mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
A meeting invitation puts the join URL in the description - Teams, Meet, Zoom and the plain iTIP fallbacks all do - and the three read-only description renders (invitation RSVP view, event detail view, detail popover) printed it as bare text. The one actionable thing in the event was not clickable: you had to select the URL by hand, across the line wraps, and paste it into the address bar. Render those three through the same pipeline the plain-text mail body already uses (`plainTextToSafeHtml` + `sanitizePlainTextRenderedHtml`), wrapped in a small `LinkifiedText` component. The text stays plain text: it is escaped, only `<a href="http(s)://...">` is emitted, and the result is sanitized again before it reaches the DOM - an event description is attacker-controlled, since anyone can send an iTIP invitation. Two details along the way: `break-words` on the paragraphs, so a long join URL wraps instead of widening the panel, and `whitespace-pre-line` on the invitation view, which was the only one of the three dropping the description's own line breaks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018QPPAw4SK4M9Z7euVPM2cL
46 lines
2.1 KiB
TypeScript
46 lines
2.1 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import { render, screen, cleanup } from '@testing-library/react';
|
|
import { LinkifiedText } from '../linkified-text';
|
|
|
|
/**
|
|
* A meeting invitation puts the join URL in the event description, so that
|
|
* text has to render its http(s) URLs as real links - while staying plain
|
|
* text: the description is attacker-controlled (anyone can send an iTIP
|
|
* invitation), so no markup in it may ever reach the DOM.
|
|
*/
|
|
|
|
describe('LinkifiedText', () => {
|
|
afterEach(cleanup);
|
|
|
|
it('turns a URL in the text into a link that opens in a new tab', () => {
|
|
render(<LinkifiedText text="Join: https://teams.example.com/meet/319560?p=CTYa9 Meeting ID: 319 560" />);
|
|
const link = screen.getByRole('link');
|
|
expect(link).toHaveAttribute('href', 'https://teams.example.com/meet/319560?p=CTYa9');
|
|
expect(link).toHaveAttribute('target', '_blank');
|
|
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
|
|
});
|
|
|
|
it('stops the URL at the closing angle bracket of an <https://...> form', () => {
|
|
render(<LinkifiedText text="Need help? <https://aka.example/JoinMeeting?omkt=fr-FR> | System" />);
|
|
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://aka.example/JoinMeeting?omkt=fr-FR');
|
|
});
|
|
|
|
it('renders markup in the text as text, never as elements', () => {
|
|
const { container } = render(<LinkifiedText text={'<img src=x onerror=alert(1)> <b>bold</b>'} />);
|
|
expect(container.querySelector('img')).toBeNull();
|
|
expect(container.querySelector('b')).toBeNull();
|
|
expect(container.textContent).toBe('<img src=x onerror=alert(1)> <b>bold</b>');
|
|
});
|
|
|
|
it('does not linkify non-http schemes', () => {
|
|
const { container } = render(<LinkifiedText text="try javascript:alert(1) or file:///etc/passwd" />);
|
|
expect(container.querySelector('a')).toBeNull();
|
|
});
|
|
|
|
it('leaves text without a URL untouched', () => {
|
|
const { container } = render(<LinkifiedText text="no links here" />);
|
|
expect(container.querySelector('a')).toBeNull();
|
|
expect(container.textContent).toBe('no links here');
|
|
});
|
|
});
|