From a078c318e801643fb01f41802795e2c5d8dcb169 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 26 Jul 2024 22:02:14 +1200 Subject: [PATCH] Fix(Security): Prevent bypass of Contend Security Policy using stored XSS, and sanitize preview HTML data (DOMPurify) This closes a security hole whereby a bad actor with SMTP access can bypass the CSP headers with a series of specially crafted HTML messages. A special thanks to @bmodotdev for responsibly disclosing the vulnerability and proving information and an initial fix. --- config/config.go | 3 + package-lock.json | 6 ++ package.json | 1 + server/server.go | 29 +++++-- server/ui-src/components/message/Message.vue | 89 ++++++++++++++++++-- 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/config/config.go b/config/config.go index 9d443b9cb..c2fdaa660 100644 --- a/config/config.go +++ b/config/config.go @@ -205,6 +205,9 @@ func VerifyConfig() error { cssFontRestriction = "'self'" } + // The default Content Security Policy is updates on every application page load to replace script-src 'self' + // with a random nonce ID to prevent XSS. This applies to the Mailpit app & API. + // See server.middleWareFunc() ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';", cssFontRestriction, cssFontRestriction, ) diff --git a/package-lock.json b/package-lock.json index 6788fbf77..fde6e5c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "bootstrap5-tags": "^1.6.1", "color-hash": "^2.0.2", "dayjs": "^1.11.10", + "dompurify": "^3.1.6", "ical.js": "^2.0.1", "modern-screenshot": "^4.4.30", "prismjs": "^1.29.0", @@ -1417,6 +1418,11 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", + "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==" + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", diff --git a/package.json b/package.json index d48725c6d..be6f5a10c 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "bootstrap5-tags": "^1.6.1", "color-hash": "^2.0.2", "dayjs": "^1.11.10", + "dompurify": "^3.1.6", "ical.js": "^2.0.1", "modern-screenshot": "^4.4.30", "prismjs": "^1.29.0", diff --git a/server/server.go b/server/server.go index d622d3b14..55dfae362 100644 --- a/server/server.go +++ b/server/server.go @@ -25,6 +25,7 @@ import ( "github.com/axllent/mailpit/server/pop3" "github.com/axllent/mailpit/server/websockets" "github.com/gorilla/mux" + "github.com/lithammer/shortuuid/v4" ) //go:embed ui @@ -75,11 +76,11 @@ func Listen() { } // UI shortcut - r.HandleFunc(config.Webroot+"view/latest", handlers.RedirectToLatestMessage).Methods("GET") + r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET") // frontend testing - r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET") - r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET") + r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(handlers.GetMessageHTML)).Methods("GET") + r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(handlers.GetMessageText)).Methods("GET") // web UI via virtual index.html r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET") @@ -179,7 +180,21 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) { func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy) + + // generate a new random nonce on every request + randomNonce := shortuuid.New() + // header used to pass nonce through to function + r.Header.Set("mp-nonce", randomNonce) + + // Prevent JavaScript XSS by adding a nonce for script-src + cspHeader := strings.Replace( + config.ContentSecurityPolicy, + "script-src 'self';", + fmt.Sprintf("script-src 'nonce-%s';", randomNonce), + 1, + ) + + w.Header().Set("Content-Security-Policy", cspHeader) if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") { w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin) @@ -281,7 +296,7 @@ func swaggerBasePath(w http.ResponseWriter, _ *http.Request) { } // Just returns the default HTML template -func index(w http.ResponseWriter, _ *http.Request) { +func index(w http.ResponseWriter, r *http.Request) { var h = ` @@ -303,7 +318,7 @@ func index(w http.ResponseWriter, _ *http.Request) { - + ` @@ -316,9 +331,11 @@ func index(w http.ResponseWriter, _ *http.Request) { data := struct { Webroot string Version string + Nonce string }{ Webroot: config.Webroot, Version: config.Version, + Nonce: r.Header.Get("mp-nonce"), } buff := new(bytes.Buffer) diff --git a/server/ui-src/components/message/Message.vue b/server/ui-src/components/message/Message.vue index c8c80af7c..e826fc4dd 100644 --- a/server/ui-src/components/message/Message.vue +++ b/server/ui-src/components/message/Message.vue @@ -9,6 +9,7 @@ import Tags from 'bootstrap5-tags' import { Tooltip } from 'bootstrap' import commonMixins from '../../mixins/CommonMixins' import { mailbox } from '../../stores/mailbox' +import DOMPurify from 'dompurify' export default { props: { @@ -73,6 +74,57 @@ export default { return (mailbox.showHTMLCheck && this.message.HTML) || mailbox.showLinkCheck || (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin) + }, + + // remove bad HTML, JavaScript, iframes etc + sanitizedHTML() { + DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#') { + return + } + if ('target' in node) { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } + if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) { + node.setAttribute('xlink:show', '_blank'); + } + }); + + const clean = DOMPurify.sanitize( + this.message.HTML, + { + WHOLE_DOCUMENT: true, + SANITIZE_DOM: false, + ADD_TAGS: [ + 'link', + 'meta', + 'o:p', + 'style', + ], + ADD_ATTR: [ + 'bordercolor', + 'charset', + 'content', + 'hspace', + 'http-equiv', + 'itemprop', + 'itemscope', + 'itemtype', + 'link', + 'vertical-align', + 'vlink', + 'vspace', + 'xml:lang' + ], + FORBID_ATTR: ['script'], + } + ) + + // for debugging + // this.debugDOMPurify(DOMPurify.removed) + + return clean } }, @@ -133,7 +185,7 @@ export default { // delay 0.2s until vue has rendered the iframe content window.setTimeout(() => { let p = document.getElementById('preview-html') - if (p) { + if (p && typeof p.contentWindow.document.body != 'undefined') { // make links open in new window let anchorEls = p.contentWindow.document.body.querySelectorAll('a') for (var i = 0; i < anchorEls.length; i++) { @@ -185,9 +237,31 @@ export default { this.resizeIframe(el) }, - sanitizeHTML(h) { - // remove tag if set - return h.replace(//mi, '') + // this function is unused but kept here to use for debugging + debugDOMPurify(removed) { + if (!removed.length) { + return + } + + const ignoreNodes = ['target', 'base', 'script', 'v:shapes'] + + let d = removed.filter((r) => { + if (typeof r.attribute != 'undefined' && + (ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith('xmlns:')) + ) { + return false + } + // inline comments + if (typeof r.element != 'undefined' && (r.element.nodeType == 8 || r.element.tagName == 'SCRIPT')) { + return false + } + + return true + }) + + if (d.length) { + console.log(d) + } }, saveTags() { @@ -292,7 +366,7 @@ export default { Bcc - + {{ t.Name }} < @@ -510,9 +584,8 @@ export default {