Missing validation for incoming file paths from web content process when attachment elements are enabled
CVE: CVE-2026-28962 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may disclose sensitive user information Apple's description: This issue was addressed with improved access restrictions. Credit: Luke Francis, Vaagn Vardanian, kwak kiyong / kakaogames, Vitaly Simonovich, Adel Bouachraoui, greenbynox
High. The privileged side validated that the attachment identifier was well-formed and never asked where the file path came from — a textbook confused deputy across the sandbox boundary. Escalation to a full file read depends on which attachment read-back flow the caller can reach; the file-open on the privileged side happens either way.
A multi-process browser splits authority deliberately: the renderer parses hostile content with almost no filesystem reach, and a privileged UI process holds the user's real authority, vending narrow per-file grants when the user pastes or drags something in. WebKit's attachment elements — <attachment> nodes that embed a file inside editable content — sit exactly on that seam, because the renderer only ever holds an opaque identifier while the UI process holds the backing file. The invariant that makes the split hold is that a path crossing from renderer to UI process must be a path the UI process itself handed over first.
The angle: A page that can drive the attachment registration message names any file the user can read, and the privileged process opens it and binds an attachment to it.
Source/WebKit/UIProcess/WebPageProxy.cpp
Source/WebKit/UIProcess/WebProcessProxy.cpp
Source/WebKit/UIProcess/Cocoa/WebPasteboardProxyCocoa.mm
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKAttachmentTests.mm
Patch Details
The commit has two halves that only make sense together: a redemption table, and the population of that table at every point where the UI process legitimately hands a path across.
The table lives on WebProcessProxy — the UI-process object representing one WebContent process. It gains a HashSet<String> m_allowedAttachmentFilePaths member plus two accessors, both guarded by ENABLE(ATTACHMENT_ELEMENT): addAllowedAttachmentFilePath(), which inserts any non-empty path, and isAllowedAttachmentFilePath() const, a plain set membership test. Nothing removes entries.
The redemption happens in WebPageProxy::registerAttachmentIdentifierFromFilePath, which gains a third MESSAGE_CHECK_BASE alongside the two that were already there. The ordering is the interesting part:
Before: After:
attachmentElementEnabled()? attachmentElementEnabled()?
└─► isValidKey(identifier)? └─► isValidKey(identifier)?
└─► [filePath unchecked] └─► isAllowedAttachmentFilePath(filePath)?
└─► ensureAttachment() └─► ensureAttachment()
└─► open(filePath) └─► open(filePath)
Both prior checks are shape checks — a feature flag and a well-formedness test on the identifier. Neither says anything about where filePath came from. The new check is a provenance test, and a failure terminates the sending web process.
Population is spread across the two legitimate origins of a path. Drag-drop: WebPageProxy::performDragOperation now walks dragData.fileNames() and registers each on the frame's process before forwarding Messages::WebPage::PerformDragOperation. Pasteboard: WebPasteboardProxyCocoa.mm gains the static helper addAllowedAttachmentFilePaths(connection, pageID, paths), which resolves the page from the WebPageProxyIdentifier as a liveness check and then registers each path against WebProcessProxy::fromConnection(connection). That helper is called from getPasteboardPathnamesForType for the returned pathnames, and from allPasteboardItemInfo and informationForItemAtIndex for each item's pathsForFileUpload.
The pasteboard-info functions are where the bulk of the diff sits, because each has four distinct routes to its completionHandler and every one of them needed the call. Two early returns (!allInfo || !process, and an empty transcodingInfo), the non-PLATFORM(IOS_FAMILY) branch, and the asynchronous HEIC-transcoding round trip. That last one required signature surgery on the lambdas: the dispatch onto sharedImageTranscodingQueueSingleton() and the hop back to the main run loop both gained protectedConnection = Ref { connection } and pageID captures, purely so the post-transcode replacement paths — which differ from the ones the pasteboard originally reported — could be allowlisted before the completion handler fires.
Separately and independently, registerAttachmentsFromSerializedData gains an isValidKey(serializedData.identifier) check inside its loop, matching the validation its single-path sibling already performed before calling ensureAttachment().
Background
The process split. Modern WebKit runs web content in a sandboxed WebContent process that cannot read the filesystem, the system pasteboard, or the drag pasteboard on its own. A separate UI process runs with the user's full authority and acts as broker: WebContent asks, the UI process decides, and hands back a narrowed result.
Sandbox extensions. The standard narrowing mechanism is a transferable token minted by the privileged side that grants the sandboxed side access to one specific path. SandboxExtension::createHandle(filename, SandboxExtension::Type::ReadOnly) appears throughout the pasteboard code for exactly this purpose — the web process gets a read grant for the files the user selected, and nothing else.
Attachment elements. WebKit's <attachment> element embeds a file inside editable content — the shape you get when a user pastes a PDF into a mail composer. The web process never holds the file; it holds an opaque identifier string, and the UI process keeps the actual API::Attachment in an IdentifierToAttachmentMap keyed by that identifier.
RegisterAttachmentIdentifierFromFilePath. This is the IPC message WebContent sends to the UI process carrying (identifier, contentType, filePath), asking it to create an attachment whose backing store is the file at filePath.
MESSAGE_CHECK_BASE(assertion, connection). WebKit's IPC-validation macro on the privileged side. A failed assertion means the message is malformed, and the sender is killed. It is the standard idiom for enforcing preconditions on any IPC argument an attacker can influence — the failure mode is deliberately loud, because a web process that sends an impossible message is presumed compromised.
WebProcessProxy::fromConnection(). Maps an incoming IPC connection back to the UI-process object representing that particular WebContent process. Per-process state — grants, capabilities, bookkeeping — hangs off that object, so fromConnection is how a receiver asks "what has this sender been granted?"
Pasteboard and drag brokering. WebContent asks WebPasteboardProxy for pathnames (getPasteboardPathnamesForType) or for per-item info including pathsForFileUpload (allPasteboardItemInfo, informationForItemAtIndex). Drag-drop takes the same shape from the other direction: the platform delivers DragData with fileNames() to WebPageProxy::performDragOperation, which forwards it inward.
HEIC transcoding. On iOS-family platforms, pasteboard images matching a site-specific quirk are transcoded off the main thread on a shared queue, and the resulting temporary paths replace the originals inside pathsForFileUpload before the completion handler runs. The paths that reach the web process on that branch are therefore not the paths the pasteboard reported.
IPC testing API. A preference-gated facility (IPCTestingAPIEnabled) that lets JavaScript in a test build synthesize arbitrary IPC messages via IPC.sendMessage. The regression test uses it to emulate what a compromised web process could send.
Analysis
This is a confused deputy: the privileged side accepted a resource name from the unprivileged side and treated it as a capability.
WebContent (sandboxed) │ UIProcess (user's full FS authority)
──────────────────────────────┼──────────────────────────────────────
paste / drop ────ask────────►│ WebPasteboardProxy → path + extension
◄───grant───────│ (this is the only legitimate origin)
│
RegisterAttachmentIdentifier │ attachmentElementEnabled()? ✓ shape
FromFilePath("/etc/passwd") ─►│ isValidKey(identifier)? ✓ shape
│ [ no provenance check ] ◄── the gap
│ ensureAttachment() → open("/etc/passwd")
The boundary the message crosses is the one drawn in the diagram's vertical rule, and the point of that rule is asymmetry: the right column can open any file the user can open, the left column is supposed to reach only what it was granted. Before the fix, registerAttachmentIdentifierFromFilePath had two guards and neither of them looked leftward. attachmentElementEnabled() asks a question about configuration. IdentifierToAttachmentMap::isValidKey(identifier) asks whether a string is a well-formed hash key. The filePath argument — the one parameter that names a resource on the privileged side of the rule — passed through untouched.
So the deputy does what it was asked. It takes the string, binds an API::Attachment to it, and wraps and reads the file with UI-process privileges. Nothing in that sequence is memory-unsafe; every object is correctly refcounted and every string is well-formed. The invariant that broke is not a lifetime or a bound, it is the sandbox rule that a sandboxed process may only name filesystem paths the broker already granted it.
The regression test states the shape without ambiguity:
1. Enable attachment elements + IPC testing API on the configuration.
2. Load editable markup so the page is a plausible attachment host.
3. From page JavaScript, IPC.sendMessage the UI process:
RegisterAttachmentIdentifierFromFilePath(
identifier: "fake-identifier" ← never vended by anyone
contentType: "application/octet-stream"
filePath: "/etc/passwd" ← never vended by anyone
)
4. Pre-fix: accepted. Post-fix: MESSAGE_CHECK_BASE fails, process dies.
5. waitForWebContentProcessDidTerminate.
A synthetic identifier and an absolute path the user never touched, and the pre-fix receiver had no basis on which to object to either. What follows the registration determines how far this goes. The attachment now exists in the UI process bound to a file the sandbox was meant to withhold, and any downstream flow that surfaces attachment bytes — data plumbed back for rendering or re-serialization, drag-out, or file access granted to the network process for upload — carries those contents outward. That is the reported impact: processing maliciously crafted web content may disclose sensitive user information. Whether it becomes a general arbitrary-user-readable-file read primitive across the sandbox boundary depends on which read-back flow is reachable from the caller's position; the file-open on the privileged side is unconditional. Reachability is most reliable for a party that already controls the WebContent process and can therefore emit the message directly with a chosen filePath; content-only reachability turns on the WebCore sender, which is not in the supplied context.
The fix restores the invariant by making the name redeemable. Every path the UI process legitimately hands over is now recorded against the receiving WebProcessProxy before it is sent — pasteboard pathnames, pasteboard file-upload paths including the transcoded replacements, and drag-drop filenames — and the receiver checks membership before acting. /etc/passwd was never vended by any of those brokers, so it is not in the set, and the message is treated as malformed. Note the direction of the guarantee: the check does not verify that the path is currently authorized for this operation. It verifies that the path was, at some point in this process's life, vended by a broker. That is a provenance filter, not a capability filter.
The secondary change in registerAttachmentsFromSerializedData is a different, smaller gap in the same neighbourhood: the loop was calling ensureAttachment(identifier) on an unvalidated HashMap key while its single-path sibling had validated keys all along. At worst that yields a controlled abort, not corruption — but the asymmetry between two entry points into the same map is precisely the kind of thing that survives for years unnoticed.
The privileged process validated that the attachment identifier was a well-formed hash key but never asked whether it had ever vended the file path it was being told to open.
Insight
The scope of the new allowlist is worth reading carefully, because it is coarser than it first appears. m_allowedAttachmentFilePaths lives on the WebProcessProxy, not on the page and not on the operation, and it is a HashSet<String> with no removal path — a path authorized by one paste in one page stays authorized for the lifetime of that process and for every other page hosted in it. The coarsening looks deliberate rather than accidental: addAllowedAttachmentFilePaths resolves the page from pageID purely as a liveness check and then registers against the process regardless, which mirrors the reasoning already written into WebPasteboardProxy::accessType — that same-process pages can impersonate each other over IPC, so per-page scoping buys less than it appears to. Worth knowing all the same, since it fixes what question the check can answer.
The other thing the diff records is how much surface a retrofit like this has to cover. Three pasteboard functions, four completion branches apiece, plus an asynchronous transcoding round trip whose lambdas needed new protectedConnection and pageID captures specifically so the post-transcode paths could be allowlisted too. The branch count is a fair measure of how a grant-tracking gap gets left behind in the first place.
Audit directions
-
Names are not capabilities — hunt the confused-deputy shape in UI-process receivers. Narrow: enumerate message receivers under
Source/WebKit/UIProcesswhose parameters include aString filePath/fileName/URLand check whether theMESSAGE_CHECK_BASEprologue validates provenance or only shape. First tier is the sibling attachment entry points —registerAttachmentsFromSerializedData,registerAttachmentIdentifier,setAttachmentDataAndContentType— plus any receiver reachingSandboxExtension::createHandleorNSFileWrapper; the tell is a body that opens or wraps the path with noisAllowed*/checkURLReceivedFromWebProcess-style lookup above it. Wider: the class appears wherever grant and redemption live in different objects — network-process file-upload access (Messages::NetworkProcess::AllowFilesAccessFromWebProcess), download destination paths, GPU-process resource identifiers, app-extension message handlers. The search-result shape to notice is a format assertion (isValidKey,isValid, non-empty) sitting directly adjacent to a use of the value as a capability. Widest: this generalizes to any privilege-split system with a broker — Chromium'sChildProcessSecurityPolicy::CanReadFileand Mojo message handlers, Android binder services taking caller-supplied paths, setuid helpers, any RPC server trusting a client-supplied filename. If the privileged side can name more resources than the unprivileged side, every name crossing the boundary needs a redemption table, and format validation is never a substitute for one. -
Branch-census multi-path asynchronous brokers for authorization applied on some completion paths but not all. Narrow: re-read
WebPasteboardProxy::allPasteboardItemInfoandinformationForItemAtIndexinWebPasteboardProxyCocoa.mmand confirm that every route tocompletionHandlerregisterspathsForFileUpload— the!allInfo || !processbranch, the empty-transcodingInfobranch, the post-transcode main-thread dispatch, and the non-PLATFORM(IOS_FAMILY)branch — then apply the same census togetPasteboardPathnamesForType,readURLFromPasteboard,readBufferFromPasteboard, and the sibling drag entry points aroundWebPageProxy::performDragOperation. Match tell: acompletionHandler(...)inside an#if PLATFORM(...)or early-return block that lacks the grant-recording call its neighbours have. Wider: the same shape appears anywhere a security side effect is written as a statement rather than encoded in a type — sandbox-extension minting,grantAccess/revokeAccesspairs, and cross-thread hand-offs whose lambdas capture a subset of the state the security decision needs (this commit addedprotectedConnectionandpageIDfor exactly that reason). Widest: a security side effect not placed on the single path all results must traverse will eventually be skipped on one branch. That holds for any callback-per-branch async API — Chromium'sbase::OnceCallbackchains, Rust and JS promise fan-outs. The portable question: is the check in the constructor of the result, or in each branch that produces one? -
Interrogate the lifetime of any newly introduced allowlist, not just its existence. Narrow: trace
m_allowedAttachmentFilePathsinWebProcessProxy.h/.cppfor any removal, clearing on navigation, or clearing on process reuse —WebProcessCachereuse andSuspendedPageProxyresumption are the interesting moments — then ask the same of the neighbouringm_pasteboardNameToAccessInformationMapinWebPasteboardProxy, which does have arevokeAccesscounterpart. Match tell: a member container with anadd/grantmethod and noremove/revoke/clearcaller, or one cleared only at destruction. Wider: this covers every per-process capability cache inWebProcessProxyandNetworkProcessProxy— granted file access, sandbox-extension bookkeeping, permission grants — where the grant is keyed on the process but the user's consent was keyed on a page, an origin, or a single gesture. The shape to look for is a grant whose key is coarser than the consent that produced it. Widest: capability lifetime must not exceed the lifetime of the consent that created it — applicable to OAuth scope caching, Android SAF persisted URI permissions, macOS security-scoped bookmarks, and browser permission stores. The carried question: what event should revoke this, and does anything call it?