diff --git a/lib/PuppeteerSharp.Tests/NetworkRestrictionTests/NetworkRestrictionsTests.cs b/lib/PuppeteerSharp.Tests/NetworkRestrictionTests/NetworkRestrictionsTests.cs index 3ce5bc02c..3501dc8f4 100644 --- a/lib/PuppeteerSharp.Tests/NetworkRestrictionTests/NetworkRestrictionsTests.cs +++ b/lib/PuppeteerSharp.Tests/NetworkRestrictionTests/NetworkRestrictionsTests.cs @@ -88,6 +88,35 @@ public async Task ShouldFailFetchRequestsToUrlsInBlocklist() Assert.That(fetchError, Does.Contain("Failed to fetch")); } + [Test, PuppeteerTest("network_restrictions.spec", "Network Restrictions", "should fail service worker registration for blocklisted script URLs")] + public async Task ShouldFailServiceWorkerRegistrationForBlocklistedScriptUrls() + { + var options = TestConstants.DefaultBrowserOptions(); + options.BlockList = ["*://*:*/empty.html", "*://*:*/pptr.png", "*://*:*/serviceworkers/empty/sw.js"]; + + await using var browser = await Puppeteer.LaunchAsync(options, TestConstants.LoggerFactory); + await using var page = await browser.NewPageAsync(); + + var allowedUrl = TestConstants.ServerUrl + "/title.html"; + var blockedUrl = TestConstants.ServerUrl + "/serviceworkers/empty/sw.js"; + + await page.GoToAsync(allowedUrl); + + var swError = await page.EvaluateFunctionAsync( + @"async (url) => { + try { + await navigator.serviceWorker.register(url); + return null; + } catch (e) { + return e.message; + } + }", + blockedUrl); + + Assert.That(swError, Is.Not.Null.And.Not.Empty); + Assert.That(swError, Does.Contain("Failed to register a ServiceWorker")); + } + [Test, PuppeteerTest("network_restrictions.spec", "Network Restrictions blocklist validation", "should fail fetch requests from within a service worker to URLs in the blocklist")] public async Task ShouldFailFetchRequestsFromWithinServiceWorkerToUrlsInBlocklist() { @@ -303,6 +332,35 @@ public async Task ShouldFailFetchRequestsToUrlsNotInAllowlist() Assert.That(fetchError, Does.Contain("Failed to fetch")); } + [Test, PuppeteerTest("network_restrictions.spec", "Network Restrictions", "should fail service worker registration for script URLs not in the allowlist")] + public async Task ShouldFailServiceWorkerRegistrationForScriptUrlsNotInAllowlist() + { + var options = TestConstants.DefaultBrowserOptions(); + options.Allowlist = ["*://*:*/empty.html"]; + + await using var browser = await Puppeteer.LaunchAsync(options, TestConstants.LoggerFactory); + await using var page = await browser.NewPageAsync(); + + var allowedUrl = TestConstants.ServerUrl + "/empty.html"; + var blockedUrl = TestConstants.ServerUrl + "/serviceworkers/empty/sw.js"; + + await page.GoToAsync(allowedUrl); + + var swError = await page.EvaluateFunctionAsync( + @"async (url) => { + try { + await navigator.serviceWorker.register(url); + return null; + } catch (e) { + return e.message; + } + }", + blockedUrl); + + Assert.That(swError, Is.Not.Null.And.Not.Empty); + Assert.That(swError, Does.Contain("Failed to register a ServiceWorker")); + } + [Test, PuppeteerTest("network_restrictions.spec", "Network Restrictions", "should prevent loading of subresources not in the allowlist (e.g., images)")] public async Task ShouldPreventLoadingOfSubresourcesNotInAllowlist() { diff --git a/lib/PuppeteerSharp/Cdp/ChromeTargetManager.cs b/lib/PuppeteerSharp/Cdp/ChromeTargetManager.cs index 7cb5dd6e3..63083e471 100644 --- a/lib/PuppeteerSharp/Cdp/ChromeTargetManager.cs +++ b/lib/PuppeteerSharp/Cdp/ChromeTargetManager.cs @@ -366,9 +366,7 @@ private async Task OnAttachedToTargetAsync(object sender, TargetAttachedToTarget { if (!IsUrlAllowed(targetInfo.Url)) { - await Task.WhenAll( - MaybeSetupNetworkBlockListAsync(session, targetInfo), - session.SendAsync("Runtime.runIfWaitingForDebugger")).ConfigureAwait(false); + await BlockServiceWorkerRegistrationAsync(session).ConfigureAwait(false); return; } @@ -512,6 +510,58 @@ private void OnDetachedFromTarget(object sender, TargetDetachedFromTargetRespons TargetGone?.Invoke(this, new TargetChangedArgs { Target = target }); } + // Blocks the registration of a disallowed service worker by failing every request it makes + // (including its own main script fetch) at the Fetch layer. Using Fetch interception rather + // than Network.emulateNetworkConditions is deliberate: a paused worker (waitForDebuggerOnStart) + // answers no CDP command until it is resumed, so we cannot await any setup before resuming; + // and offline emulation only sets a connectivity state that races the script fetch (and on some + // transports stalls it instead of failing it). Fetch.enable is queued ahead of + // runIfWaitingForDebugger on the same session (FIFO), so interception is active before the + // worker can fetch anything, and Fetch holds each request until we fail it — no race, no stall, + // deterministic across transports. This diverges from upstream (which uses offline emulation and + // only exercises headless+websocket in CI, so it never hits the race/stall). + private async Task BlockServiceWorkerRegistrationAsync(CDPSession session) + { + async void OnRequestPaused(object sender, MessageEventArgs e) + { + if (e.MessageID != "Fetch.requestPaused") + { + return; + } + + var paused = e.MessageData.ToObject(); + try + { + await session.SendAsync( + "Fetch.failRequest", + new FetchFailRequest + { + RequestId = paused.RequestId, + ErrorReason = "BlockedByClient", + }).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fail blocked service worker request"); + } + } + + session.MessageReceived += OnRequestPaused; + + try + { + await Task.WhenAll( + session.SendAsync( + "Fetch.enable", + new FetchEnableRequest { Patterns = [new FetchEnableRequest.Pattern("*")] }), + session.SendAsync("Runtime.runIfWaitingForDebugger")).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to block service worker registration"); + } + } + private async Task MaybeSetupNetworkBlockListAsync(CDPSession session, TargetInfo targetInfo) { var hasBlockList = _blockList != null && _blockList.Length > 0;