blob: c79cfef70b8dff05146d741022758dc76e1dbe24 [file]
/*
* Copyright (C) 2026 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
#import "config.h"
#import "Helpers/PlatformUtilities.h"
#import "Helpers/Utilities.h"
#import "Helpers/cocoa/HTTPServer.h"
#import "Helpers/cocoa/TestNavigationDelegate.h"
#import "Helpers/cocoa/TestUIDelegate.h"
#import "Helpers/cocoa/TestWKWebView.h"
#import <WebKit/WKPreferencesPrivate.h>
#import <WebKit/WKProcessPoolPrivate.h>
#import <WebKit/WKWebViewPrivate.h>
#import <WebKit/WKWebsiteDataStorePrivate.h>
#import <WebKit/_WKFeature.h>
#import <WebKit/_WKWebsiteDataStoreConfiguration.h>
#import <wtf/RetainPtr.h>
#if ENABLE(IPC_TESTING_API)
static void enableIPCTestingAPI(WKWebViewConfiguration *configuration)
{
for (_WKFeature *feature in [WKPreferences _features]) {
if ([feature.key isEqualToString:@"IPCTestingAPIEnabled"]) {
[[configuration preferences] _setEnabled:YES forFeature:feature];
break;
}
}
}
static constexpr auto sharedWorkerBytes = R"PORTRESOURCE(
var heldPort = null;
self.addEventListener('connect', function(e) {
var port = e.ports[0];
port.addEventListener('message', function(event) {
if (event.data === 'store-port' && event.ports && event.ports.length > 0) {
heldPort = event.ports[0];
port.postMessage('port-stored');
} else if (event.data === 'get-port' && heldPort) {
port.postMessage('here-is-port', [heldPort]);
heldPort = null;
}
});
port.start();
});
)PORTRESOURCE"_s;
static constexpr auto senderBytes = R"PORTRESOURCE(
<script>
var port2ProcessId, port2PortId;
IPC.addOutgoingMessageListener('Networking', function(msg) {
if (msg.description.indexOf('CreateNewMessagePortChannel') !== -1 && !port2ProcessId) {
var buf1 = msg.arguments[1];
if (buf1 instanceof ArrayBuffer) {
var dv = new DataView(buf1);
port2ProcessId = dv.getBigUint64(0, true);
port2PortId = dv.getBigUint64(8, true);
}
}
});
var channel = new MessageChannel();
var worker = new SharedWorker('/worker.js');
worker.port.addEventListener('message', function(event) {
if (event.data === 'port-stored')
alert('port-stored');
});
worker.port.start();
worker.port.postMessage('store-port', [channel.port2]);
</script>
)PORTRESOURCE"_s;
static constexpr auto receiverBytes = R"PORTRESOURCE(
<script>
var worker = new SharedWorker('/worker.js');
worker.port.addEventListener('message', function(event) {
if (event.data === 'here-is-port' && event.ports && event.ports.length > 0) {
window.storedPort = event.ports[0];
alert('port-received');
}
});
worker.port.start();
worker.port.postMessage('get-port');
</script>
)PORTRESOURCE"_s;
TEST(MessagePortSecurity, CrossProcessMessageTheftViaTakeAllMessagesForPort)
{
TestWebKitAPI::HTTPServer server({
{ "/sender"_s, { senderBytes } },
{ "/receiver"_s, { receiverBytes } },
{ "/worker.js"_s, { { { "Content-Type"_s, "application/javascript"_s } }, sharedWorkerBytes } },
{ "/attacker"_s, { "<html><body>attacker</body></html>"_s } },
});
auto dataStoreConfiguration = adoptNS([[_WKWebsiteDataStoreConfiguration alloc] initNonPersistentConfiguration]);
auto dataStore = adoptNS([[WKWebsiteDataStore alloc] _initWithConfiguration:dataStoreConfiguration.get()]);
auto config = adoptNS([[WKWebViewConfiguration alloc] init]);
enableIPCTestingAPI(config.get());
[config setWebsiteDataStore:dataStore.get()];
// `webViewA` connects to a SharedWorker and sends a message port to it.
auto navDelegateA = adoptNS([TestNavigationDelegate new]);
auto uiDelegateA = adoptNS([TestUIDelegate new]);
auto webViewA = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 800, 600) configuration:config.get()]);
[webViewA setNavigationDelegate:navDelegateA.get()];
[webViewA setUIDelegate:uiDelegateA.get()];
[webViewA loadRequest:server.request("/sender"_s)];
[navDelegateA waitForDidFinishNavigation];
// The sender page connects to the shared worker and sends it a port from a message channel.
// Wait for all of that to finish.
EXPECT_WK_STREQ([uiDelegateA waitForAlert], "port-stored");
// `webViewB` will get the port from the shared worker, formally moving it to a new web content process,
// forcing the networking process to be an intermediary.
auto navDelegateB = adoptNS([TestNavigationDelegate new]);
auto uiDelegateB = adoptNS([TestUIDelegate new]);
auto webViewB = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 800, 600) configuration:config.get()]);
[webViewB setNavigationDelegate:navDelegateB.get()];
[webViewB setUIDelegate:uiDelegateB.get()];
[webViewB loadRequest:server.request("/receiver"_s)];
// Wait for `webViewB` to get the port from the shared worker, and verify that
// it's out of process from the first web view.
// At that point, we know that messages to the port are going through the networking
// process as an intermediate.
EXPECT_WK_STREQ([uiDelegateB waitForAlert], "port-received");
EXPECT_NE([webViewA _webProcessIdentifier], [webViewB _webProcessIdentifier]);
// Post some messages from `webViewA`
// Since `webViewB` acquired the port but did not activate it, these messages
// will queue up in the Networking process.
[webViewA evaluateJavaScript:
@"channel.port1.postMessage('secret-message-1');"
"channel.port1.postMessage('secret-message-2');"
"channel.port1.postMessage('secret-message-3');"
"alert('messages-posted');"
completionHandler:nil];
EXPECT_WK_STREQ([uiDelegateA waitForAlert], "messages-posted");
// Earlier we used CoreIPC JS to grab the process identifier and port ID for the now-remote-MessagePort.
// Grab them here for the attacker to use.
NSString *port2ProcessString = [webViewA stringByEvaluatingJavaScript:@"port2ProcessId ? port2ProcessId.toString() : 'undefined'"];
NSString *port2PortString = [webViewA stringByEvaluatingJavaScript:@"port2PortId ? port2PortId.toString() : 'undefined'"];
EXPECT_FALSE([port2ProcessString isEqualToString:@"undefined"]);
EXPECT_FALSE([port2PortString isEqualToString:@"undefined"]);
// `webViewC` is the attacker.
auto navDelegateC = adoptNS([TestNavigationDelegate new]);
auto uiDelegateC = adoptNS([TestUIDelegate new]);
auto webViewC = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 800, 600) configuration:config.get()]);
[webViewC setNavigationDelegate:navDelegateC.get()];
[webViewC setUIDelegate:uiDelegateC.get()];
[webViewC loadRequest:server.request("/attacker"_s)];
[navDelegateC waitForDidFinishNavigation];
EXPECT_NE([webViewA _webProcessIdentifier], [webViewC _webProcessIdentifier]);
EXPECT_NE([webViewB _webProcessIdentifier], [webViewC _webProcessIdentifier]);
// It uses CoreIPC JS to craft a message to acquire the messages sent to the port
// we identified above. Ideally it should *not* be able to get those messages.
NSString *attackScript = [NSString stringWithFormat:
@"var net = IPC.connectionForProcessTarget('Networking');"
"var portId = [{type: 'uint64_t', value: BigInt('%@')}, {type: 'uint64_t', value: BigInt('%@')}];"
"net.sendWithAsyncReply(0,"
" IPC.messages.NetworkConnectionToWebProcess_TakeAllMessagesForPort.name,"
" [portId],"
" function(reply) {"
" try {"
" var buf = reply.arguments[0];"
" if (buf instanceof ArrayBuffer) {"
" var dv = new DataView(buf);"
" var count = Number(dv.getBigUint64(0, true));"
" alert('stolen:' + count);"
" } else {"
" alert('stolen:unexpected-type');"
" }"
" } catch(e) {"
" alert('error:' + e.message);"
" }"
" }"
");", port2ProcessString, port2PortString];
[webViewC evaluateJavaScript:attackScript completionHandler:nil];
// If the bug exists, the attacker will manage to get all 3 pending messages, resulting in "stolen:3"
// The MESSAGE_CHECK being added responds with an empty set, resulting in "stolen:0"
EXPECT_WK_STREQ([uiDelegateC waitForAlert], "stolen:0");
}
#endif // ENABLE(IPC_TESTING_API)
// When a WebContent process detaches from its Networking process, then reattaches to a new one,
// then tries to use stale message ports... It triggers a message check in the new Networking process.
// This test verifies that a WebContent process with "stale" message ports clears record of them,
// so that we cannot trigger this message check under normal operation.
TEST(MessagePortSecurity, MessagePortsSurviveNetworkProcessRestart)
{
TestWebKitAPI::HTTPServer server({
{ "/main"_s, { "<!doctype html><script>"
"window.channel = new MessageChannel();"
"window.channel.port2.onmessage = function(e) { alert('received-' + e.data); };"
"</script>"_s } },
{ "/ping"_s, { "ok"_s } },
});
RetainPtr<TestNavigationDelegate> navDelegate = adoptNS([TestNavigationDelegate new]);
RetainPtr<TestUIDelegate> uiDelegate = adoptNS([TestUIDelegate new]);
RetainPtr<WKWebViewConfiguration> config = adoptNS([[WKWebViewConfiguration alloc] init]);
RetainPtr<TestWKWebView> webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 800, 600) configuration:config.get()]);
[webView setNavigationDelegate:navDelegate.get()];
[webView setUIDelegate:uiDelegate.get()];
[webView loadRequest:server.request("/main"_s)];
[navDelegate waitForDidFinishNavigation];
pid_t initialWebProcessPID = [webView _webProcessIdentifier];
pid_t initialNetworkProcessPID = [[webView configuration].websiteDataStore _networkProcessIdentifier];
EXPECT_TRUE(!!initialWebProcessPID);
EXPECT_TRUE(!!initialNetworkProcessPID);
[webView evaluateJavaScript:@"channel.port1.postMessage('first');" completionHandler:nil];
EXPECT_WK_STREQ([uiDelegate waitForAlert], "received-first");
// Killing the Networking process should cause the WebContent process to handle the disconnect,
// invalidating its current ports. This will help it avoid being killed by failing a MESSAGE_CHECK
// in the new Networking process.
[[webView configuration].websiteDataStore _terminateNetworkProcess];
// Force a new Networking process to fire up and connect.
NSString *fetchJS = [NSString stringWithFormat:
@"fetch('%@/ping').then(r => r.text()).catch(e => 'err')",
server.origin().createNSString().get()];
__block bool fetchSettled = false;
[webView evaluateJavaScript:fetchJS completionHandler:^(id, NSError *) {
fetchSettled = true;
}];
TestWebKitAPI::Util::run(&fetchSettled);
pid_t reestablishedNetworkProcessPID = [[webView configuration].websiteDataStore _networkProcessIdentifier];
EXPECT_NE(initialNetworkProcessPID, reestablishedNetworkProcessPID);
__block bool webContentTerminated = false;
navDelegate.get().webContentProcessDidTerminate = ^(WKWebView *, _WKProcessTerminationReason) {
webContentTerminated = true;
};
// Posting on a stale leftover port should be an instant no-op - The port has been invalidated.
__block bool unexpectedAlert = false;
uiDelegate.get().runJavaScriptAlertPanelWithMessage = ^(WKWebView *, NSString *, WKFrameInfo *, void (^handler)(void)) {
unexpectedAlert = true;
handler();
};
[webView evaluateJavaScript:@"channel.port1.postMessage('stale');" completionHandler:nil];
// Let any in-flight tasks settle. If the bug is present (stale TakeAllMessagesForPort over the new
// connection), webContentTerminated would flip during this window when the MESSAGE_CHECK fails.
TestWebKitAPI::Util::runFor(0.5_s);
EXPECT_FALSE(webContentTerminated);
EXPECT_FALSE(unexpectedAlert);
EXPECT_EQ(initialWebProcessPID, [webView _webProcessIdentifier]);
// New MessageChannels should, of course, work again.
__block bool freshAlertReceived = false;
__block RetainPtr<NSString> freshAlertText;
uiDelegate.get().runJavaScriptAlertPanelWithMessage = ^(WKWebView *, NSString *message, WKFrameInfo *, void (^handler)(void)) {
freshAlertText = message;
freshAlertReceived = true;
handler();
};
[webView evaluateJavaScript:
@"window.fresh = new MessageChannel();"
"window.fresh.port2.onmessage = function(e) { alert('fresh-' + e.data); };"
"window.fresh.port1.postMessage('hello');"
completionHandler:nil];
TestWebKitAPI::Util::runFor(&freshAlertReceived, 5_s);
EXPECT_TRUE(freshAlertReceived);
if (freshAlertReceived)
EXPECT_WK_STREQ(freshAlertText.get(), "fresh-hello");
EXPECT_FALSE(webContentTerminated);
}
TEST(MessagePortSecurity, MessagePortsSurviveNetworkProcessRestartWithSiteIsolation)
{
TestWebKitAPI::HTTPServer server({
{ "/main"_s, { "<!doctype html><script>"
"window.channel = new MessageChannel();"
"</script>"_s } },
{ "/ping"_s, { "ok"_s } },
});
RetainPtr<TestNavigationDelegate> navDelegate = adoptNS([TestNavigationDelegate new]);
RetainPtr<TestUIDelegate> uiDelegate = adoptNS([TestUIDelegate new]);
RetainPtr<WKWebViewConfiguration> config = adoptNS([[WKWebViewConfiguration alloc] init]);
for (_WKFeature *feature in [WKPreferences _features]) {
if ([feature.key isEqualToString:@"SiteIsolationEnabled"]) {
[[config preferences] _setEnabled:YES forFeature:feature];
break;
}
}
RetainPtr<TestWKWebView> webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 800, 600) configuration:config.get()]);
[webView setNavigationDelegate:navDelegate.get()];
[webView setUIDelegate:uiDelegate.get()];
[webView loadRequest:server.request("/main"_s)];
[navDelegate waitForDidFinishNavigation];
pid_t initialWebProcessPID = [webView _webProcessIdentifier];
pid_t initialNetworkProcessPID = [[webView configuration].websiteDataStore _networkProcessIdentifier];
EXPECT_TRUE(!!initialWebProcessPID);
EXPECT_TRUE(!!initialNetworkProcessPID);
[[webView configuration].websiteDataStore _terminateNetworkProcess];
NSString *fetchJS = [NSString stringWithFormat:
@"fetch('%@/ping').then(r => r.text()).catch(e => 'err')",
server.origin().createNSString().get()];
__block bool fetchSettled = false;
[webView evaluateJavaScript:fetchJS completionHandler:^(id, NSError *) {
fetchSettled = true;
}];
TestWebKitAPI::Util::run(&fetchSettled);
pid_t reestablishedNetworkProcessPID = [[webView configuration].websiteDataStore _networkProcessIdentifier];
EXPECT_NE(initialNetworkProcessPID, reestablishedNetworkProcessPID);
__block bool webContentTerminated = false;
navDelegate.get().webContentProcessDidTerminate = ^(WKWebView *, _WKProcessTerminationReason) {
webContentTerminated = true;
};
[webView evaluateJavaScript:@"channel.port2.start();" completionHandler:nil];
TestWebKitAPI::Util::runFor(0.5_s);
EXPECT_FALSE(webContentTerminated);
EXPECT_EQ(initialWebProcessPID, [webView _webProcessIdentifier]);
}
TEST(MessagePortSecurity, WorkerMessagePortsSurviveNetworkProcessRestart)
{
TestWebKitAPI::HTTPServer server({
{ "/main"_s, { "<!doctype html><script>"
"window.worker = new Worker('/worker.js');"
"window.channel = new MessageChannel();"
"window.worker.onmessage = function(e) { alert('worker-' + e.data); };"
"window.worker.postMessage('init', [window.channel.port2]);"
"</script>"_s } },
{ "/worker.js"_s, { { { "Content-Type"_s, "application/javascript"_s } },
"self.port = null;"
"self.onmessage = function(e) {"
" if (e.data === 'init') {"
" self.port = e.ports[0];"
// Intentionally don't start the port here. Starting it after the NetworkProcess
// restart is what exercises the racy TakeAllMessagesForPort path.
" self.postMessage('ready');"
" } else if (e.data === 'start-stale-port') {"
" try {"
" self.port.start();"
" self.postMessage('start-ok');"
" } catch (err) { self.postMessage('start-threw-' + err.message); }"
" }"
"};"_s } },
{ "/ping"_s, { "ok"_s } },
});
RetainPtr<TestNavigationDelegate> navDelegate = adoptNS([TestNavigationDelegate new]);
RetainPtr<TestUIDelegate> uiDelegate = adoptNS([TestUIDelegate new]);
RetainPtr<WKWebViewConfiguration> config = adoptNS([[WKWebViewConfiguration alloc] init]);
RetainPtr<TestWKWebView> webView = adoptNS([[TestWKWebView alloc] initWithFrame:CGRectMake(0, 0, 800, 600) configuration:config.get()]);
[webView setNavigationDelegate:navDelegate.get()];
[webView setUIDelegate:uiDelegate.get()];
[webView loadRequest:server.request("/main"_s)];
[navDelegate waitForDidFinishNavigation];
EXPECT_WK_STREQ([uiDelegate waitForAlert], "worker-ready");
pid_t initialWebProcessPID = [webView _webProcessIdentifier];
pid_t initialNetworkProcessPID = [[webView configuration].websiteDataStore _networkProcessIdentifier];
[[webView configuration].websiteDataStore _terminateNetworkProcess];
NSString *fetchJS = [NSString stringWithFormat:
@"fetch('%@/ping').then(r => r.text()).catch(e => 'err')",
server.origin().createNSString().get()];
__block bool fetchSettled = false;
[webView evaluateJavaScript:fetchJS completionHandler:^(id, NSError *) {
fetchSettled = true;
}];
TestWebKitAPI::Util::run(&fetchSettled);
pid_t reestablishedNetworkProcessPID = [[webView configuration].websiteDataStore _networkProcessIdentifier];
EXPECT_NE(initialNetworkProcessPID, reestablishedNetworkProcessPID);
__block bool webContentTerminated = false;
navDelegate.get().webContentProcessDidTerminate = ^(WKWebView *, _WKProcessTerminationReason) {
webContentTerminated = true;
};
[webView evaluateJavaScript:@"worker.postMessage('start-stale-port');" completionHandler:nil];
EXPECT_WK_STREQ([uiDelegate waitForAlert], "worker-start-ok");
TestWebKitAPI::Util::runFor(0.5_s);
EXPECT_FALSE(webContentTerminated);
EXPECT_EQ(initialWebProcessPID, [webView _webProcessIdentifier]);
}