Email security deprecated
Secure email rendering
HTML email is untrusted content. App Suite UI renders every HTML mail body in the browser, inside a sandboxed iframe under a strict Content-Security-Policy, after sanitizing it — so a message can never run a script, pull in an external stylesheet, or silently phone home.
This page explains how that works, what stays user-facing, and the two hoster-facing options: allow-listing a brand web font, and the optional external-image privacy proxy.
From the middleware to the browser — and why
Historically the middleware pre-processed HTML mail for the detail view: it sanitized the body, normalized the markup, and proxied every external image through the server. App Suite UI now fetches the unmodified mail and owns rendering end to end — sanitizing, external-content control, and a Content-Security-Policy all run in the browser.
Why the move:
- Correctness — the browser is the best HTML/CSS engine; rendering the original markup avoids a class of server-side normalization bugs.
- CDNs block centralized proxies — this is inherent to any image proxy, not a flaw in ours: a single server fetching images on behalf of everyone reads as bot traffic, so CDNs (Cloudflare, Akamai, …) block it and images break for whole deployments. A direct browser fetch looks like ordinary user traffic and isn't singled out. (The proxy can still be offered as an opt-in privacy feature — see below.)
- One place owns it — the client already sanitized the body for years (with DOMPurify); moving the rest there removes a confusing split of responsibilities between server and client.
Note this is not "sanitization moved to the client" — that was already client-side. What moved is the external-content blocking, the markup normalization, and the image proxy.
What the client does
Every HTML mail body passes through the same pipeline before it is shown:
- Sanitize — DOMPurify removes scripts, event-handler attributes, and dangerous tags against the operator's allowlist. No mail markup can execute code.
- Isolate — the sanitized body is rendered inside a sandboxed iframe under a strict CSP (below), never in the main document. Even if something slipped past the sanitizer, the CSP is a hard second gate.
- Block external content by default — remote images are blocked until the user (or a trust rule) allows them (the visible part — see the next section). External fonts, media and stylesheets are blocked outright by the CSP and are not user-revealable (they only affect appearance, not content).
The same sanitizing applies wherever mail content is shown or edited: reply/forward quoting, the compose editor, signatures and templates, and the print view.
Blocking external images (user-facing)
External images are blocked by default to prevent tracking — loading a remote image tells the sender the mail was opened and, without a proxy, reveals the recipient's IP address. The detail view shows a small banner:
- Show images — reveal the external images in this one message.
- Always show images from this sender — trust the sender, so their future mail auto-loads images.
Trusted senders (and deployments configured to auto-load) skip the banner. This behaviour is unchanged — only where the blocking happens (client instead of server) changed.
The Content-Security-Policy (and the attack it closes)
Dropping the server image proxy re-armed an attack the proxy had been masking: an external image whose HTTP response carries a Link: …; rel=preload; as=font (or as=style) header can chain to a 401 WWW-Authenticate response, which pops a native browser credential dialog — with attacker-chosen realm text — over the App Suite UI. The email markup itself is harmless; the trick is entirely in the network response, so more sanitizing wouldn't help.
The fix is a strict Content-Security-Policy on the render frame, which simply refuses the offending sub-resource loads:
default-src 'none'; script-src 'none'; style-src 'unsafe-inline';
img-src data: cid: https:; font-src 'none'; media-src 'none';
form-action 'none'; base-uri 'none'
script-src 'none'— no scripts, ever (the hard second gate behind DOMPurify).style-src 'unsafe-inline'with no host — a mail's own inline styles work; external stylesheets don't.img-src … https:— images may load (after consent); their 401s are browser-suppressed, so they can't pop the dialog.font-src 'none'+media-src 'none'— close the credential-dialog vector for every preload variant.
Why the sandbox still allows scripts (on Safari). The render frame is sandboxed, but on Safari (and every browser on iOS — all of them are WebKit) it must additionally be granted
allow-scripts. This is not to run scripts from the message: WebKit has a long-standing bug (218086) where it otherwise refuses to deliver events (clicks, keystrokes) to the application's own handlers on the frame — even though those handlers are the app's, not the message's. Scripts in a message still never run: the CSP'sscript-src 'none'is the actual gate. Other browser engines deliver the events without the flag, so it is omitted there (a stricter sandbox and no browser console warning about the flag combination).
Allowing a brand web font
Because font-src is 'none', an external/brand web font falls back to a system font (purely cosmetic). A hoster who serves a corporate font from a trusted host can allow-list it — font-src is the one overridable directive:
io.ox/mail//csp/fontSrc: "https://fonts.example.com https://*.brand.example.com"
The setting applies to the compose editor as well, so a signature using that font looks the same while writing a mail as it does when reading one.
The value is validated against a strict allowlist (only specific https hosts or left-most-label wildcards on a domain you control; bare schemes and over-broad wildcards are rejected, so an allow-list entry can't re-open the vector). See Content Security Policy in the settings list for the full rules and examples.
Optional: load external images through a privacy proxy
Removing the proxy was a security decision, not a privacy one — the CSP closes the attack regardless of how images load. But some deployments want the proxy's privacy side-effect back: fetching images through the server hides the recipient's IP address from the sender. This is available as an opt-in feature.
When enabled, external images (once the user consents, or for a trusted sender) load through the middleware proxy instead of directly, so the sender sees the proxy's IP rather than the user's.
# also requires the middleware to provide the image-proxy endpoint (advertised via the `proxy` capability)
io.ox/core//features/proxy: true
Caveat — the inherent CDN limitation. This re-introduces the very problem that motivated dropping the proxy: CDN-fronted senders often block the proxy's fetch, so some images may not load. The UI handles this gracefully — a failed image shows a placeholder, and a banner reports how many images couldn't be shown together with a Load images directly action. Choosing it loads the images without the proxy for that one message (a deliberate, per-message trade-off that reveals the IP to the sender).
Custom proxy logic
Image resolution runs through the io.ox/mail/externalImages/proxy extension point. The built-in resolver calls the middleware endpoint; a deployment that runs its own image-proxy service can register its own resolver on that point (and/or disable the default) without touching the rest of the rendering pipeline.
Notes
cid:inline images embedded in a message always render — they are part of the mail, not external content.- External media (video/audio) is blocked by the CSP; the default operator allowlist drops those tags anyway.
- Print reuses the same sanitizer (it's a separate document, so there is no render frame/CSP there), so printed mail is script-free too.
- Message size limit (processing, not security) — the client caps the size of the body it fetches and renders (
io.ox/mail//maxSize/{desktop,mobile}/view, default 3 MB desktop / 1 MB mobile) so a pathologically large mail can't hang the view. A truncated mail shows a notice with Show the entire message, which re-fetches up to a hard cap (…/viewLimit, default 10 MB, aligned to the middleware's ownbodyDisplaySizeLimit); beyond that it stays truncated and can be downloaded instead. This is a robustness/performance guard, not a security control — see the settings list for the values.