← All reports

WebGPU importExternalTexture origin-clean bypass in Safari

HighWebCore WebGPU bindingsCrossOrigin

CVE: CVE-2026-43700 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may disclose sensitive user information Apple's description: A cross-origin issue was addressed with improved tracking of security origins. Credit: Vitaly Simonovich, Christian Meurer Xavier

67b563b | Bugzilla 315368

High. Not a memory-safety bug — a nine-line lambda restoring an origin check that a performance cache had routed around. The primitive is exactly one thing, and it is the thing the same-origin policy exists to prevent: script reading the decoded pixels of a video the document was never entitled to see.

Video frames are the one class of pixel data a browser hands to a page without the page ever having downloaded them — the media pipeline decodes them, the compositor presents them, and script is supposed to be able to display them without being able to read them. Every graphics API that touches media has to re-litigate that boundary at its own ingress point: 2D canvas does it by tainting on drawImage, WebGL by rejecting cross-origin texImage2D uploads, and WebGPU by validating in GPUDevice::importExternalTexture. The invariant is uniform even when the enforcement is not — a sampleable handle over a video frame must never be returned to script unless the underlying resource is same-origin or CORS-approved with respect to the document.

The angle: A page can obtain a shader-sampleable handle over a cross-origin video's decoded frames and read the pixels back, disclosing authenticated video content from any site the victim is logged into.

Source/WebCore/Modules/WebGPU/GPUDevice.cpp

ExceptionOr<Ref<GPUExternalTexture>> GPUDevice::importExternalTexture(GPUExternalTextureDescriptor&& externalTextureDescriptor)
{
+#if ENABLE(VIDEO)
+ auto checkVideoElementOriginTaint = [this](const HTMLVideoElement& videoElement) -> std::optional<Exception> {
+ RefPtr context = scriptExecutionContext();
+ if (RefPtr securityOrigin = context ? context->securityOrigin() : nullptr; securityOrigin && videoElement.taintsOrigin(*securityOrigin))
+ return Exception { ExceptionCode::SecurityError, "GPUDevice.importExternalTexture: Cross origin external videos are not allowed in WebGPU"_s };
+ return std::nullopt;
+ };
+#endif
+
#if ENABLE(VIDEO) && PLATFORM(COCOA)
if (RefPtr externalTexture = externalTextureForDescriptor(externalTextureDescriptor)) {
externalTexture->undestroy();
...
+ if (auto exception = checkVideoElementOriginTaint(videoElementRef))
+ return WTF::move(*exception);
+
m_videoElementToExternalTextureMap.remove(videoElementRef);
if (auto optionalMediaIdentifier = externalTextureDescriptor.mediaIdentifier()) {
m_backing->updateExternalTexture(externalTexture->backing(), *optionalMediaIdentifier);
...
Ref videoElementRef = externalTextureDescriptor.source;
#endif
+ if (auto exception = checkVideoElementOriginTaint(videoElementRef))
+ return WTF::move(*exception);
+
WeakPtr videoElementPtr = videoElementRef.ptr();
m_videoElementToExternalTextureMap.set(videoElementRef, externalTexture.get());
m_previouslyImportedExternalTexture.first = videoElementRef.ptr();

LayoutTests/fast/webgpu/regression/repro_315368b.html

+ video.requestVideoFrameCallback(() => {
+ try {
+ let et1 = device.importExternalTexture({source: video}); // new texture path
+ let et2 = device.importExternalTexture({source: video}); // cached texture path (Cocoa)
+ log('Pass');
+ } catch (e) {
+ log('FAIL: ' + e.message);
+ }
+ ...
+ });

The functional change is confined to one function in one file. A local lambda checkVideoElementOriginTaint, guarded by #if ENABLE(VIDEO), pulls the owning document's SecurityOrigin off the ScriptExecutionContext and asks the source element whether it taints that origin. On a positive answer it returns a populated std::optional<Exception> carrying ExceptionCode::SecurityError and the message "GPUDevice.importExternalTexture: Cross origin external videos are not allowed in WebGPU"; otherwise std::nullopt. #include "SecurityOrigin.h" is added to make taintsOrigin's parameter type complete at the call site.

The lambda is then invoked at the two points that matter — both of the function's returns-a-texture paths:

  importExternalTexture(descriptor)
    │
    ├─ [COCOA] externalTextureForDescriptor(descriptor) → hit?
    │     ├─ undestroy()
    │     ├─ ✚ checkVideoElementOriginTaint(videoElementRef)   ← added
    │     ├─ m_videoElementToExternalTextureMap.remove(...)
    │     ├─ m_backing->updateExternalTexture(backing, *mediaIdentifier)
    │     └─ return cached Ref<GPUExternalTexture>
    │
    └─ miss / non-Cocoa: create new GPUExternalTexture
          ├─ ✚ checkVideoElementOriginTaint(videoElementRef)   ← added
          ├─ m_videoElementToExternalTextureMap.set(...)
          └─ return new Ref<GPUExternalTexture>

Placement on the cache-hit path is deliberate: the check sits after externalTexture->undestroy() but before the map mutation and before m_backing->updateExternalTexture(externalTexture->backing(), *optionalMediaIdentifier) re-binds the backing to the element's current media source. Rejecting before the re-bind means a rejected import leaves the cached texture pointing where it already pointed, rather than at the newly-selected resource.

The remaining four files are LayoutTests. repro_315368.html imports a same-origin data: video once, exercising the creation path; repro_315368b.html imports the same element twice inside one requestVideoFrameCallback, so the second call lands on the Cocoa cache-hit path. Both expect CONSOLE MESSAGE: Pass. Note the direction of that assertion — the shipped coverage pins down that the new check does not reject legitimate same-origin imports, which is the regression risk a newly-guarded fast path carries.

Origin-clean media and tainting. WebKit tracks, per media resource, whether reading its decoded pixels would expose data the embedding document is not entitled to see. HTMLMediaElement::taintsOrigin(const SecurityOrigin&) is that predicate: it returns true when the element's currently-loaded resource is cross-origin and not CORS-approved with respect to the passed origin. It is the same predicate that flips a <canvas> to "tainted" after a drawImage of a cross-origin image, after which getImageData and toDataURL throw.

SecurityOrigin and the script context. A SecurityOrigin is the scheme/host/port tuple identifying a document's security context. ScriptExecutionContext::securityOrigin() returns the origin of the document that owns the GPUDevice — i.e. the origin whose entitlements the import is being checked against.

External textures in WebGPU. GPUDevice.importExternalTexture({source: videoElement}) produces a GPUExternalTexture, a WebGPU object that exposes a video frame to shaders as WGSL's texture_external type, sampled with textureSampleBaseClampToEdge. Unlike an ordinary GPUTexture, its contents are not uploaded by the page — they are owned by the media pipeline, and the external texture is a view onto them. requestVideoFrameCallback is the <video> API that fires once a new decoded frame is presentable, and is the natural point at which a page performs the import.

mediaIdentifier and the GPU-process backing. GPUExternalTextureDescriptor carries an opaque mediaIdentifier that names the media sample source to the GPU-process side of WebGPU. WebGPU::Device::updateExternalTexture(backing, identifier) re-binds an already-created external-texture backing to a media source, so the same WebCore-side object can be re-pointed at different frames without a fresh allocation.

The Cocoa import cache. On PLATFORM(COCOA), GPUDevice memoizes imports in m_videoElementToExternalTextureMap and m_previouslyImportedExternalTexture so that repeated imports of the same HTMLVideoElement reuse one GPUExternalTexture instead of allocating per frame. externalTextureForDescriptor performs that lookup, and on a hit importExternalTexture takes an early return.

ExceptionOr<T>. WebCore's return type for bindings that may throw. Returning Exception { ExceptionCode::SecurityError, ... } from a function declared ExceptionOr<Ref<GPUExternalTexture>> surfaces to JavaScript as a SecurityError DOMException instead of a value.

The root cause is an authorization decision cached on a key that does not determine the answer. The cache is keyed on element identitym_videoElementToExternalTextureMap.set(videoElementRef, ...), .remove(videoElementRef) — while the property being enforced is a property of the element's current resource, and an HTMLVideoElement is a mutable container: its src can be re-pointed over its lifetime, and taintsOrigin() reports on whatever resource is loaded at the moment it is called. A validation performed once, at insertion time, is therefore not still true at lookup time.

  t0  element created, src = same-origin.mp4
      │
      ▼
  t1  importExternalTexture(el)   ── cache MISS ── validate ✓ ── insert ── return tex
      │
  t2  el.src = https://victim.example/private.mp4      ← provenance changes
      │                                                  cache key unchanged
      ▼
  t3  importExternalTexture(el)   ── cache HIT  ── (no validate) ──┐
                                                                   │
              m_backing->updateExternalTexture(tex, mediaIdentifier@t2)
                                                                   │
                                       return SAME tex, now bound to victim frames

Follow the arrows: the only thing carried across t1t3 is the element pointer, and that is precisely the thing that did not change. Everything that determines whether the import is legal — the loaded resource, its origin, its CORS disposition — is re-selected at t2 and re-plumbed at t3 by updateExternalTexture with the new mediaIdentifier. The cache-hit path returns the same Ref<GPUExternalTexture> it returned at t1, but the frames behind it are no longer the frames that were authorized. The commit message states the omission precisely — "Add missing cross-origin check when the cache is reached" — and the patch as landed installs the check on both return paths, with no other taintsOrigin call anywhere in the function.

What makes this a full read primitive rather than a display-only leak is that WebGPU has no notion of a write-only texture. Once script holds a texture_external, the ordinary API surface completes the chain:

  1. Bind the external texture into a bind group and sample it in a fragment shader with textureSampleBaseClampToEdge.
  2. Render to an attacker-owned GPUTexture — a plain color attachment the page allocated itself, with no taint tracking of its own.
  3. copyTextureToBuffer into a GPUBuffer created with MAP_READ.
  4. mapAsync and read the pixels out of the resulting ArrayBuffer.

None of those four steps involves any origin check, because each of them is operating on data the page is presumed to already own. The entire security model for external textures rests on step 0 — the import — refusing to hand over the handle in the first place. The disclosure lands in the WebContent process, in the JS heap of the attacker's page; the frames are produced by the media and GPU pipeline but delivered through the normal WebGPU readback path, so no sandbox boundary is crossed and no separate escape is needed. This is a same-origin policy bypass within the renderer, and it composes with a sandbox escape rather than substituting for one.

The consequence is the classic cross-origin media disclosure. Any video the victim's browser can fetch with ambient credentials — private streams, personal media behind a session cookie — becomes readable, pixel-accurate, at whatever frame rate the attacking page chooses to import. The read is passive from the victim's perspective: visiting the attacker's page is sufficient, since the attacker controls both the element whose src is re-pointed and the shader that samples the result.

The fix restores the invariant by making the authorization a property of the use, not of the cache entry. checkVideoElementOriginTaint re-derives the origin taint on every call — the lambda re-fetches scriptExecutionContext()->securityOrigin() and re-asks videoElement.taintsOrigin() each time it runs — so the cache hit at t3 above now re-evaluates against the resource loaded at t2 and returns SecurityError before updateExternalTexture gets the chance to re-bind the backing. The cache continues to memoize the object; it no longer memoizes the permission.

A capability cache keyed on a DOM element inherited its authorization from the resource loaded at insertion time, while the element was free to load a different-origin resource before the next hit.

The interesting part is not the missing check but where it was missing. Origin-clean enforcement in WebKit is historically attached to the moment a resource is first consumed — canvas taints at drawImage, WebGL rejects at texImage2D — and that works precisely because the consuming call and the security decision are the same event. External-texture caching breaks that coupling for the first time: the decision is made when the element enters m_videoElementToExternalTextureMap, while the resource the texture actually exposes is re-selected later by updateExternalTexture(..., *optionalMediaIdentifier). Any performance cache that memoizes a derived capability keyed on a DOM node inherits this hazard, because DOM nodes are mutable containers for resources of differing provenance while the cached capability is not re-validated. Identity is not provenance.