86d0164f83073aa858779f68fdf4804eacf1f676 Generate structured deserialization exposure checks from IDL
Triage note: SerializedScriptValue::isTypeExposedToGlobalObject previously returned true for every type in any non-AudioWorklet context; the fix adds per-interface isInterfaceExposed<JSWrapper>()/isExposedInGlobalObject() checks (honoring Exposed, SecureContext, EnabledBySetting) for all global objects and marks the relevant IDL interfaces Serializable/Transferable. This stops deserializing objects of interfaces not exposed in the receiving context (e.g. secure-context-only or feature-gated types) via postMessage/IndexedDB.
The bug at a glance
Triage labels this a PolicyBypass (structured-clone deserialization materializing interfaces not exposed in the target global). The diff only PARTIALLY supports that: it is overwhelmingly infrastructure — it un-gates the IDL Serializable/Transferable extended attributes (removing “unsupported”: true), generates a per-wrapper isExposedInGlobalObject() honoring Exposed/SecureContext/EnabledBySetting/EnabledForContext, and rewrites SerializedScriptValue::isTypeExposedToGlobalObject to call isInterfaceExposed per tag. But the new isInterfaceExposed() still early-returns true for every non-AudioWorklet global (an explicit FIXME), so for Window/Worker contexts behaviour is unchanged and no exposure is newly enforced. The only behavioural change today is in AudioWorklet deserialization, which moves from “reject all interface types” to “accept interfaces actually exposed there (e.g. MessagePort)” — a scoped correctness/relaxation. I judge the diff does NOT yet support the general cross-context PolicyBypass closure the triage describes; the broad protection is scaffolded but gated. Flagging for orchestrator review. Severity as a standalone fix is low/defense-in-depth groundwork, not the medium cross-context bypass claimed.
The interesting part is what the patch does NOT turn on: it builds the whole machinery to reject non-exposed interfaces on deserialize, then deliberately short-circuits it to true for every global except AudioWorklet, leaving the real tightening for a follow-up.
Root cause
The invariant the patch is working toward: structured-clone deserialization (postMessage, BroadcastChannel, IndexedDB, etc.) must not materialize a platform object whose interface is not exposed in the receiving global per its Exposed/SecureContext/EnabledBySetting/EnabledForContext attributes. Pre-patch, SerializedScriptValue::isTypeExposedToGlobalObject was guarded entirely by #if ENABLE(WEB_AUDIO): for a non-AudioWorklet global it returned true for every tag, and for an AudioWorklet it returned true only for built-in JS tags (ArrayTag…ErrorTag) and false for all interface tags. So the sole pre-existing enforcement was “AudioWorklet gets built-ins only”; Window/Worker/DedicatedWorker deserialized any serializable tag unconditionally, with one ad-hoc exception (CryptoKeyTag checked context->isSecureContext() inside CloneDeserializer).
The patch factors isAudioWorkletGlobalScope() out and introduces template isInterfaceExposed(globalObject). Crucially its body is: if (!isAudioWorkletGlobalScope(globalObject)) return true; return JSWrapper::isExposedInGlobalObject(uncheckedDowncast<JSDOMGlobalObject>(globalObject)); with a FIXME that relying on isExposedInGlobalObject() for every global still needs null-scriptExecutionContext() handling and a JSIDBSerializationGlobalObject exemption. isTypeExposedToGlobalObject is rewritten as a tag switch delegating each interface tag to isInterfaceExposed (JSFile, JSBlob, JSCryptoKey, JSRTCCertificate, JSMediaStreamTrack, JSWebCodecs*, etc.), and ReadableStream/WritableStream/TransformStream return !isAudioWorkletGlobalScope(globalObject). Because isInterfaceExposed returns true for all non-AudioWorklet globals, the net effect for Window/Worker is identical to before; only AudioWorklet now consults the generated isExposedInGlobalObject(), which flips AudioWorklet from “no interface objects at all” to “interfaces whose Exposed set includes AudioWorklet (e.g. MessagePort, now marked Transferable).”
The generation side: CodeGeneratorJS.pm adds IsSerializableOrTransferable() and, for such interfaces, emits static bool JS::isExposedInGlobalObject(JSDOMGlobalObject&) built from GenerateRuntimeEnableConditionalString (the generated JSTestSerializable::isExposedInGlobalObject shows the full chain: isSecureContext() && (JSDOMWindowBase||JSDedicatedWorkerGlobalScope) && settingsValues().testSettingEnabled && enabledForContext(…)). IDLAttributes.json drops “unsupported”: true from Serializable and Transferable, and dozens of IDL files are annotated Serializable/Transferable (CryptoKey, DOMPoint/Rect/Matrix(ReadOnly), DOMQuad, Blob/File/FileList, ImageData/ImageBitmap, MessagePort, OffscreenCanvas, ReadableStream/WritableStream/TransformStream, WebCodecs*, MediaStreamTrack(+Handle), RTC*). WorkletGlobalScope::isAudioWorkletGlobalScope() loses its #if ENABLE(WEB_AUDIO) so the discriminator is always available. These objects are JS wrappers (JSDOMWrapper) over RefCounted impls; no lifetime bug is involved — this is exposure/authorization plumbing.
Key code
Enforcement is wired but still returns true for every non-AudioWorklet global (FIXME-gated)
template<typename JSWrapper>
static bool isInterfaceExposed(JSC::JSGlobalObject& globalObject)
{
// FIXME: Rely on JSWrapper::isExposedInGlobalObject() for every global object. This needs a null
// scriptExecutionContext() to be handled and JSIDBSerializationGlobalObject to be exempted.
if (!isAudioWorkletGlobalScope(globalObject))
return true;
return JSWrapper::isExposedInGlobalObject(uncheckedDowncast<JSDOMGlobalObject>(globalObject));
}
Fix approach
The patch supplies the machinery for exposure-checked deserialization without yet enabling it broadly. New holder of truth: the generated per-wrapper isExposedInGlobalObject(JSDOMGlobalObject&), produced for any interface marked Serializable/Transferable and composed from the same runtime-enable conditional string that gates normal interface exposure (Exposed set, SecureContext, EnabledBySetting, EnabledForContext). isTypeExposedToGlobalObject becomes a pure tag→wrapper dispatch, and the CryptoKey ad-hoc secure-context check in CloneDeserializer is kept with a FIXME “Remove once isInterfaceExposed() covers every global object.” The deliberate semantic limit: isInterfaceExposed short-circuits to true unless the receiving global is an AudioWorklet, so the GC/hot deserialization path for Window/Worker collapses to its pre-patch behaviour; only AudioWorklet now honours per-interface exposure. The intended full tightening (reject non-exposed interfaces in all globals) is explicitly deferred.
Patch walkthrough
Source/WebCore/bindings/js/SerializedScriptValue.cpp— Splits out isAudioWorkletGlobalScope(), adds the isInterfaceExposed template (currently true for non-AudioWorklet), rewrites isTypeExposedToGlobalObject as a per-tag dispatch, and keeps the CryptoKey secure-context check with a removal FIXME.Source/WebCore/bindings/scripts/CodeGeneratorJS.pm— Adds IsSerializableOrTransferable() and emits static isExposedInGlobalObject() for such interfaces from the runtime-enable conditional; also fixes EnabledForContext to work at interface scope.Source/WebCore/bindings/scripts/IDLAttributes.json— Removes “unsupported”: true from Serializable and Transferable, making the attributes usable on interfaces.Numerous Source/WebCore/**/*.idl (CryptoKey, DOMPoint/Rect/Matrix, Blob/File/FileList, ImageData/ImageBitmap, MessagePort, OffscreenCanvas, streams, WebCodecs*, MediaStreamTrack*, RTC*)— Annotates the serializable/transferable interfaces so codegen produces their isExposedInGlobalObject().Source/WebCore/bindings/scripts/test/* (TestSerializable, JSExposedStar, JSTestSerializable)— New/updated binding-generator test baselines showing the generated isExposedInGlobalObject() output.Source/WebCore/worklets/WorkletGlobalScope.h— Makes isAudioWorkletGlobalScope() unconditional so the serializer discriminator is always available.
Background
Structured clone / SerializedScriptValue — The algorithm behind postMessage, BroadcastChannel, history.state and IndexedDB value storage; it serializes JS values to a tagged byte stream and later deserializes them into a (possibly different) global object.
Exposed / SecureContext / EnabledBySetting / EnabledForContext — Web IDL extended attributes controlling in which globals and conditions an interface exists; e.g. CryptoKey is SecureContext, many media types are Exposed=(Window,DedicatedWorker) only.
isExposedInGlobalObject() — A new generated per-wrapper predicate that evaluates the interface’s runtime-enable conditional against a specific JSDOMGlobalObject, used to decide whether deserialization may materialize that interface there.
AudioWorkletGlobalScope — A restricted worklet global that historically received only built-in JS types via structured clone; the only context whose deserialization this patch actually changes.
Serializable / Transferable IDL attributes — Mark an interface as cloneable/transferable per the HTML structured-data spec; previously flagged “unsupported” in WebKit’s generator and un-gated here so codegen can emit the exposure predicate.
JSIDBSerializationGlobalObject — A special global used for IndexedDB value (de)serialization with no real script execution context, which is why the general isExposedInGlobalObject() rollout is deferred (it needs a null-context exemption).
Vulnerability window
- Setup — A value containing a platform object (e.g. a WebCodecs frame, MediaStreamTrack, CryptoKey) is structured-cloned via postMessage/IndexedDB.
- Transfer — The bytes reach a target global that may differ in exposure (worker vs window, secure vs insecure, feature-flag on/off).
- Deserialize (pre-patch) — isTypeExposedToGlobalObject returns true for any non-AudioWorklet target, so the object is materialized regardless of exposure (except the ad-hoc CryptoKey secure-context check).
- Deserialize (post-patch) — Window/Worker behaviour is unchanged; AudioWorklet now consults the per-interface predicate and accepts exposed interfaces (e.g. MessagePort) while still rejecting unexposed ones.
- Future — When the FIXME is resolved, isInterfaceExposed will enforce exposure for every global, finally closing the general bypass.
Proof of concept
The only artifacts are binding-generator test baselines (TestSerializable/JSTestSerializable/JSExposedStar), not a web regression test. The snippet shows how the generated exposure predicate composes SecureContext + global-kind + EnabledBySetting + EnabledForContext. A web-facing PoC for the full bypass is NOT derivable from this diff because the Window/Worker path still returns true (enforcement deferred); any such PoC is (inferred) future behaviour.
// Generated binding baseline demonstrating the predicate shape (JSTestSerializable):
bool JSTestSerializable::isExposedInGlobalObject(JSDOMGlobalObject& globalObject)
{
return ((&globalObject)->scriptExecutionContext()->isSecureContext()
&& (((&globalObject))->inherits<JSDOMWindowBase>() || ((&globalObject))->inherits<JSDedicatedWorkerGlobalScope>())
&& (&globalObject)->scriptExecutionContext()->settingsValues().testSettingEnabled
&& TestSerializable::enabledForContext(*(&globalObject)->scriptExecutionContext()));
}
Exploitation
- Reach — Web content (or a compromised process) that can postMessage/IndexedDB-store a serialized value into a differently-exposed target global.
- Trigger (today) — Only an AudioWorklet target shows a behavioural delta; transferring an exposed interface (e.g. MessagePort) into an AudioWorklet now succeeds where it was rejected.
- Primitive (intended, not yet active) — When fully enabled, materializing an interface not exposed in the receiving context would be blocked; pre-patch that materialization was possible for Window/Worker (secure-context/feature-gated types).
- Escalation (inferred) — Any real-world bypass (e.g. obtaining a SecureContext-only wrapper in an insecure context) depends on the deferred enforcement and on a consuming code path that trusts the wrapper’s context invariants; unproven from this diff.
Detection & hunting
For defenders and detection engineers:
- Deserialized wrapper for an interface not exposed in the target — Instrument CloneDeserializer to log cases where isExposedInGlobalObject() would be false if applied to the receiving (Window/Worker) global — reveals the still-open pre-patch surface.
- CryptoKey deserialized into an insecure context — The retained CryptoKey secure-context check in CloneDeserializer is the canary; monitor its FAIL deserialize trace.
- AudioWorklet structured-clone acceptance changes — Compare which tags AudioWorklet accepts before/after to confirm only intended relaxations (e.g. MessagePort) occurred.
Remaining risk in the patch
- General enforcement is NOT enabled — isInterfaceExposed returns true for every non-AudioWorklet global, so the cross-context exposure bypass the triage describes remains open for Window/Worker/DedicatedWorker after this commit; the patch is groundwork, and the triage severity overstates what ships.
- Known incomplete cases flagged by the authors — The FIXMEs call out null scriptExecutionContext() and JSIDBSerializationGlobalObject as unhandled, and the CryptoKey secure-context check is retained precisely because generic coverage is absent — all explicit evidence the fix is partial.
- ReadableStream/WritableStream/TransformStream special-case — These return !isAudioWorkletGlobalScope rather than a real exposure check, with a comment that transferring them to an AudioWorklet is untested — a deliberately coarse rule rather than a validated one.
Audit directions
- The deferred FIXME path — Track when isInterfaceExposed stops early-returning true for non-AudioWorklet globals; until then, Window/Worker deserialization remains exposure-unchecked — audit consumers that assume a wrapper implies exposure.
- Interfaces newly marked Serializable/Transferable — Verify each annotated interface’s impl is safe to materialize in every global its Exposed set permits, and that no impl assumes a context it may not be in.
- JSIDBSerializationGlobalObject / null-context deserialization — Review IndexedDB value deserialization where scriptExecutionContext() can be null, the exact case the predicate rollout must special-case.
- CloneDeserializer ad-hoc context checks — Enumerate remaining per-tag context checks (like CryptoKey’s) that must be removed only once generic enforcement lands, to avoid a coverage gap.