Skip to content

Image proxy returns 401 (HMAC mismatch) for remote images whose URL query contains percent-encoded characters #13493

Description

@Alerinos

Steps to reproduce

  1. Receive an HTML email containing an <img> whose src query string contains a percent-encoded character, for example a signed CDN URL:
    https://cdn.example.com/img.jpg?sig=%2Fabc%2Bdef%3D&t=123
  2. Open the message and click Show images
  3. The image is not rendered

This is common in marketing email, where CDN URLs carry base64-style signed tracking parameters.

Expected behaviour

The image is fetched through the proxy and displayed.

Actual behaviour

The request to /apps/mail/proxy?id=…&hmac=…&src=… returns HTTP 401. Nothing is written to nextcloud.log at default log level.

In ProxyController::proxy(), 401 has exactly one source:

if (!hash_equals($this->hmacGenerator->generate($id, $src), $hmac)) {
    $this->logger->info('Proxied email content blocked due to invalid HMAC');
    return new Response(Http::STATUS_UNAUTHORIZED);
}

Root cause

In lib/Service/HtmlPurify/TransformURLScheme.php the proxy URL is built with IURLGenerator::linkToRoute(), which percent-encodes the src parameter correctly. The resulting query string is then passed back through HTMLPurifier_URI:

$proxyUrl = $this->urlGenerator->linkToRoute('mail.proxy.proxy', [
    'id'   => $this->messageId,
    'hmac' => $this->hmacGenerator->generate($this->messageId, $originalURL),
    'src'  => $originalURL
]);
$parsedProxyUrl = parse_url($proxyUrl);
return new \HTMLPurifier_URI(
    $this->request->getServerProtocol(),
    null, $this->request->getServerHost(), null,
    $parsedProxyUrl['path'],
    $parsedProxyUrl['query'],   // <-- already encoded, gets re-normalised
    null
);

HTMLPurifier re-normalises percent-encoding in that query string. Percent-encoded characters that belonged to the original image URL are decoded one level too far, so the src the server receives is no longer byte-identical to the string the HMAC was generated over.

Evidence

I recomputed the HMAC server-side for the candidate forms of src, using the same algorithm as ProxyHmacGenerator
(HMAC-SHA512, key = hash('sha512', $secret . '|' . $id . 'a')), and compared against the hmac value present in the emitted proxy URL.

For an image URL whose query contained x-signature=%2F…%2B…%3D:

Candidate src HMAC matches?
as received by PHP after decoding (%2F/) no
fully double-encoded form no
original form, single encoding preserved (%2F, %2B, %3D) yes

So the HMAC was generated over the URL with %2F intact, while the emitted src parameter contains %2F where it should contain %252F.

Notably, %2B and %3D in the same parameter value were correctly double-encoded (%252B, %253D) — only the encoded slash came out under-encoded. After PHP decodes the query parameter, %2F becomes /, the strings differ by that one character, and hash_equals() fails.

Suggested fix

Do not pass the already-encoded query string back through HTMLPurifier_URI. Either build the HTMLPurifier_URI from unencoded components and let it encode once, or bypass re-normalisation for the generated proxy URL.

Server configuration

  • Nextcloud: 34.0.2
  • Mail: 5.10.12
  • PHP: 8.3.32
  • ezyang/htmlpurifier: 4.19.0
  • Database: PostgreSQL
  • Deployment: Nextcloud All-in-One
  • Browser: Chromium-based

Notes

The image proxy has a second failure mode that is worth being aware of when triaging reports of this kind: if passesStrictCookieCheck() fails, ProxyController silently substitutes blocked-image.png and writes no log entry at all. That makes "images do not load" reports hard to diagnose from server logs alone — the absence of log lines does not rule the proxy out.

Metadata

Metadata

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions