mirror of
https://github.com/axllent/mailpit.git
synced 2025-07-15 01:25:10 +02:00
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.
This commit is contained in:
@ -205,6 +205,9 @@ func VerifyConfig() error {
|
|||||||
cssFontRestriction = "'self'"
|
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';",
|
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,
|
cssFontRestriction, cssFontRestriction,
|
||||||
)
|
)
|
||||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -14,6 +14,7 @@
|
|||||||
"bootstrap5-tags": "^1.6.1",
|
"bootstrap5-tags": "^1.6.1",
|
||||||
"color-hash": "^2.0.2",
|
"color-hash": "^2.0.2",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"dompurify": "^3.1.6",
|
||||||
"ical.js": "^2.0.1",
|
"ical.js": "^2.0.1",
|
||||||
"modern-screenshot": "^4.4.30",
|
"modern-screenshot": "^4.4.30",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
@ -1417,6 +1418,11 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
"bootstrap5-tags": "^1.6.1",
|
"bootstrap5-tags": "^1.6.1",
|
||||||
"color-hash": "^2.0.2",
|
"color-hash": "^2.0.2",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"dompurify": "^3.1.6",
|
||||||
"ical.js": "^2.0.1",
|
"ical.js": "^2.0.1",
|
||||||
"modern-screenshot": "^4.4.30",
|
"modern-screenshot": "^4.4.30",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
|
@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/axllent/mailpit/server/pop3"
|
"github.com/axllent/mailpit/server/pop3"
|
||||||
"github.com/axllent/mailpit/server/websockets"
|
"github.com/axllent/mailpit/server/websockets"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/lithammer/shortuuid/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed ui
|
//go:embed ui
|
||||||
@ -75,11 +76,11 @@ func Listen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UI shortcut
|
// 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
|
// frontend testing
|
||||||
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
|
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(handlers.GetMessageHTML)).Methods("GET")
|
||||||
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
|
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(handlers.GetMessageText)).Methods("GET")
|
||||||
|
|
||||||
// web UI via virtual index.html
|
// web UI via virtual index.html
|
||||||
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
|
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 {
|
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
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/") {
|
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
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
|
// Just returns the default HTML template
|
||||||
func index(w http.ResponseWriter, _ *http.Request) {
|
func index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
var h = `<!DOCTYPE html>
|
var h = `<!DOCTYPE html>
|
||||||
<html lang="en" class="h-100">
|
<html lang="en" class="h-100">
|
||||||
@ -303,7 +318,7 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
|||||||
</noscript>
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
|
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}" nonce="{{ .Nonce }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>`
|
</html>`
|
||||||
@ -316,9 +331,11 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
|||||||
data := struct {
|
data := struct {
|
||||||
Webroot string
|
Webroot string
|
||||||
Version string
|
Version string
|
||||||
|
Nonce string
|
||||||
}{
|
}{
|
||||||
Webroot: config.Webroot,
|
Webroot: config.Webroot,
|
||||||
Version: config.Version,
|
Version: config.Version,
|
||||||
|
Nonce: r.Header.Get("mp-nonce"),
|
||||||
}
|
}
|
||||||
|
|
||||||
buff := new(bytes.Buffer)
|
buff := new(bytes.Buffer)
|
||||||
|
@ -9,6 +9,7 @@ import Tags from 'bootstrap5-tags'
|
|||||||
import { Tooltip } from 'bootstrap'
|
import { Tooltip } from 'bootstrap'
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from '../../mixins/CommonMixins'
|
||||||
import { mailbox } from '../../stores/mailbox'
|
import { mailbox } from '../../stores/mailbox'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@ -73,6 +74,57 @@ export default {
|
|||||||
return (mailbox.showHTMLCheck && this.message.HTML)
|
return (mailbox.showHTMLCheck && this.message.HTML)
|
||||||
|| mailbox.showLinkCheck
|
|| mailbox.showLinkCheck
|
||||||
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
|| (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
|
// delay 0.2s until vue has rendered the iframe content
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
let p = document.getElementById('preview-html')
|
let p = document.getElementById('preview-html')
|
||||||
if (p) {
|
if (p && typeof p.contentWindow.document.body != 'undefined') {
|
||||||
// make links open in new window
|
// make links open in new window
|
||||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||||
for (var i = 0; i < anchorEls.length; i++) {
|
for (var i = 0; i < anchorEls.length; i++) {
|
||||||
@ -185,9 +237,31 @@ export default {
|
|||||||
this.resizeIframe(el)
|
this.resizeIframe(el)
|
||||||
},
|
},
|
||||||
|
|
||||||
sanitizeHTML(h) {
|
// this function is unused but kept here to use for debugging
|
||||||
// remove <base/> tag if set
|
debugDOMPurify(removed) {
|
||||||
return h.replace(/<base .*>/mi, '')
|
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() {
|
saveTags() {
|
||||||
@ -292,7 +366,7 @@ export default {
|
|||||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||||
<th>Bcc</th>
|
<th>Bcc</th>
|
||||||
<td class="privacy">
|
<td class="privacy">
|
||||||
<span v-for="( t, i ) in message.Bcc ">
|
<span v-for="(t, i) in message.Bcc">
|
||||||
<template v-if="i > 0">,</template>
|
<template v-if="i > 0">,</template>
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
<<a :href="searchURI(t.Address)" class="text-body">
|
<<a :href="searchURI(t.Address)" class="text-body">
|
||||||
@ -510,9 +584,8 @@ export default {
|
|||||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||||
aria-labelledby="nav-html-tab" tabindex="0">
|
aria-labelledby="nav-html-tab" tabindex="0">
|
||||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html"
|
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
|
||||||
:srcdoc="sanitizeHTML(message.HTML)" v-on:load="resizeIframe" frameborder="0"
|
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
||||||
style="width: 100%; height: 100%; background: #fff;">
|
|
||||||
</iframe>
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||||
|
Reference in New Issue
Block a user