WebGPU importExternalTexture origin-clean bypass in Safari
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
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
LayoutTests/fast/webgpu/regression/repro_315368b.html
Patch Details
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.
Background
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.
Analysis
The root cause is an authorization decision cached on a key that does not determine the answer. The cache is keyed on element identity — m_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 t1→t3 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:
- Bind the external texture into a bind group and sample it in a fragment shader with
textureSampleBaseClampToEdge. - Render to an attacker-owned
GPUTexture— a plain color attachment the page allocated itself, with no taint tracking of its own. copyTextureToBufferinto aGPUBuffercreated withMAP_READ.mapAsyncand read the pixels out of the resultingArrayBuffer.
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.
Insight
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.
Audit directions
-
Security validation on the slow path, skipped by the cache hit. The invariant is that every return path yielding a capability must re-derive its authorization rather than inherit it from the path that populated the cache. Narrow: audit the remaining WebGPU ingress points in
Source/WebCore/Modules/WebGPUthat consult a cache or anm_previously*fast path before validating — start fromGPUDevice::externalTextureForDescriptorand any other member ofGPUDevice/GPUQueuethat returns early on a lookup hit; the tell is areturnstatement sitting textually above the block containing the validation call. Wider: the same class appears in any WebCore memoization keyed on a DOM node whose underlying resource is mutable — canvasoriginCleanpropagation, WebGL texture-upload paths caching per-element decoder state, ImageBitmap creation caches; in code-search results the tell is aHashMapor pair keyed on an elementRef/WeakPtrwhose value is a GPU- or decode-side resource. Widest: the reusable invariant is that a cached authorization decision is only valid while the subject it was computed over is immutable — carry it into any codebase with cached capability checks, including HTTP caches keyed without aVaryon the authorizing header, permission and DNS caches, and Chromium'sVideoFrameimport paths. The audit question in each is: what can change about the subject between cache insert and cache hit, and does the hit re-check it? -
Current-state predicates treated as stable properties.
taintsOrigin()reports on the resource loaded right now, but consumers routinely treat its answer as an attribute of the element. Narrow: grep WebCore fortaintsOrigin(and, for each caller, check whether the value derived from that call — texture, ImageBitmap, decoded surface, cached handle — outlives the call and can later be re-pointed at a different resource; start with the media-consuming graphics bindings underModules/WebGPUandplatform/graphics, where the tell is ataintsOrigincall whose result gates an object subsequently stored in a member container. Wider: the shape covers any once-computed policy flag on a mutable DOM subject — canvassetOriginTaintedinteracting withsrcmutation, MediaStream track replacement,SourceBufferappends that swap the effective resource mid-stream; the tell is a boolean or gate evaluated at attach time with no re-evaluation hook on resource change. Widest: the principle — a permission decision about a mutable subject must be re-evaluated at each use, or the subject must be pinned at decision time — applies to file-descriptor and path revalidation after re-open, capability handles in OS sandboxes, and session-scoped authorization caches in server codebases. -
Uniformity of origin-clean enforcement across all external-image ingress into WebGPU, not just
importExternalTexture. Narrow: trace WebGPU's other external-source entry point,GPUQueue.copyExternalImageToTexture, plus theGPUCanvasContextconfigure/present paths inSource/WebCore/Modules/WebGPU, and confirm each accepts only sources that failtaintsOriginagainst the context'sSecurityOrigin; the tell is any source-union handler (video element, canvas,ImageBitmap,OffscreenCanvas) that reaches a backing upload without aSecurityErrorbranch. Wider: compare the enforcement matrix against the older graphics APIs that already solved this — WebGL's video-upload rejection, 2D canvas tainting — and look for source types accepted by the newer API but absent from the older API's check table; a coverage gap in the union of accepted source types is the recurring shape when a new API reuses an old policy. Widest: the general form is a new API surface accepting a superset of the source types the existing policy was written for. Audit any codebase where a validation predicate written for API v1 is reused verbatim by API v2 over a broader input domain; the tell is a validation helper whose parameter type is narrower than the caller's accepted union.