Skip to content

Commit

Permalink
Fix(Security): Prevent bypass of Contend Security Policy using stored…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
axllent committed Jul 26, 2024
1 parent 9e881ea commit a078c31
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 14 deletions.
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 23 additions & 6 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = `<!DOCTYPE html>
<html lang="en" class="h-100">
Expand All @@ -303,7 +318,7 @@ func index(w http.ResponseWriter, _ *http.Request) {
</noscript>
</div>
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}" nonce="{{ .Nonce }}"></script>
</body>
</html>`
Expand All @@ -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)
Expand Down
89 changes: 81 additions & 8 deletions server/ui-src/components/message/Message.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
}
},
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -185,9 +237,31 @@ export default {
this.resizeIframe(el)
},
sanitizeHTML(h) {
// remove <base/> tag if set
return h.replace(/<base .*>/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() {
Expand Down Expand Up @@ -292,7 +366,7 @@ export default {
<tr v-if="message.Bcc && message.Bcc.length" class="small">
<th>Bcc</th>
<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>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body">
Expand Down Expand Up @@ -510,9 +584,8 @@ export default {
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
aria-labelledby="nav-html-tab" tabindex="0">
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
<iframe target-blank="" class="tab-pane d-block" id="preview-html"
:srcdoc="sanitizeHTML(message.HTML)" v-on:load="resizeIframe" frameborder="0"
style="width: 100%; height: 100%; background: #fff;">
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
</iframe>
</div>
<Attachments v-if="allAttachments(message).length" :message="message"
Expand Down

0 comments on commit a078c31

Please sign in to comment.