Steps to reproduce
- 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
- Open the message and click Show images
- 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.
Steps to reproduce
<img>whosesrcquery string contains a percent-encoded character, for example a signed CDN URL:https://cdn.example.com/img.jpg?sig=%2Fabc%2Bdef%3D&t=123This 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 tonextcloud.logat default log level.In
ProxyController::proxy(), 401 has exactly one source:Root cause
In
lib/Service/HtmlPurify/TransformURLScheme.phpthe proxy URL is built withIURLGenerator::linkToRoute(), which percent-encodes thesrcparameter correctly. The resulting query string is then passed back throughHTMLPurifier_URI: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
srcthe 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 asProxyHmacGenerator(HMAC-SHA512, key =
hash('sha512', $secret . '|' . $id . 'a')), and compared against thehmacvalue present in the emitted proxy URL.For an image URL whose query contained
x-signature=%2F…%2B…%3D:src%2F→/)%2F,%2B,%3D)So the HMAC was generated over the URL with
%2Fintact, while the emittedsrcparameter contains%2Fwhere it should contain%252F.Notably,
%2Band%3Din the same parameter value were correctly double-encoded (%252B,%253D) — only the encoded slash came out under-encoded. After PHP decodes the query parameter,%2Fbecomes/, the strings differ by that one character, andhash_equals()fails.Suggested fix
Do not pass the already-encoded query string back through
HTMLPurifier_URI. Either build theHTMLPurifier_URIfrom unencoded components and let it encode once, or bypass re-normalisation for the generated proxy URL.Server configuration
Notes
The image proxy has a second failure mode that is worth being aware of when triaging reports of this kind: if
passesStrictCookieCheck()fails,ProxyControllersilently substitutesblocked-image.pngand 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.