Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -728,8 +728,8 @@ const IDENTITIES = [
|
||||
email: 'dev@localhost',
|
||||
replyTo: null,
|
||||
bcc: null,
|
||||
textSignature: '-- \nDev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>--<br>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
textSignature: 'Dev User\nBulwark Webmail Developer',
|
||||
htmlSignature: '<p>Dev User<br><em>Bulwark Webmail Developer</em></p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -263,7 +263,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-html-sig"
|
||||
maxLength={5000}
|
||||
maxLength={50000}
|
||||
value={formData.htmlSignature}
|
||||
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
|
||||
rows={5}
|
||||
|
||||
@@ -78,11 +78,67 @@ describe('email-sanitization', () => {
|
||||
expect(clean).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should remove images from signatures', () => {
|
||||
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
|
||||
it('should allow img with https src', () => {
|
||||
const signature = '<p>John</p><img src="https://cdn.example.com/logo.png" alt="Logo" width="120" height="40">';
|
||||
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).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', () => {
|
||||
@@ -113,16 +169,17 @@ describe('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 signatureClean = sanitizeSignatureHtml(html);
|
||||
|
||||
// Email allows img and table
|
||||
expect(emailClean).toContain('<img');
|
||||
// Email allows table
|
||||
expect(emailClean).toContain('<table>');
|
||||
|
||||
// Signature blocks img but may allow some tables (verify in implementation)
|
||||
expect(signatureClean).not.toContain('<img');
|
||||
// Signature blocks table and video
|
||||
expect(signatureClean).not.toContain('<table');
|
||||
expect(signatureClean).not.toContain('<video');
|
||||
expect(signatureClean).toContain('Text');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,24 +58,39 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'class'],
|
||||
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div', 'img'],
|
||||
ALLOWED_ATTR: ['href', 'style', 'class', 'src', 'alt', 'width', 'height', 'title'],
|
||||
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'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns Sanitized signature (no scripts, no external resources)
|
||||
*/
|
||||
export function sanitizeSignatureHtml(html: string): string {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user