CVE-2026-28971
Overview
Impact:
Description: A malicious iframe may use another website’s download settings
Researchers: Khiem Tran
Key insight. Download decisions were not gated on the completion of the navigation’s own Safe Browsing check, so a malicious iframe could slip a download past the warning; deferring the download until the per-navigation check completes and honoring a match closes it.
Patch Details
diff --git a/Source/WebKit/UIProcess/API/APINavigation.cpp b/Source/WebKit/UIProcess/API/APINavigation.cpp
index a65c4a21dfe6..28f2960c47ab 100644
--- a/Source/WebKit/UIProcess/API/APINavigation.cpp
+++ b/Source/WebKit/UIProcess/API/APINavigation.cpp
@@ -187,6 +187,21 @@ void Navigation::setSafeBrowsingWarning(RefPtr<WebKit::BrowsingWarning>&& safeBr
m_safeBrowsingWarning = WTF::move(safeBrowsingWarning);
}
+void Navigation::whenSafeBrowsingCheckCompletes(Function<void()>&& callback)
+{
+ if (!safeBrowsingCheckOngoing()) {
+ callback();
+ return;
+ }
+ m_safeBrowsingCheckCompletionCallbacks.append(WTF::move(callback));
+}
+
+void Navigation::fireSafeBrowsingCheckCompletionCallbacks()
+{
+ for (auto& callback : std::exchange(m_safeBrowsingCheckCompletionCallbacks, { }))
+ callback();
+}
+
size_t Navigation::redirectChainIndex(const WTF::URL& url)
{
size_t index = m_redirectChain.find(url);
diff --git a/Source/WebKit/UIProcess/API/APINavigation.h b/Source/WebKit/UIProcess/API/APINavigation.h
index 6ae3d0fa8e37..96ec09585e65 100644
--- a/Source/WebKit/UIProcess/API/APINavigation.h
+++ b/Source/WebKit/UIProcess/API/APINavigation.h
@@ -188,6 +188,8 @@ public:
bool NODELETE safeBrowsingCheckOngoing();
void setSafeBrowsingWarning(RefPtr<WebKit::BrowsingWarning>&&);
RefPtr<WebKit::BrowsingWarning> NODELETE safeBrowsingWarning();
+ void whenSafeBrowsingCheckCompletes(Function<void()>&&);
+ void fireSafeBrowsingCheckCompletionCallbacks();
void setSafeBrowsingCheckTimedOut() { m_safeBrowsingCheckTimedOut = true; }
bool safeBrowsingCheckTimedOut() { return m_safeBrowsingCheckTimedOut; }
bool hadSafeBrowsingWarning() const { return m_hadSafeBrowsingWarning; }
@@ -250,6 +252,7 @@ private:
MonotonicTime m_requestStart { MonotonicTime::now() };
RefPtr<WebKit::BrowsingWarning> m_safeBrowsingWarning;
ListHashSet<size_t> m_ongoingSafeBrowsingChecks;
+ Vector<Function<void()>> m_safeBrowsingCheckCompletionCallbacks;
RefPtr<WebKit::FrameProcess> m_pendingSharedProcess;
RefPtr<WebKit::FrameState> m_backForwardFrameState;
};
diff --git a/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm b/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
index e0ce751cde8e..d20e7d987e3e 100644
--- a/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
+++ b/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
@@ -326,6 +326,9 @@ void WebPageProxy::beginSafeBrowsingCheck(const URL& url, API::Navigation& navig
}
}
+ if (!navigation->safeBrowsingCheckOngoing())
+ navigation->fireSafeBrowsingCheckCompletionCallbacks();
+
if (!navigation->safeBrowsingCheckOngoing() && navigation->safeBrowsingWarning() && navigation->safeBrowsingCheckTimedOut()) {
protectedThis->setHasShownSafeBrowsingWarningAfterLastLoadCommit();
protectedThis->showBrowsingWarning(navigation->safeBrowsingWarning());
diff --git a/Source/WebKit/UIProcess/WebPageProxy.cpp b/Source/WebKit/UIProcess/WebPageProxy.cpp
index 5270ec0eabe5..30de1e0e1e55 100644
--- a/Source/WebKit/UIProcess/WebPageProxy.cpp
+++ b/Source/WebKit/UIProcess/WebPageProxy.cpp
@@ -9405,6 +9405,54 @@ void WebPageProxy::decidePolicyForNavigationAction(Ref<WebProcessProxy>&& proces
protectedPageClient->clearBrowsingWarning();
+ if (policyAction == PolicyAction::Download && navigation->safeBrowsingCheckOngoing()) {
+ navigation->whenSafeBrowsingCheckCompletes([
+ this, protectedThis = WTF::move(protectedThis), navigation, completionHandlerWrapper = WTF::move(completionHandlerWrapper),
+ frame, frameInfo = WTF::move(frameInfo), protectedPageClient = WTF::move(protectedPageClient)
+ ] mutable {
+ if (RefPtr safeBrowsingWarning = navigation->safeBrowsingWarning()) {
+ navigation->setSafeBrowsingWarning(nullptr);
+ if (!frame->isMainFrame()) {
+ auto error = interruptedForPolicyChangeError(navigation->currentRequest());
+ m_navigationClient->didFailProvisionalNavigationWithError(*this, FrameInfoData { frameInfo }, navigation.get(), navigation->currentRequest().url(), error, nullptr);
+ WEBPAGEPROXY_RELEASE_LOG(Loading, "decidePolicyForNavigationAction: Ignoring download because Safe Browsing found a match.");
+ completionHandlerWrapper(PolicyAction::Ignore);
+ return;
+ }
+
+ Ref protectedPageLoadState = pageLoadState();
+ auto transaction = protectedPageLoadState->transaction();
+ protectedPageLoadState->setTitleFromBrowsingWarning(transaction, safeBrowsingWarning->title());
+
+ protectedPageClient->showBrowsingWarning(*safeBrowsingWarning, [protectedThis = WTF::move(protectedThis), completionHandlerWrapper = WTF::move(completionHandlerWrapper), protectedPageClient](auto&& result) mutable {
+ Ref protectedPageLoadState = protectedThis->pageLoadState();
+ auto transaction = protectedPageLoadState->transaction();
+ protectedPageLoadState->setTitleFromBrowsingWarning(transaction, { });
+
+ switchOn(result, [&](const URL& url) {
+ completionHandlerWrapper(PolicyAction::Ignore);
+ protectedThis->loadRequest({ URL { url } });
+ }, [&protectedThis, &completionHandlerWrapper](ContinueUnsafeLoad continueUnsafeLoad) {
+ switch (continueUnsafeLoad) {
+ case ContinueUnsafeLoad::No:
+ if (!protectedThis->hasCommittedAnyProvisionalLoads())
+ protectedThis->m_uiClient->close(protectedThis.ptr());
+ completionHandlerWrapper(PolicyAction::Ignore);
+ break;
+ case ContinueUnsafeLoad::Yes:
+ completionHandlerWrapper(PolicyAction::Download);
+ break;
+ }
+ });
+ });
+ m_uiClient->didShowSafeBrowsingWarning();
+ return;
+ }
+ completionHandlerWrapper(PolicyAction::Download);
+ });
+ return;
+ }
+
if (RefPtr safeBrowsingWarning = navigation->safeBrowsingWarning()) {
navigation->setSafeBrowsingWarning(nullptr);
if (frame->isMainFrame() && safeBrowsingWarning->url().isValid()) {
@@ -9727,9 +9775,43 @@ void WebPageProxy::decidePolicyForResponseShared(Ref<WebProcessProxy>&& process,
#if USE(QUICK_LOOK) && ENABLE(QUICKLOOK_SANDBOX_RESTRICTIONS)
bool supportsMIMEType = PreviewConverter::supportsMIMEType(navigationResponse->response().mimeType());
#endif
- auto completionHandlerWrapper = [navigation, protectedThis, request, navigationResponse = WTF::move(navigationResponse), frameInfo = WTF::move(frameInfo), completionHandler = WTF::move(completionHandler)] (PolicyAction policyAction) mutable {
+ auto completionHandlerWrapper = [navigation, protectedThis, request, navigationResponse = WTF::move(navigationResponse), completionHandler = WTF::move(completionHandler)](PolicyAction policyAction) mutable {
protectedThis->receivedNavigationResponsePolicyDecision(policyAction, navigation.get(), request, WTF::move(navigationResponse), WTF::move(completionHandler));
};
+ if (policyAction == PolicyAction::Download && navigation && navigation->safeBrowsingCheckOngoing()) {
+ navigation->whenSafeBrowsingCheckCompletes([
+ this, protectedThis = WTF::move(protectedThis), navigation, completionHandlerWrapper = WTF::move(completionHandlerWrapper),
+ frame, frameInfo = WTF::move(frameInfo), request
+ ] mutable {
+ if (RefPtr safeBrowsingWarning = navigation->safeBrowsingWarning()) {
+ if (!frame->isMainFrame()) {
+ auto error = interruptedForPolicyChangeError(navigation->currentRequest());
+ m_navigationClient->didFailProvisionalNavigationWithError(*this, FrameInfoData { frameInfo }, navigation.get(), request.url(), error, nullptr);
+ completionHandlerWrapper(PolicyAction::Ignore);
+ return;
+ }
+ Ref protectedPageLoadState = pageLoadState();
+ auto transaction = protectedPageLoadState->transaction();
+ protectedPageLoadState->setTitleFromBrowsingWarning(transaction, safeBrowsingWarning->title());
+ navigation->setSafeBrowsingWarning(nullptr);
+ protect(protectedThis->pageClient())->showBrowsingWarning(*safeBrowsingWarning, [protectedThis = WTF::move(protectedThis), completionHandlerWrapper = WTF::move(completionHandlerWrapper)](auto&& result) mutable {
+ switchOn(result, [&](const URL& url) {
+ completionHandlerWrapper(PolicyAction::Ignore);
+ protectedThis->loadRequest(URL { url });
+ }, [&](ContinueUnsafeLoad continueUnsafeLoad) {
+ if (continueUnsafeLoad == ContinueUnsafeLoad::No)
+ completionHandlerWrapper(PolicyAction::Ignore);
+ else
+ completionHandlerWrapper(PolicyAction::Download);
+ });
+ });
+ m_uiClient->didShowSafeBrowsingWarning();
+ return;
+ }
+ completionHandlerWrapper(PolicyAction::Download);
+ });
+ return;
+ }
if (navigation && navigation->safeBrowsingWarning()) {
RefPtr safeBrowsingWarning = navigation->safeBrowsingWarning();
if (frame->isMainFrame() && safeBrowsingWarning->url().isValid()) {
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm
index 4b417cd08322..c4894846a476 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm
@@ -1194,4 +1194,184 @@ TEST(SafeBrowsing, SetTimeoutNavigationFromWarningPage)
EXPECT_FALSE(didCloseCalled);
}
+TEST(SafeBrowsing, DownloadDeferredAndBlockedBySafeBrowsing)
+{
+ DelayedLookupContext.delayDuration = 50_ms;
+ TestWebKitAPI::HTTPServer server({
+ { "/malicious"_s, { "download-content"_s } },
+ }, TestWebKitAPI::HTTPServer::Protocol::HttpsProxy);
+ RetainPtr configuration = server.httpsProxyConfiguration();
+
+ ClassMethodSwizzler swizzler(getSSBLookupContextClassSingleton(), @selector(sharedLookupContext), [DelayedLookupContext methodForSelector:@selector(sharedLookupContext)]);
+
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [webView configuration].preferences.fraudulentWebsiteWarningEnabled = YES;
+ RetainPtr delegate = adoptNS([TestNavigationDelegate new]);
+ [delegate allowAnyTLSCertificate];
+
+ __block bool downloadStarted = false;
+ delegate.get().decidePolicyForNavigationResponse = ^(WKNavigationResponse *, void (^decisionHandler)(WKNavigationResponsePolicy)) {
+ decisionHandler(WKNavigationResponsePolicyDownload);
+ };
+ delegate.get().navigationResponseDidBecomeDownload = ^(WKNavigationResponse *, WKDownload *) {
+ downloadStarted = true;
+ };
+
+ [webView setNavigationDelegate:delegate.get()];
+ [webView evaluateJavaScript:@"window.location = 'https://example2.com/malicious'" completionHandler:nil];
+
+ while (![webView _safeBrowsingWarning])
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-28971 |
| Bugzilla | 311288 |
| Component | WebKit |
| Bug Class | Bypass |
| Severity | medium |
| Commit | 4b574bf8287b6b57… |
| Advisory | Apple Advisory |
Terminology
- Safe Browsing check — An asynchronous check that flags navigations to known-unsafe URLs and shows a warning before proceeding.
- Download policy action — A navigation whose response is handled as a download rather than displayed; it must still respect Safe Browsing.
- Main frame vs subframe — Downloads and warnings are handled differently for the top-level frame versus an embedded iframe.
- Per-navigation state — Safe Browsing results are tied to a specific API::Navigation; using another navigation’s state confuses the security decision.
Root Cause Analysis
When a navigation resolves to a Download, WebKit must still honor the Safe Browsing check associated with that navigation. Pre-patch, a download policy decision could proceed without waiting for the navigation’s Safe Browsing check to finish (and without correctly applying its result per frame), so a download initiated from a subframe/iframe could bypass or mis-apply the Safe Browsing warning – described as a malicious iframe using another website’s download settings, i.e. the download was not gated on the correct, per-navigation Safe Browsing state. The fix adds a completion mechanism on API::Navigation – whenSafeBrowsingCheckCompletes(callback), which runs immediately if no check is ongoing else queues the callback, and fireSafeBrowsingCheckCompletionCallbacks(), invoked from WebPageProxy::beginSafeBrowsingCheck when the check finishes (using std::exchange to drain the queue). In WebPageProxy::decidePolicyForNavigationAction, when policyAction == Download and a Safe Browsing check is ongoing, the download decision is deferred via whenSafeBrowsingCheckCompletes; on completion, if a Safe Browsing warning matched, the download is refused: for a non-main frame it fails the provisional navigation and returns PolicyAction::Ignore, and for the main frame it shows the browsing warning and only proceeds per the user’s choice. The restored invariant is that a download is authorized only after the navigation’s own Safe Browsing check completes and only if it did not match, so a subframe cannot slip a download past the warning or inherit another frame’s settings.
Attack Path
1. Embed a malicious iframe
A page hosts a cross-origin iframe that initiates a navigation which will resolve to a download.
2. Race the Safe Browsing check
The iframe triggers the download policy decision while the navigation’s Safe Browsing check is still ongoing.
3. Bypass the warning
Pre-patch, the download proceeds without waiting for / correctly applying the per-navigation Safe Browsing result, effectively using the wrong frame’s download handling.
4. Unsafe download
A download that Safe Browsing would have blocked is allowed, or attributed to another site’s settings, in the UIProcess.
Impact Assessment
A security-policy bypass (not memory safety): a subframe-initiated download could proceed without correctly waiting for or applying the navigation’s Safe Browsing check, letting a malicious iframe evade the warning or use another site’s download handling. It executes in the UIProcess and affects download safety/attribution; no code-execution path. Rated medium.
Audit Directions
Other policy actions vs Safe Browsing
Audit decidePolicyForNavigationAction/Response branches (Use, Download, Ignore) to confirm each waits for and applies the navigation’s Safe Browsing result.
Per-frame attribution
Review how frame vs main-frame and frameInfo are used in download and warning handling to prevent one frame inheriting another’s settings.
Async check completion races
grep for safeBrowsingCheckOngoing()/completion callbacks to ensure no security decision proceeds while a check is pending.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
Navigation::whenSafeBrowsingCheckCompletes / fireSafeBrowsingCheckCompletionCallbacks |
Source/WebKit/UIProcess/API/APINavigation.cpp |
added | Adds a per-navigation completion queue: run now if no check is ongoing, else queue; fire drains it via std::exchange when the check finishes. |
WebPageProxy::beginSafeBrowsingCheck |
Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm |
modified | Fires the navigation’s Safe Browsing completion callbacks once the check is no longer ongoing. |
WebPageProxy::decidePolicyForNavigationAction |
Source/WebKit/UIProcess/WebPageProxy.cpp |
modified | For PolicyAction::Download with an ongoing Safe Browsing check, defers via whenSafeBrowsingCheckCompletes and, on a match, ignores the download (subframe: fail provisional navigation; main frame: show warning). |
Files Changed
Source Files
Source/WebKit/UIProcess/API/APINavigation.cppSource/WebKit/UIProcess/API/APINavigation.hSource/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mmSource/WebKit/UIProcess/WebPageProxy.cpp
Test Files
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm
index 4b417cd08322..c4894846a476 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/SafeBrowsing.mm
@@ -1194,4 +1194,184 @@ TEST(SafeBrowsing, SetTimeoutNavigationFromWarningPage)
EXPECT_FALSE(didCloseCalled);
}
+TEST(SafeBrowsing, DownloadDeferredAndBlockedBySafeBrowsing)
+{
+ DelayedLookupContext.delayDuration = 50_ms;
+ TestWebKitAPI::HTTPServer server({
+ { "/malicious"_s, { "download-content"_s } },
+ }, TestWebKitAPI::HTTPServer::Protocol::HttpsProxy);
+ RetainPtr configuration = server.httpsProxyConfiguration();
+
+ ClassMethodSwizzler swizzler(getSSBLookupContextClassSingleton(), @selector(sharedLookupContext), [DelayedLookupContext methodForSelector:@selector(sharedLookupContext)]);
+
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [webView configuration].preferences.fraudulentWebsiteWarningEnabled = YES;
+ RetainPtr delegate = adoptNS([TestNavigationDelegate new]);
+ [delegate allowAnyTLSCertificate];
+
+ __block bool downloadStarted = false;
+ delegate.get().decidePolicyForNavigationResponse = ^(WKNavigationResponse *, void (^decisionHandler)(WKNavigationResponsePolicy)) {
+ decisionHandler(WKNavigationResponsePolicyDownload);
+ };
+ delegate.get().navigationResponseDidBecomeDownload = ^(WKNavigationResponse *, WKDownload *) {
+ downloadStarted = true;
+ };
+
+ [webView setNavigationDelegate:delegate.get()];
+ [webView evaluateJavaScript:@"window.location = 'https://example2.com/malicious'" completionHandler:nil];
+
+ while (![webView _safeBrowsingWarning])
+ TestWebKitAPI::Util::spinRunLoop();
+
+ EXPECT_FALSE(downloadStarted);
+}
+
+TEST(SafeBrowsing, DownloadDeferredAndBlockedBySafeBrowsingPostTimeout)
+{
+ // Use a delay longer than the ~250ms listener timeout to verify the deferral
+ // waits for the actual Safe Browsing result, not just the timeout.
+ DelayedLookupContext.delayDuration = 500_ms;
+ TestWebKitAPI::HTTPServer server({
+ { "/malicious"_s, { "download-content"_s } },
+ }, TestWebKitAPI::HTTPServer::Protocol::HttpsProxy);
+ RetainPtr configuration = server.httpsProxyConfiguration();
+
+ ClassMethodSwizzler swizzler(getSSBLookupContextClassSingleton(), @selector(sharedLookupContext), [DelayedLookupContext methodForSelector:@selector(sharedLookupContext)]);
+
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [webView configuration].preferences.fraudulentWebsiteWarningEnabled = YES;
+ RetainPtr delegate = adoptNS([TestNavigationDelegate new]);
+ [delegate allowAnyTLSCertificate];
+
+ __block bool downloadStarted = false;
+ delegate.get().decidePolicyForNavigationResponse = ^(WKNavigationResponse *, void (^decisionHandler)(WKNavigationResponsePolicy)) {
+ decisionHandler(WKNavigationResponsePolicyDownload);
+ };
+ delegate.get().navigationResponseDidBecomeDownload = ^(WKNavigationResponse *, WKDownload *) {
+ downloadStarted = true;
+ };
+
+ [webView setNavigationDelegate:delegate.get()];
+ [webView evaluateJavaScript:@"window.location = 'https://example2.com/malicious'" completionHandler:nil];
+
+ while (![webView _safeBrowsingWarning])
+ TestWebKitAPI::Util::spinRunLoop();
+
+ EXPECT_FALSE(downloadStarted);
+}
+
+TEST(SafeBrowsing, CleanDownloadProceedsAfterSafeBrowsingCheck)
+{
+ DelayedLookupContext.delayDuration = 50_ms;
+ TestWebKitAPI::HTTPServer server({
+ { "/safe"_s, { "download-content"_s } },
+ }, TestWebKitAPI::HTTPServer::Protocol::HttpsProxy);
+ RetainPtr configuration = server.httpsProxyConfiguration();
+
+ ClassMethodSwizzler swizzler(getSSBLookupContextClassSingleton(), @selector(sharedLookupContext), [DelayedLookupContext methodForSelector:@selector(sharedLookupContext)]);
+
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [webView configuration].preferences.fraudulentWebsiteWarningEnabled = YES;
+ RetainPtr delegate = adoptNS([TestNavigationDelegate new]);
+ [delegate allowAnyTLSCertificate];
+
+ __block bool downloadStarted = false;
+ delegate.get().decidePolicyForNavigationResponse = ^(WKNavigationResponse *, void (^decisionHandler)(WKNavigationResponsePolicy)) {
+ decisionHandler(WKNavigationResponsePolicyDownload);
+ };
+ delegate.get().navigationResponseDidBecomeDownload = ^(WKNavigationResponse *, WKDownload *) {
+ downloadStarted = true;
+ };
+
+ [webView setNavigationDelegate:delegate.get()];
+ [webView evaluateJavaScript:@"window.location = 'https://example2.com/safe'" completionHandler:nil];
+
+ TestWebKitAPI::Util::run(&downloadStarted);
+ EXPECT_NULL([webView _safeBrowsingWarning]);
+}
+
+TEST(SafeBrowsing, SubframeDownloadBlockedBySafeBrowsing)
+{
+ DelayedLookupContext.delayDuration = 50_ms;
+ TestWebKitAPI::HTTPServer server({
+ { "/safe"_s, { "<html><body><iframe src='https://evil.com/malicious'></iframe></body></html>"_s } },
+ { "/malicious"_s, { "download-content"_s } },
+ }, TestWebKitAPI::HTTPServer::Protocol::HttpsProxy);
+ RetainPtr configuration = server.httpsProxyConfiguration();
+
+ ClassMethodSwizzler swizzler(getSSBLookupContextClassSingleton(), @selector(sharedLookupContext), [DelayedLookupContext methodForSelector:@selector(sharedLookupContext)]);
+
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [webView configuration].preferences.fraudulentWebsiteWarningEnabled = YES;
+ RetainPtr delegate = adoptNS([TestNavigationDelegate new]);
+ [delegate allowAnyTLSCertificate];
+
+ __block bool downloadStarted = false;
+ __block bool subframeNavigationFailed = false;
+ __block bool mainNavigationFinished = false;
+
+ delegate.get().decidePolicyForNavigationResponse = ^(WKNavigationResponse *response, void (^decisionHandler)(WKNavigationResponsePolicy)) {
+ if ([response.response.URL.path isEqualToString:@"/malicious"])
+ decisionHandler(WKNavigationResponsePolicyDownload);
+ else
+ decisionHandler(WKNavigationResponsePolicyAllow);
+ };
+ delegate.get().navigationResponseDidBecomeDownload = ^(WKNavigationResponse *, WKDownload *) {
+ downloadStarted = true;
+ };
+ delegate.get().didFailProvisionalLoadInSubframeWithError = ^(WKWebView *, WKFrameInfo *, NSError *error) {
+ EXPECT_NOT_NULL(error);
+ subframeNavigationFailed = true;
+ };
+ delegate.get().didFinishNavigation = ^(WKWebView *, WKNavigation *) {
+ mainNavigationFinished = true;
+ };
+
+ [webView setNavigationDelegate:delegate.get()];
+ [webView evaluateJavaScript:@"window.location = 'https://example2.com/safe'" completionHandler:nil];
+
+ TestWebKitAPI::Util::run(&mainNavigationFinished);
+
+ while (!subframeNavigationFailed && !downloadStarted)
+ TestWebKitAPI::Util::spinRunLoop();
+
+ EXPECT_TRUE(subframeNavigationFailed);
+ EXPECT_FALSE(downloadStarted);
+}
+
+TEST(SafeBrowsing, NavigationActionDownloadDeferredBySafeBrowsing)
+{
+ DelayedLookupContext.delayDuration = 50_ms;
+ TestWebKitAPI::HTTPServer server({
+ { "/malicious"_s, { "content"_s } },
+ }, TestWebKitAPI::HTTPServer::Protocol::HttpsProxy);
+ RetainPtr configuration = server.httpsProxyConfiguration();
+
+ ClassMethodSwizzler swizzler(getSSBLookupContextClassSingleton(), @selector(sharedLookupContext), [DelayedLookupContext methodForSelector:@selector(sharedLookupContext)]);
+
+ RetainPtr webView = adoptNS([[TestWKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration.get()]);
+ [webView configuration].preferences.fraudulentWebsiteWarningEnabled = YES;
+ RetainPtr delegate = adoptNS([TestNavigationDelegate new]);
+ [delegate allowAnyTLSCertificate];
+
+ __block bool downloadStarted = false;
+ delegate.get().decidePolicyForNavigationAction = ^(WKNavigationAction *action, void (^decisionHandler)(WKNavigationActionPolicy)) {
+ if ([action.request.URL.path isEqualToString:@"/malicious"])
+ decisionHandler(WKNavigationActionPolicyDownload);
+ else
+ decisionHandler(WKNavigationActionPolicyAllow);
+ };
+ delegate.get().navigationActionDidBecomeDownload = ^(WKNavigationAction *, WKDownload *) {
+ downloadStarted = true;
+ };
+
+ [webView setNavigationDelegate:delegate.get()];
+ [webView evaluateJavaScript:@"window.location = 'https://example2.com/malicious'" completionHandler:nil];
+
+ while (![webView _safeBrowsingWarning])
+ TestWebKitAPI::Util::spinRunLoop();
+
+ EXPECT_FALSE(downloadStarted);
+}
+
#endif // HAVE(SAFE_BROWSING)