Prevent synchronous XHR in beforeunload / unload event handlers
https://bugs.webkit.org/show_bug.cgi?id=204912
<rdar://problem/57676394>
Reviewed by Darin Adler.
Source/WebCore:
Prevent synchronous XHR in beforeunload / unload event handlers. They are terrible for performance
and the Beacon API (or Fetch keepalive) are more efficient & supported alternatives.
In particular, this would cause hangs when trying to navigate away from a site or when closing
attempt, which would result in terrible user experience.
Chrome and Edge have expressed public support for this. Chrome has actually been testing this behavior
for a while now:
https://www.chromestatus.com/feature/4664843055398912
I added this new behavior behind an experimental feature flag, enabled by default.
Tests: http/tests/xmlhttprequest/sync-xhr-in-beforeunload.html
http/tests/xmlhttprequest/sync-xhr-in-unload.html
* loader/DocumentThreadableLoader.cpp:
(WebCore::DocumentThreadableLoader::DocumentThreadableLoader):
* loader/FrameLoader.cpp:
(WebCore::PageLevelForbidScope::PageLevelForbidScope):
(WebCore::ForbidPromptsScope::ForbidPromptsScope):
(WebCore::ForbidPromptsScope::~ForbidPromptsScope):
(WebCore::ForbidSynchronousLoadsScope::ForbidSynchronousLoadsScope):
(WebCore::ForbidSynchronousLoadsScope::~ForbidSynchronousLoadsScope):
(WebCore::FrameLoader::dispatchUnloadEvents):
(WebCore::FrameLoader::dispatchBeforeUnloadEvent):
* page/Page.cpp:
(WebCore::Page::forbidSynchronousLoads):
(WebCore::Page::allowSynchronousLoads):
(WebCore::Page::areSynchronousLoadsAllowed):
* page/Page.h:
LayoutTests:
Add layout test coverage.
* http/tests/xmlhttprequest/resources/sync-xhr-in-beforeunload-window.html: Added.
* http/tests/xmlhttprequest/resources/sync-xhr-in-unload-window.html: Added.
* http/tests/xmlhttprequest/sync-xhr-in-beforeunload-expected.txt: Added.
* http/tests/xmlhttprequest/sync-xhr-in-beforeunload.html: Added.
* http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt: Added.
* http/tests/xmlhttprequest/sync-xhr-in-unload.html: Added.
git-svn-id: http://svn.webkit.org/repository/webkit/trunk@253213 268f45cc-cd09-0410-ab3c-d52691b4dbfc
diff --git a/LayoutTests/ChangeLog b/LayoutTests/ChangeLog
index 1892efe..64b8132 100644
--- a/LayoutTests/ChangeLog
+++ b/LayoutTests/ChangeLog
@@ -1,3 +1,20 @@
+2019-12-06 Chris Dumez <cdumez@apple.com>
+
+ Prevent synchronous XHR in beforeunload / unload event handlers
+ https://bugs.webkit.org/show_bug.cgi?id=204912
+ <rdar://problem/57676394>
+
+ Reviewed by Darin Adler.
+
+ Add layout test coverage.
+
+ * http/tests/xmlhttprequest/resources/sync-xhr-in-beforeunload-window.html: Added.
+ * http/tests/xmlhttprequest/resources/sync-xhr-in-unload-window.html: Added.
+ * http/tests/xmlhttprequest/sync-xhr-in-beforeunload-expected.txt: Added.
+ * http/tests/xmlhttprequest/sync-xhr-in-beforeunload.html: Added.
+ * http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt: Added.
+ * http/tests/xmlhttprequest/sync-xhr-in-unload.html: Added.
+
2019-12-06 Antti Koivisto <antti@apple.com>
Support for resolving highlight pseudo element style
diff --git a/LayoutTests/http/tests/xmlhttprequest/resources/sync-xhr-in-beforeunload-window.html b/LayoutTests/http/tests/xmlhttprequest/resources/sync-xhr-in-beforeunload-window.html
new file mode 100644
index 0000000..47c6f4c
--- /dev/null
+++ b/LayoutTests/http/tests/xmlhttprequest/resources/sync-xhr-in-beforeunload-window.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script>
+window.addEventListener("beforeunload", (event) => {
+ xhr = new XMLHttpRequest();
+ xhr.open("GET", "/resources/dummy.xml", false);
+ try {
+ xhr.send(null);
+ opener.testFailed("Doing a synchronous XHR in the beforeunload event handler did not throw");
+ } catch (e) {
+ opener.testPassed("Doing a synchronous XHR in the beforeunload event handler threw an exception");
+ }
+ opener.finishJSTest();
+});
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/xmlhttprequest/resources/sync-xhr-in-unload-window.html b/LayoutTests/http/tests/xmlhttprequest/resources/sync-xhr-in-unload-window.html
new file mode 100644
index 0000000..458e2e5
--- /dev/null
+++ b/LayoutTests/http/tests/xmlhttprequest/resources/sync-xhr-in-unload-window.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+<body>
+<script>
+window.addEventListener("unload", (event) => {
+ xhr = new XMLHttpRequest();
+ xhr.open("GET", "/resources/dummy.xml", false);
+ try {
+ xhr.send(null);
+ opener.testFailed("Doing a synchronous XHR in the unload event handler did not throw");
+ } catch (e) {
+ opener.testPassed("Doing a synchronous XHR in the unload event handler threw an exception");
+ }
+ opener.finishJSTest();
+});
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-beforeunload-expected.txt b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-beforeunload-expected.txt
new file mode 100644
index 0000000..05f0e6d
--- /dev/null
+++ b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-beforeunload-expected.txt
@@ -0,0 +1,11 @@
+CONSOLE MESSAGE: line 9: XMLHttpRequest cannot load http://127.0.0.1:8000/resources/dummy.xml.
+Checks that trying to do a synchronous XHR in a beforeunload event handler is not allowed.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Doing a synchronous XHR in the beforeunload event handler threw an exception
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-beforeunload.html b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-beforeunload.html
new file mode 100644
index 0000000..d818342
--- /dev/null
+++ b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-beforeunload.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+</head>
+<body>
+<script>
+description("Checks that trying to do a synchronous XHR in a beforeunload event handler is not allowed.");
+jsTestIsAsync = true;
+
+if (window.testRunner)
+ testRunner.setCanOpenWindows();
+
+onload = () => {
+ w = open("resources/sync-xhr-in-beforeunload-window.html");
+ w.onload = () => {
+ setTimeout(() => {
+ w.close();
+ }, 0);
+ };
+}
+</script>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt
new file mode 100644
index 0000000..4c3619a
--- /dev/null
+++ b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt
@@ -0,0 +1,11 @@
+main frame - has 1 onunload handler(s)
+Checks that trying to do a synchronous XHR in a unload event handler is not allowed.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Doing a synchronous XHR in the unload event handler threw an exception
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-unload.html b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-unload.html
new file mode 100644
index 0000000..a5de8ae
--- /dev/null
+++ b/LayoutTests/http/tests/xmlhttprequest/sync-xhr-in-unload.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+</head>
+<body>
+<script>
+description("Checks that trying to do a synchronous XHR in a unload event handler is not allowed.");
+jsTestIsAsync = true;
+
+if (window.testRunner)
+ testRunner.setCanOpenWindows();
+
+onload = () => {
+ w = open("resources/sync-xhr-in-unload-window.html");
+ w.onload = () => {
+ setTimeout(() => {
+ w.close();
+ }, 0);
+ };
+}
+</script>
+</body>
+</html>
diff --git a/LayoutTests/platform/mac-wk1/http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt b/LayoutTests/platform/mac-wk1/http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt
new file mode 100644
index 0000000..1bd8a43
--- /dev/null
+++ b/LayoutTests/platform/mac-wk1/http/tests/xmlhttprequest/sync-xhr-in-unload-expected.txt
@@ -0,0 +1,12 @@
+main frame - has 1 onunload handler(s)
+CONSOLE MESSAGE: line 9: XMLHttpRequest cannot load http://127.0.0.1:8000/resources/dummy.xml.
+Checks that trying to do a synchronous XHR in a unload event handler is not allowed.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Doing a synchronous XHR in the unload event handler threw an exception
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/Source/WebCore/ChangeLog b/Source/WebCore/ChangeLog
index 11d21f1..3be3c29 100644
--- a/Source/WebCore/ChangeLog
+++ b/Source/WebCore/ChangeLog
@@ -1,3 +1,42 @@
+2019-12-06 Chris Dumez <cdumez@apple.com>
+
+ Prevent synchronous XHR in beforeunload / unload event handlers
+ https://bugs.webkit.org/show_bug.cgi?id=204912
+ <rdar://problem/57676394>
+
+ Reviewed by Darin Adler.
+
+ Prevent synchronous XHR in beforeunload / unload event handlers. They are terrible for performance
+ and the Beacon API (or Fetch keepalive) are more efficient & supported alternatives.
+
+ In particular, this would cause hangs when trying to navigate away from a site or when closing
+ attempt, which would result in terrible user experience.
+
+ Chrome and Edge have expressed public support for this. Chrome has actually been testing this behavior
+ for a while now:
+ https://www.chromestatus.com/feature/4664843055398912
+
+ I added this new behavior behind an experimental feature flag, enabled by default.
+
+ Tests: http/tests/xmlhttprequest/sync-xhr-in-beforeunload.html
+ http/tests/xmlhttprequest/sync-xhr-in-unload.html
+
+ * loader/DocumentThreadableLoader.cpp:
+ (WebCore::DocumentThreadableLoader::DocumentThreadableLoader):
+ * loader/FrameLoader.cpp:
+ (WebCore::PageLevelForbidScope::PageLevelForbidScope):
+ (WebCore::ForbidPromptsScope::ForbidPromptsScope):
+ (WebCore::ForbidPromptsScope::~ForbidPromptsScope):
+ (WebCore::ForbidSynchronousLoadsScope::ForbidSynchronousLoadsScope):
+ (WebCore::ForbidSynchronousLoadsScope::~ForbidSynchronousLoadsScope):
+ (WebCore::FrameLoader::dispatchUnloadEvents):
+ (WebCore::FrameLoader::dispatchBeforeUnloadEvent):
+ * page/Page.cpp:
+ (WebCore::Page::forbidSynchronousLoads):
+ (WebCore::Page::allowSynchronousLoads):
+ (WebCore::Page::areSynchronousLoadsAllowed):
+ * page/Page.h:
+
2019-12-06 Antti Koivisto <antti@apple.com>
Support for resolving highlight pseudo element style
diff --git a/Source/WebCore/loader/DocumentThreadableLoader.cpp b/Source/WebCore/loader/DocumentThreadableLoader.cpp
index f5e5515..3d26893 100644
--- a/Source/WebCore/loader/DocumentThreadableLoader.cpp
+++ b/Source/WebCore/loader/DocumentThreadableLoader.cpp
@@ -55,6 +55,7 @@
#include "RuntimeApplicationChecks.h"
#include "RuntimeEnabledFeatures.h"
#include "SecurityOrigin.h"
+#include "Settings.h"
#include "SharedBuffer.h"
#include "SubresourceIntegrity.h"
#include "SubresourceLoader.h"
@@ -131,6 +132,11 @@
// Setting a referrer header is only supported in the async code path.
ASSERT(m_async || m_referrer.isEmpty());
+ if (document.settings().disallowSyncXHRDuringPageDismissalEnabled() && !m_async && (!document.page() || !document.page()->areSynchronousLoadsAllowed())) {
+ logErrorAndFail(ResourceError(errorDomainWebKitInternal, 0, request.url(), "Synchronous loads are not allowed at this time"));
+ return;
+ }
+
// Referrer and Origin headers should be set after the preflight if any.
ASSERT(!request.hasHTTPReferrer() && !request.hasHTTPOrigin());
diff --git a/Source/WebCore/loader/FrameLoader.cpp b/Source/WebCore/loader/FrameLoader.cpp
index e105be0..0f1f281 100644
--- a/Source/WebCore/loader/FrameLoader.cpp
+++ b/Source/WebCore/loader/FrameLoader.cpp
@@ -210,22 +210,46 @@
return frame.document() && frame.document()->isSandboxed(mask);
}
-struct ForbidPromptsScope {
- ForbidPromptsScope(Page* page) : m_page(page)
+class PageLevelForbidScope {
+protected:
+ explicit PageLevelForbidScope(Page* page)
+ : m_page(makeWeakPtr(page))
{
- if (!m_page)
- return;
- m_page->forbidPrompts();
+ }
+
+ ~PageLevelForbidScope() = default;
+
+ WeakPtr<Page> m_page;
+};
+
+struct ForbidPromptsScope : public PageLevelForbidScope {
+ explicit ForbidPromptsScope(Page* page)
+ : PageLevelForbidScope(page)
+ {
+ if (m_page)
+ m_page->forbidPrompts();
}
~ForbidPromptsScope()
{
- if (!m_page)
- return;
- m_page->allowPrompts();
+ if (m_page)
+ m_page->allowPrompts();
+ }
+};
+
+struct ForbidSynchronousLoadsScope : public PageLevelForbidScope {
+ explicit ForbidSynchronousLoadsScope(Page* page)
+ : PageLevelForbidScope(page)
+ {
+ if (m_page)
+ m_page->forbidSynchronousLoads();
}
- Page* m_page;
+ ~ForbidSynchronousLoadsScope()
+ {
+ if (m_page)
+ m_page->allowSynchronousLoads();
+ }
};
class FrameLoader::FrameProgressTracker {
@@ -3298,6 +3322,7 @@
// We store the frame's page in a local variable because the frame might get detached inside dispatchEvent.
ForbidPromptsScope forbidPrompts(m_frame.page());
+ ForbidSynchronousLoadsScope forbidSynchronousLoads(m_frame.page());
IgnoreOpensDuringUnloadCountIncrementer ignoreOpensDuringUnloadCountIncrementer(m_frame.document());
if (m_didCallImplicitClose && !m_wasUnloadEventEmitted) {
@@ -3378,6 +3403,7 @@
{
SetForScope<PageDismissalType> change(m_pageDismissalEventBeingDispatched, PageDismissalType::BeforeUnload);
ForbidPromptsScope forbidPrompts(m_frame.page());
+ ForbidSynchronousLoadsScope forbidSynchronousLoads(m_frame.page());
domWindow->dispatchEvent(beforeUnloadEvent, domWindow->document());
}
diff --git a/Source/WebCore/page/Page.cpp b/Source/WebCore/page/Page.cpp
index 2271c19..3bea9d8 100644
--- a/Source/WebCore/page/Page.cpp
+++ b/Source/WebCore/page/Page.cpp
@@ -2418,6 +2418,22 @@
return !m_forbidPromptsDepth;
}
+void Page::forbidSynchronousLoads()
+{
+ ++m_forbidSynchronousLoadsDepth;
+}
+
+void Page::allowSynchronousLoads()
+{
+ ASSERT(m_forbidSynchronousLoadsDepth);
+ --m_forbidSynchronousLoadsDepth;
+}
+
+bool Page::areSynchronousLoadsAllowed()
+{
+ return !m_forbidSynchronousLoadsDepth;
+}
+
void Page::logNavigation(const Navigation& navigation)
{
String navigationDescription;
diff --git a/Source/WebCore/page/Page.h b/Source/WebCore/page/Page.h
index 059056e..0171238 100644
--- a/Source/WebCore/page/Page.h
+++ b/Source/WebCore/page/Page.h
@@ -584,6 +584,10 @@
void allowPrompts();
bool arePromptsAllowed();
+ void forbidSynchronousLoads();
+ void allowSynchronousLoads();
+ bool areSynchronousLoadsAllowed();
+
void mainFrameLoadStarted(const URL&, FrameLoadType);
void setLastSpatialNavigationCandidateCount(unsigned count) { m_lastSpatialNavigationCandidatesCount = count; }
@@ -918,6 +922,7 @@
unsigned m_lastSpatialNavigationCandidatesCount { 0 };
unsigned m_forbidPromptsDepth { 0 };
+ unsigned m_forbidSynchronousLoadsDepth { 0 };
Ref<SocketProvider> m_socketProvider;
Ref<CookieJar> m_cookieJar;
diff --git a/Source/WebCore/page/Settings.yaml b/Source/WebCore/page/Settings.yaml
index a3e3526..0ea6a04 100644
--- a/Source/WebCore/page/Settings.yaml
+++ b/Source/WebCore/page/Settings.yaml
@@ -54,6 +54,10 @@
type: int
initial: 0
+disallowSyncXHRDuringPageDismissalEnabled:
+ type: bool
+ initial: true
+
# Allow clients concerned with memory consumption to set a quota on session storage
# since the memory used won't be released until the Page is destroyed.
sessionStorageQuota:
diff --git a/Source/WebKit/Shared/WebPreferences.yaml b/Source/WebKit/Shared/WebPreferences.yaml
index 835fdc8..8d9595c 100644
--- a/Source/WebKit/Shared/WebPreferences.yaml
+++ b/Source/WebKit/Shared/WebPreferences.yaml
@@ -18,6 +18,13 @@
condition: ENABLE(DEVICE_ORIENTATION)
webcoreName: deviceOrientationPermissionAPIEnabled
+DisallowSyncXHRDuringPageDismissalEnabled:
+ type: bool
+ defaultValue: true
+ humanReadableName: "Disallow sync XHR during page dismissal"
+ humanReadableDescription: "Disallow synchronous XMLHttpRequest during page dismissal"
+ category: experimental
+
JavaScriptEnabled:
type: bool
defaultValue: true