fix: open signature links in a new tab instead of navigating the app away

Signatures render into the main document - the identity form's live preview
and the composer's signature block - rather than the sandboxed iframe used for
message bodies. SIGNATURE_SANITIZE_CONFIG allows no target attribute, so those
anchors were live and target-less: one click navigated the whole app away,
discarding the unsent draft or the unsaved signature with it.

Add sanitizeSignatureHtmlForDisplay, which keeps the storage sanitizer's image
restrictions but forces target="_blank" rel="noopener noreferrer" on every
anchor, and use it at the two render sites. The composer's SignatureBlock
NodeView stamps the target on its rendered DOM instead, because attrs.html is
what serializeEditorContent emits into the sent message - storage and the
recipient's copy stay exactly as the user wrote them.
This commit is contained in:
honzup
2026-07-11 10:45:01 +02:00
committed by Linus Rath
parent 75d17d4e37
commit 42798c2b7c
5 changed files with 112 additions and 13 deletions
+38
View File
@@ -3,6 +3,7 @@ import DOMPurify from 'dompurify';
import {
sanitizeEmailHtml,
sanitizeSignatureHtml,
sanitizeSignatureHtmlForDisplay,
parseHtmlSafely,
hasRichFormatting,
plainTextToSafeHtml,
@@ -582,6 +583,43 @@ describe('email-sanitization', () => {
});
});
describe('sanitizeSignatureHtmlForDisplay', () => {
// Signatures render into the main document (identity-form preview, composer
// block), not the sandboxed iframe, so a target-less anchor navigates the
// whole app away and takes the unsaved draft/signature with it.
it('forces target=_blank and rel on signature links', () => {
const clean = sanitizeSignatureHtmlForDisplay('<p><a href="https://example.com">Site</a></p>');
expect(clean).toContain('target="_blank"');
expect(clean).toContain('rel="noopener noreferrer"');
});
it('overrides a target the user supplied themselves', () => {
const clean = sanitizeSignatureHtmlForDisplay('<a href="https://example.com" target="_top">x</a>');
expect(clean).toContain('target="_blank"');
expect(clean).not.toContain('_top');
});
it('keeps the image restrictions of the storage sanitizer', () => {
const clean = sanitizeSignatureHtmlForDisplay(
'<img src="http://insecure.example.com/l.png"><img src="https://cdn.example.com/l.png">',
);
expect(clean).not.toContain('insecure.example.com');
expect(clean).toContain('https://cdn.example.com/l.png');
});
it('does not leak target into the stored or sent signature', () => {
// sanitizeSignatureHtml feeds both storage and the outgoing message body.
const stored = sanitizeSignatureHtml('<p><a href="https://example.com">Site</a></p>');
expect(stored).toContain('href="https://example.com"');
expect(stored).not.toContain('target=');
});
it('handles empty input', () => {
expect(sanitizeSignatureHtmlForDisplay('')).toBe('');
expect(sanitizeSignatureHtmlForDisplay(' ')).toBe('');
});
});
describe('sanitizePlainTextRenderedHtml', () => {
// This branch renders into the main document, not the sandboxed iframe, so
// an anchor that loses target="_blank" navigates the whole app away.