This commit is contained in:
Linus Rath
2026-05-15 14:49:58 +02:00
4 changed files with 89 additions and 17 deletions
+2 -2
View File
@@ -728,8 +728,8 @@ const IDENTITIES = [
email: 'dev@localhost', email: 'dev@localhost',
replyTo: null, replyTo: null,
bcc: null, bcc: null,
textSignature: '-- \nDev User\nBulwark Webmail Developer', textSignature: 'Dev User\nBulwark Webmail Developer',
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>', htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
mayDelete: false, mayDelete: false,
}, },
]; ];
+1 -1
View File
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
</label> </label>
<textarea <textarea
id="identity-html-sig" id="identity-html-sig"
maxLength={5000} maxLength={50000}
value={formData.htmlSignature} value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })} onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
rows={5} rows={5}
+65 -8
View File
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
expect(clean).toContain('John Doe'); expect(clean).toContain('John Doe');
}); });
it('should remove images from signatures', () => { it('should allow img with https src', () => {
const signature = '<p>John</p><img src="logo.png" alt="Logo">'; const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
const clean = sanitizeSignatureHtml(signature); const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('src="https://cdn.example.com/logo.png"');
expect(clean).toContain('alt="Logo"');
expect(clean).toContain('width="120"');
expect(clean).toContain('height="40"');
});
it('should allow img with data:image/png;base64 src', () => {
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlPM0jRW/QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAA1JREFUCNdjYGBgAAAABAABc7Rs9wAAAABJRU5ErkJggg==';
const signature = `<img src="${dataUri}" alt="Logo">`;
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<img');
expect(clean).toContain('data:image/png;base64,');
});
it('should allow img with data:image/jpeg, gif, webp', () => {
const cases = ['data:image/jpeg;base64,AAA', 'data:image/jpg;base64,AAA', 'data:image/gif;base64,AAA', 'data:image/webp;base64,AAA'];
for (const src of cases) {
const clean = sanitizeSignatureHtml(`<img src="${src}" alt="x">`);
expect(clean).toContain('<img');
expect(clean).toContain(src);
}
});
it('should strip img with http: src (https only)', () => {
const signature = '<img src="http://insecure.example.com/logo.png" alt="Logo">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('http://insecure.example.com');
expect(clean).not.toContain('<img'); expect(clean).not.toContain('<img');
expect(clean).toContain('John'); });
it('should strip img with javascript: src', () => {
const signature = '<img src="javascript:alert(1)" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('javascript:');
expect(clean).not.toContain('<img');
});
it('should strip img with data:image/svg+xml src (SVG forbidden)', () => {
const signature = '<img src="data:image/svg+xml;base64,PHN2Zy8+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:image/svg');
expect(clean).not.toContain('<img');
});
it('should strip img with non-image data: URI', () => {
const signature = '<img src="data:text/html;base64,PHA+aGk8L3A+" alt="x">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('data:text/html');
expect(clean).not.toContain('<img');
});
it('should strip event handlers on img', () => {
const signature = '<img src="https://cdn.example.com/logo.png" alt="x" onerror="alert(1)" onload="alert(2)">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('onerror');
expect(clean).not.toContain('onload');
expect(clean).toContain('https://cdn.example.com/logo.png');
}); });
it('should remove video and audio tags', () => { it('should remove video and audio tags', () => {
@@ -113,16 +169,17 @@ describe('email-sanitization', () => {
}); });
it('should be stricter than email sanitization', () => { it('should be stricter than email sanitization', () => {
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>'; const html = '<p>Text</p><table><tr><td>Data</td></tr></table><video src="v.mp4"></video>';
const emailClean = sanitizeEmailHtml(html); const emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html); const signatureClean = sanitizeSignatureHtml(html);
// Email allows img and table // Email allows table
expect(emailClean).toContain('<img');
expect(emailClean).toContain('<table>'); expect(emailClean).toContain('<table>');
// Signature blocks img but may allow some tables (verify in implementation) // Signature blocks table and video
expect(signatureClean).not.toContain('<img'); expect(signatureClean).not.toContain('<table');
expect(signatureClean).not.toContain('<video');
expect(signatureClean).toContain('Text');
}); });
}); });
+21 -6
View File
@@ -58,24 +58,39 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
/** /**
* Sanitize HTML signature with stricter rules * Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources * Allows basic formatting plus <img> for company logos
*/ */
export const SIGNATURE_SANITIZE_CONFIG = { export const SIGNATURE_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'], ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'],
ALLOWED_ATTR: ['href', 'style', 'class'], ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'],
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'], FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'video', 'audio'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'], FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
}; };
/** /**
* Sanitize HTML signature for storage and display * Sanitize HTML signature for storage and display.
* img src is restricted to https: or base64-embedded raster data: URIs
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
* bytes inside a data: URI. Images with a disallowed src are removed
* entirely so they don't render as broken-image icons.
* @param html - User-provided HTML signature * @param html - User-provided HTML signature
* @returns Sanitized signature (no scripts, no external resources) * @returns Sanitized signature (no scripts, no external resources)
*/ */
export function sanitizeSignatureHtml(html: string): string { export function sanitizeSignatureHtml(html: string): string {
if (!html?.trim()) return ''; if (!html?.trim()) return '';
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG); DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName !== 'IMG') return;
const src = node.getAttribute('src');
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
node.remove();
}
});
try {
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
} }
/** /**