CVE-2026-16354
Overview
Background
- `imgRequest`
- The ImageLib object that owns the network load of a single image, holding the
nsITimedChanneland shared image data behind one or more proxies. - `imgRequestProxy`
- A per-consumer handle onto an
imgRequestthat exposes image state (including redirect provenance) to callers such as the canvas/CORS machinery. - Cross-origin redirect tainting
- The rule that an image whose load chain crossed an origin boundary must taint a
<canvas>so its pixels cannot be read back by script. - `nsITimedChannel`
- The channel-side object that records timing and redirect metadata, including
GetAllRedirectsSameOriginIgnoringInternal, and is only alive during the load.
Root Cause Analysis
imgRequestProxy::GetHadCrossOriginRedirects computed its answer by calling TimedChannel() and querying GetAllRedirectsSameOriginIgnoringInternal on it, but the timed channel is set to nullptr in imgRequest::OnStopRequest as soon as the load finishes. Once the channel was gone, the getter fell through to its *aHadCrossOriginRedirects = false default, silently reporting “no cross-origin redirect” for any image whose load had already completed — the common case by the time a <canvas> reads pixels. The security invariant that a load chain containing a cross-origin redirect must always taint the canvas was therefore violated after OnStopRequest.
The fix latches the redirect result into a persistent mHadCrossOriginRedirects member inside OnStopRequest (before dropping mTimedChannel) and adds imgRequest::HadCrossOriginRedirects(), which reads the live channel while it exists and otherwise returns the latched value. The proxy now delegates to GetOwner()->HadCrossOriginRedirects(), so the answer survives the lifetime of the channel.
nsITimedChannel) that is destroyed at load completion, so the answer defaulted to the insecure false afterward. The fix persists the value in imgRequest at OnStopRequest time and reads it from there, decoupling the taint decision from the channel’s lifetime.Attack Path
- Host a redirecting image The attacker serves an image URL that redirects across an origin boundary (including a same-origin to cross-origin to same-origin bounce-back) to bring cross-origin pixel data into a same-origin-looking response.
- Load into the victim page
A victim page draws that image; while loading,
mTimedChannelcorrectly reflects the cross-origin redirect. - Wait for load completion
After
imgRequest::OnStopRequestnullsmTimedChannel, the redirect provenance is no longer retrievable from the channel. - Draw and read back
Script draws the now-loaded image to a
<canvas>;GetHadCrossOriginRedirectsreturnsfalse, so the canvas is not tainted. - Exfiltrate pixels
The script calls
getImageData/toDataURLand reads cross-origin pixel data that should have been blocked.
Impact Assessment
mTimedChannel is dropped) before the canvas read, and that the load chain contain a real cross-origin redirect that is not merely an internal/service-worker substitution. Impact is information disclosure of cross-origin image contents; no memory corruption is implied by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifimage/imgRequest.cpp |
modified | |
GetHadCrossOriginRedirectsimage/imgRequestProxy.cpp |
modified | |
ifimage/imgRequestProxy.cpp |
modified |
Files Changed
image/imgRequest.cppimage/imgRequest.himage/imgRequestProxy.cpptesting/web-platform/meta/fetch/images/canvas-remote-read-remote-image-redirect.html.ini
Audit Directions
- Ephemeral-object-derived security stateAudit any security or CORS decision computed from
nsITimedChannel, the underlyingnsIChannel, or other load-time objects that are released atOnStopRequest; such values must be latched into the owning object. - Default-to-insecure fallbacksFlag getters whose “object missing” path defaults a taint/permission flag to
false/allow, since a lifetime change silently converts them into a bypass. - Redirect provenance across proxiesVerify that
imgRequestProxyconsumers read redirect and principal provenance from the owningimgRequest, and that internal versus real cross-origin redirects are distinguished consistently wherever tainting is decided.
Patch
diff --git a/image/imgRequest.cpp b/image/imgRequest.cpp
index a7f7a8d8036..5b3676a9afa 100644
--- a/image/imgRequest.cpp
+++ b/image/imgRequest.cpp
@@ -622,6 +622,22 @@ bool imgRequest::HadInsecureRedirect() const {
return mHadInsecureRedirect;
}
+bool imgRequest::HadCrossOriginRedirects() const {
+ // While the channel is still around (during the load) read the authoritative
+ // value from it; afterwards fall back to the value latched in OnStopRequest.
+ // Ignore internal redirects (e.g. a service worker substituting a same-origin
+ // response for a cross-origin request): those are not cross-origin data flow
+ // and must not taint. Real cross-origin redirects (incl. bounce-backs) count.
+ if (mTimedChannel) {
+ bool allRedirectsSameOrigin = false;
+ return NS_SUCCEEDED(
+ mTimedChannel->GetAllRedirectsSameOriginIgnoringInternal(
+ &allRedirectsSameOrigin)) &&
+ !allRedirectsSameOrigin;
+ }
+ return mHadCrossOriginRedirects;
+}
+
/** nsIRequestObserver methods **/
NS_IMETHODIMP
@@ -817,6 +833,18 @@ imgRequest::OnStopRequest(nsIRequest* aRequest, nsresult status) {
progressTracker->SyncNotifyProgress(progress);
}
+ // Store whether the load involved a cross-origin redirect before we drop the
+ // timed channel. Internal redirects (e.g. a service worker serving a
+ // same-origin response for a cross-origin request) are ignored so we don't
+ // over-taint; real cross-origin redirects (incl. bounce-backs) still count.
+ if (mTimedChannel) {
+ bool allRedirectsSameOrigin = false;
+ mHadCrossOriginRedirects =
+ NS_SUCCEEDED(mTimedChannel->GetAllRedirectsSameOriginIgnoringInternal(
+ &allRedirectsSameOrigin)) &&
+ !allRedirectsSameOrigin;
+ }
+
mTimedChannel = nullptr;
return NS_OK;
}
diff --git a/image/imgRequest.h b/image/imgRequest.h
index 33497c94782..febd6d65529 100644
--- a/image/imgRequest.h
+++ b/image/imgRequest.h
@@ -165,6 +165,10 @@ class imgRequest final : public nsIThreadRetargetableStreamListener,
nsITimedChannel* GetTimedChannel() const { return mTimedChannel; }
+ /// Returns true if any redirect in the chain that loaded this image was
+ /// cross-origin.
+ bool HadCrossOriginRedirects() const;
+
imgCacheValidator* GetValidator() const { return mValidator; }
void SetValidator(imgCacheValidator* aValidator) { mValidator = aValidator; }
@@ -285,6 +289,8 @@ class imgRequest final : public nsIThreadRetargetableStreamListener,
bool mIsCrossSiteNoCORSRequest;
bool mShouldReportRenderTimeForLCP;
+ // True if any redirect in the chain that loaded this image was cross-origin.
+ bool mHadCrossOriginRedirects = false;
// SVGs can't be OffMainThread for example
bool mOffMainThreadData = false;
diff --git a/image/imgRequestProxy.cpp b/image/imgRequestProxy.cpp
index 1067f9ba827..9a41d868e02 100644
--- a/image/imgRequestProxy.cpp
+++ b/image/imgRequestProxy.cpp
@@ -873,17 +873,8 @@ imgRequestProxy::GetImagePrincipal(nsIPrincipal** aPrincipal) {
NS_IMETHODIMP
imgRequestProxy::GetHadCrossOriginRedirects(bool* aHadCrossOriginRedirects) {
- *aHadCrossOriginRedirects = false;
-
- nsCOMPtr<nsITimedChannel> timedChannel = TimedChannel();
- if (timedChannel) {
- bool allRedirectsSameOrigin = false;
- *aHadCrossOriginRedirects =
- NS_SUCCEEDED(timedChannel->GetAllRedirectsSameOriginIgnoringInternal(
- &allRedirectsSameOrigin)) &&
- !allRedirectsSameOrigin;
- }
-
+ *aHadCrossOriginRedirects =
+ GetOwner() ? GetOwner()->HadCrossOriginRedirects() : false;
return NS_OK;
}
diff --git a/testing/web-platform/meta/fetch/images/canvas-remote-read-remote-image-redirect.html.ini b/testing/web-platform/meta/fetch/images/canvas-remote-read-remote-image-redirect.html.ini
deleted file mode 100644
index 33630ce7eba..00000000000
--- a/testing/web-platform/meta/fetch/images/canvas-remote-read-remote-image-redirect.html.ini
+++ /dev/null
@@ -1,4 +0,0 @@
-[canvas-remote-read-remote-image-redirect.html]
- [Load a no-cors image from a same-origin URL that redirects to a cross-origin URL that redirects to the initial origin]
- expected: FAIL
-
Regression Test / PoC
diff --git a/testing/web-platform/meta/fetch/images/canvas-remote-read-remote-image-redirect.html.ini b/testing/web-platform/meta/fetch/images/canvas-remote-read-remote-image-redirect.html.ini deleted file mode 100644 index 33630ce7eba..00000000000 --- a/testing/web-platform/meta/fetch/images/canvas-remote-read-remote-image-redirect.html.ini +++ /dev/null @@ -1,4 +0,0 @@ -[canvas-remote-read-remote-image-redirect.html] - [Load a no-cors image from a same-origin URL that redirects to a cross-origin URL that redirects to the initial origin] - expected: FAIL -