Bug 1922677 - Rate limit every navigation. r=smaug,zcorpan,firefox-style-system-reviewers,emilio

Differential Revision: https://phabricator.services.mozilla.com/D300744
This commit is contained in:
Andreas Farre
2026-09-09 13:29:13 +00:00
committed by afarre@mozilla.com
parent eca98b505f
commit 73a7bc0a7a
34 changed files with 220 additions and 126 deletions
+10 -19
View File
@@ -2617,15 +2617,6 @@ void BrowsingContext::Navigate(
dom::NavigationAPIMethodTracker* aNavigationAPIMethodTracker) {
MOZ_LOG_FMT(gNavigationAPILog, LogLevel::Debug, "Navigate to {} as {}", *aURI,
aHistoryHandling);
CallerType callerType = aSubjectPrincipal.IsSystemPrincipal()
? CallerType::System
: CallerType::NonSystem;
nsresult rv = CheckNavigationRateLimit(callerType);
if (NS_FAILED(rv)) {
aRv.Throw(rv);
return;
}
RefPtr<nsDocShellLoadState> loadState =
CheckURLAndCreateLoadState(aURI, aSubjectPrincipal, aSourceDocument, aRv);
@@ -2677,7 +2668,7 @@ void BrowsingContext::Navigate(
loadState->SetNavigationAPIState(aNavigationAPIState);
loadState->SetNavigationAPIMethodTracker(aNavigationAPIMethodTracker);
rv = LoadURI(loadState);
nsresult rv = LoadURI(loadState);
if (NS_WARN_IF(NS_FAILED(rv))) {
if (rv == NS_ERROR_DOM_BAD_CROSS_ORIGIN_URI &&
loadState->URI()->SchemeIs("javascript")) {
@@ -4638,10 +4629,10 @@ bool BrowsingContext::ShouldUpdateSessionHistory(uint32_t aLoadType) {
(IsForceReloadType(aLoadType) && IsSubframe()));
}
nsresult BrowsingContext::CheckNavigationRateLimit(CallerType aCallerType) {
bool BrowsingContext::CheckNavigationRateLimit(CallerType aCallerType) {
// We only rate limit non system callers
if (aCallerType == CallerType::System) {
return NS_OK;
return true;
}
// Fetch rate limiting preferences
@@ -4651,7 +4642,7 @@ nsresult BrowsingContext::CheckNavigationRateLimit(CallerType aCallerType) {
// Disable throttling if either of the preferences is set to 0.
if (limitCount == 0 || timeSpanSeconds == 0) {
return NS_OK;
return true;
}
TimeDuration throttleSpan = TimeDuration::FromSeconds(timeSpanSeconds);
@@ -4661,24 +4652,24 @@ nsresult BrowsingContext::CheckNavigationRateLimit(CallerType aCallerType) {
// Initial call or timespan exceeded, reset counter and timespan.
mNavigationRateLimitSpanStart = TimeStamp::Now();
mNavigationRateLimitCount = 1;
return NS_OK;
return true;
}
if (mNavigationRateLimitCount >= limitCount) {
if (NS_WARN_IF(mNavigationRateLimitCount >= limitCount)) {
// Rate limit reached
Document* doc = GetDocument();
if (doc) {
nsContentUtils::ReportToConsole(nsIScriptError::errorFlag, "DOM"_ns, doc,
PropertiesFile::DOM_PROPERTIES,
"LocChangeFloodingPrevented");
"NavigationChangeFloodingPrevented");
}
return NS_ERROR_DOM_SECURITY_ERR;
return false;
}
mNavigationRateLimitCount++;
return NS_OK;
return true;
}
void BrowsingContext::ResetNavigationRateLimit() {
+7 -7
View File
@@ -1054,13 +1054,13 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache {
bool ShouldUpdateSessionHistory(uint32_t aLoadType);
// Checks if we reached the rate limit for calls to Location and History API.
// The rate limit is controlled by the
// "dom.navigation.navigationRateLimit" prefs.
// Rate limit applies per BrowsingContext.
// Returns NS_OK if we are below the rate limit and increments the counter.
// Returns NS_ERROR_DOM_SECURITY_ERR if limit is reached.
nsresult CheckNavigationRateLimit(CallerType aCallerType);
// Checks if we reached the rate limit for navigations, which includes calls
// to the Location and History APIs.
// The rate limit is controlled by the "dom.navigation.navigationRateLimit"
// prefs. Rate limit applies per BrowsingContext. Returns true if we are below
// the rate limit and increments the counter. Returns false if the limit is
// reached
bool CheckNavigationRateLimit(CallerType aCallerType);
void ResetNavigationRateLimit();
+17 -3
View File
@@ -8725,6 +8725,15 @@ nsresult nsDocShell::InternalLoad(nsDocShellLoadState* aLoadState,
}
}
CallerType callerType = aLoadState->TriggeringPrincipal()->IsSystemPrincipal()
? CallerType::System
: CallerType::NonSystem;
if (!aLoadState->LoadIsFromSessionHistory() &&
!mBrowsingContext->CheckNavigationRateLimit(callerType)) {
return NS_OK;
}
// See if this is actually a load between two history entries for the same
// document. If the process fails, or if we successfully navigate within the
// same document, return.
@@ -10897,9 +10906,10 @@ bool nsDocShell::CollectWireframe() {
// nsDocShell: Session History
//*****************************************************************************
NS_IMETHODIMP
nsDocShell::AddState(JS::Handle<JS::Value> aData, const nsAString& aTitle,
const nsAString& aURL, bool aReplace, JSContext* aCx) {
nsresult nsDocShell::AddState(JS::Handle<JS::Value> aData,
const nsAString& aTitle, const nsAString& aURL,
CallerType aCallerType, bool aReplace,
JSContext* aCx) {
MOZ_LOG(gSHLog, LogLevel::Debug,
("nsDocShell[%p]: AddState(..., %s, %s, %d)", this,
NS_ConvertUTF16toUTF8(aTitle).get(),
@@ -11062,6 +11072,10 @@ nsDocShell::AddState(JS::Handle<JS::Value> aData, const nsAString& aTitle,
} // end of same-origin check
if (!mBrowsingContext->CheckNavigationRateLimit(aCallerType)) {
return NS_OK;
}
// https://html.spec.whatwg.org/#shared-history-push/replace-state-steps
// Step 8
if (nsCOMPtr<nsPIDOMWindowInner> window = document->GetInnerWindow()) {
+10 -1
View File
@@ -1096,7 +1096,16 @@ class nsDocShell final : public nsDocLoader,
void MaybeDisconnectChildListenersOnPageHide();
/**
* Helper for addState and document.open that does just the
* Do either a history.pushState() or history.replaceState() operation,
* depending on the value of aReplace.
*/
MOZ_CAN_RUN_SCRIPT
nsresult AddState(JS::Handle<JS::Value> aData, const nsAString& aTitle,
const nsAString& aURL, mozilla::dom::CallerType aCallerType,
bool aReplace, JSContext* aCx);
/**
* Helper for AddState and document.open that does just the
* history-manipulation guts.
*
* Arguments the spec defines:
-8
View File
@@ -101,14 +101,6 @@ interface nsIDocShell : nsIDocShellTreeItem
*/
[noscript]void loadURI(in nsDocShellLoadStatePtr aLoadState, in boolean aSetNavigating);
/**
* Do either a history.pushState() or history.replaceState() operation,
* depending on the value of aReplace.
*/
[implicit_jscontext, can_run_script]
void addState(in jsval aData, in AString aTitle,
in AString aURL, in boolean aReplace);
/**
* Reset state to a new content model within the current document and the document
* viewer. Called by the document before initiating an out of band document.write().
+2
View File
@@ -264,6 +264,8 @@ skip-if = [
["test_rate_limit_location_change.html"]
["test_rate_limit_same_document_navigation.html"]
["test_recursive_frames.html"]
skip-if = [
"http2",
@@ -44,6 +44,23 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
"location.reload": () => win.location.reload(),
});
// pushState is rate limited and takes effect synchronously, which makes it
// usable as a probe for whether the rate limit is currently in effect.
function isRateLimited(win) {
const before = win.location.href;
win.history.pushState(null, "test", `#probe${inc++}`);
return win.location.href == before;
}
function mustNotThrow(fn, name) {
try {
fn();
ok(true, `${name} must not throw.`);
} catch (error) {
ok(false, `${name} must not throw, but threw ${error}.`);
}
}
async function test() {
await setup();
@@ -61,27 +78,21 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
for(let i = 0; i< RATE_LIMIT_COUNT; i++) {
fn.call(this);
}
// Next calls should throw because we're above the rate limit
// Next calls are above the rate limit, and should be silently ignored.
for(let i = 0; i < 5; i++) {
SimpleTest.doesThrow(() => fn.call(this), `Call #${RATE_LIMIT_COUNT + i + 1} to ${name} should throw.`);
mustNotThrow(() => fn.call(this), `Call #${RATE_LIMIT_COUNT + i + 1} to ${name}`);
}
ok(isRateLimited(win), `Calling ${name} should reach the rate limit.`);
})
// We didn't reset the rate limit after the last loop iteration above.
// Wait for the rate limit timer to expire.
SimpleTest.requestFlakyTimeout("Waiting to trigger rate limit reset.");
await new Promise((resolve) => setTimeout(resolve, 5000));
await new Promise((resolve) => setTimeout(resolve, (RATE_LIMIT_TIME_SPAN + 2) * 1000));
// Calls should be allowed again.
Object.entries(rateLimitedFunctions(win)).forEach(([name, fn]) => {
let didThrow = false;
try {
fn.call(this);
} catch(error) {
didThrow = true;
}
is(didThrow, false, `Call to ${name} must not throw.`)
});
// Navigations should be allowed again.
ok(!isRateLimited(win), "The rate limit should be lifted after the time span.");
// Cleanup
win.close();
@@ -0,0 +1,108 @@
<!DOCTYPE HTML>
<html>
<!--
https://bugzilla.mozilla.org/show_bug.cgi?id=1922677
-->
<head>
<meta charset="utf-8">
<title>Test for Bug 1922677</title>
<script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
<script type="application/javascript">
/** Test for Bug 1922677 */
const RATE_LIMIT_COUNT = 5;
// Long enough that the rate limit never resets on its own during the test.
const RATE_LIMIT_TIME_SPAN = 100;
// Time to wait for a hashchange event that we expect to never be fired.
const NO_HASH_CHANGE_TIMEOUT = 500;
SimpleTest.waitForExplicitFinish();
SimpleTest.requestFlakyTimeout(
"Waiting for a hashchange event that should not be fired.");
async function setup() {
await SpecialPowers.pushPrefEnv({set: [
["dom.navigation.navigationRateLimit.count", RATE_LIMIT_COUNT],
["dom.navigation.navigationRateLimit.timespan", RATE_LIMIT_TIME_SPAN]]});
}
function nextHashChange(aWin) {
return new Promise((resolve) => {
aWin.addEventListener("hashchange", resolve, {once: true});
});
}
// Resolves to false if no hashchange event was fired in aWin within
// NO_HASH_CHANGE_TIMEOUT, and to true if one was fired.
function noHashChange(aWin) {
return new Promise((resolve) => {
const listener = () => {
clearTimeout(timer);
resolve(true);
};
aWin.addEventListener("hashchange", listener, {once: true});
const timer = setTimeout(() => {
aWin.removeEventListener("hashchange", listener);
resolve(false);
}, NO_HASH_CHANGE_TIMEOUT);
});
}
async function test() {
await setup();
const iframe = document.getElementById("iframe");
await new Promise((resolve) => {
iframe.addEventListener("load", resolve, {once: true});
iframe.src = "blank.html";
});
const win = iframe.contentWindow;
const link = win.document.createElement("a");
win.document.body.appendChild(link);
const clickFragment = (aFragment) => {
link.setAttribute("href", `#${aFragment}`);
link.click();
};
// Loading the iframe counted towards the rate limit.
SpecialPowers.wrap(win).browsingContext.resetNavigationRateLimit();
for (let i = 1; i <= RATE_LIMIT_COUNT; ++i) {
const hashChanged = nextHashChange(win);
clickFragment(i);
await hashChanged;
is(win.location.hash, `#${i}`, `Fragment navigation #${i} to #${i}.`);
}
const noHashChanged = noHashChange(win);
clickFragment("blocked");
ok(!await noHashChanged,
"Fragment navigation above the rate limit should not be performed.");
is(win.location.hash, `#${RATE_LIMIT_COUNT}`,
"Fragment should be left untouched by the rate limited navigation.");
// Once the rate limit is reset, fragment navigations should work again.
SpecialPowers.wrap(win).browsingContext.resetNavigationRateLimit();
const hashChangedAgain = nextHashChange(win);
clickFragment("allowed");
await hashChangedAgain;
is(win.location.hash, "#allowed",
"Fragment navigation should be performed after resetting the limit.");
SpecialPowers.wrap(win).browsingContext.resetNavigationRateLimit();
SimpleTest.finish();
}
</script>
</head>
<body onload="setTimeout(test, 0);">
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1922677">Mozilla Bug 1922677</a>
<iframe id="iframe"></iframe>
</body>
</html>
+3 -5
View File
@@ -595,9 +595,7 @@ void Location::Reload(JSContext* aCx, bool aForceget,
? CallerType::System
: CallerType::NonSystem;
nsresult rv = bc->CheckNavigationRateLimit(callerType);
if (NS_FAILED(rv)) {
aRv.Throw(rv);
if (!bc->CheckNavigationRateLimit(callerType)) {
return;
}
@@ -612,8 +610,8 @@ void Location::Reload(JSContext* aCx, bool aForceget,
callerType == CallerType::System ? UserNavigationInvolvement::BrowserUI
: UserNavigationInvolvement::None;
rv = docShell->ReloadNavigable(Some(WrapNotNull(aCx)), reloadFlags, nullptr,
userInvolvement);
nsresult rv = docShell->ReloadNavigable(Some(WrapNotNull(aCx)), reloadFlags,
nullptr, userInvolvement);
if (NS_FAILED(rv) && rv != NS_BINDING_ABORTED) {
// NS_BINDING_ABORTED is returned when we attempt to reload a POST result
// and the user says no at the "do you want to reload?" prompt. Don't
+10 -19
View File
@@ -24,13 +24,9 @@ extern LazyLogModule gSHistoryLog;
#define LOG(format) MOZ_LOG(gSHistoryLog, mozilla::LogLevel::Debug, format)
static bool CheckNavigationRateLimit(BrowsingContext* aContext,
CallerType aCallerType, ErrorResult& aRv) {
CallerType aCallerType) {
if (aContext) {
nsresult rv = aContext->CheckNavigationRateLimit(aCallerType);
if (NS_FAILED(rv)) {
aRv.Throw(rv);
return false;
}
return aContext->CheckNavigationRateLimit(aCallerType);
}
return true;
@@ -105,7 +101,7 @@ void nsHistory::SetScrollRestoration(mozilla::dom::ScrollRestoration aMode,
return;
}
if (!CheckNavigationRateLimit(win->GetBrowsingContext(), aCallerType, aRv)) {
if (!CheckNavigationRateLimit(win->GetBrowsingContext(), aCallerType)) {
return;
}
@@ -175,19 +171,15 @@ void nsHistory::PushOrReplaceState(JSContext* aCx, JS::Handle<JS::Value> aData,
return;
}
if (!win->HasActiveDocument()) {
if (!win->IsFullyActive()) {
aRv.Throw(NS_ERROR_DOM_SECURITY_ERR);
return;
}
if (!CheckNavigationRateLimit(win->GetBrowsingContext(), aCallerType, aRv)) {
return;
}
// AddState might run scripts, so we need to hold a strong reference to the
// docShell here to keep it from going away.
nsCOMPtr<nsIDocShell> docShell = win->GetDocShell();
RefPtr docShell = nsDocShell::Cast(win->GetDocShell());
if (!docShell) {
aRv.Throw(NS_ERROR_FAILURE);
@@ -197,8 +189,7 @@ void nsHistory::PushOrReplaceState(JSContext* aCx, JS::Handle<JS::Value> aData,
// The "replace" argument tells the docshell to whether to add a new
// history entry or modify the current one.
aRv = docShell->AddState(aData, aTitle, aUrl, aReplace, aCx);
aRv = docShell->AddState(aData, aTitle, aUrl, aCallerType, aReplace, aCx);
}
already_AddRefed<ChildSHistory> nsHistory::GetSessionHistory() const {
@@ -226,12 +217,12 @@ void nsHistory::DeltaTraverse(mozilla::Maybe<NotNull<JSContext*>> aCx,
return;
}
if (!CheckNavigationRateLimit(win->GetBrowsingContext(), aCallerType, aRv)) {
MOZ_LOG(gSHistoryLog, LogLevel::Debug, ("Rejected"));
// Step 3
if (!CheckNavigationRateLimit(win->GetBrowsingContext(), aCallerType)) {
return;
}
// Step 3
// Step 4
if (!aDelta) {
MOZ_DIAGNOSTIC_ASSERT(aCx);
RefPtr<nsDocShell> docShell = nsDocShell::Cast(win->GetDocShell());
@@ -244,7 +235,7 @@ void nsHistory::DeltaTraverse(mozilla::Maybe<NotNull<JSContext*>> aCx,
return;
}
// Step 4 is the remainder of this method.
// Step 5 is the remainder of this method.
RefPtr<ChildSHistory> session_history = GetSessionHistory();
if (!session_history) {
aRv.Throw(NS_ERROR_FAILURE);
+1
View File
@@ -4,6 +4,7 @@ prefs = [
"formhelper.autozoom.force-disable.test-only=true",
"network.http.referer.sendFromRefresh=false",
"plugins.rewrite_youtube_embeds=true",
"dom.navigation.navigationRateLimit.count=0",
]
support-files = [
"audio.ogg",
+1
View File
@@ -4,6 +4,7 @@ prefs = [
"formhelper.autozoom.force-disable.test-only=true",
"network.http.referer.sendFromRefresh=false",
"plugins.rewrite_youtube_embeds=true",
"dom.navigation.navigationRateLimit.count=0",
]
support-files = [
"audio.ogg",
+2
View File
@@ -1,3 +1,5 @@
defaults pref(dom.navigation.navigationRateLimit.count,0)
load 68912-1.html
load 257818-1.html
load 285166-1.html
+4 -1
View File
@@ -1,5 +1,8 @@
[DEFAULT]
prefs = ["gfx.font_loader.delay=0"]
prefs = [
"gfx.font_loader.delay=0",
"dom.navigation.navigationRateLimit.count=0",
]
support-files = [
"347174transform.xsl",
"347174transformable.xml",
+5 -1
View File
@@ -1,5 +1,9 @@
[DEFAULT]
prefs = ["gfx.font_loader.delay=0"]
prefs = [
"apz.zoom-to-focused-input.enabled=false",
"gfx.font_loader.delay=0",
"dom.navigation.navigationRateLimit.count=0",
]
support-files = [
"347174transform.xsl",
"347174transformable.xml",
+1 -1
View File
@@ -454,7 +454,7 @@ RequestStorageAccessPermissionsPolicy=document.requestStorageAccess() may not be
# LOCALIZATION NOTE: Do not translate document.requestStorageAccess()
RequestStorageAccessNotSecureContext=document.requestStorageAccess() may only grant access to secure contexts.
# LOCALIZATION NOTE: Do not translate "Location" and "History".
LocChangeFloodingPrevented=Too many calls to Location or History APIs within a short timeframe.
NavigationChangeFloodingPrevented=Too many attempts to navigate or modify history within a short timeframe.
FolderUploadPrompt.title = Confirm Upload
# LOCALIZATION NOTE: %S is the name of the folder the user selected in the file picker.
FolderUploadPrompt.message = Are you sure you want to upload all files from “%S”? Only do this if you trust the site.
+1
View File
@@ -253,6 +253,7 @@ support-files = [
]
prefs = [
"security.mixed_content.upgrade_display_content=false",
"dom.navigation.navigationRateLimit.count=0",
]
["test_301_redirect.html"]
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"DOMTestCase.js",
"activity-home.css",
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"DOMTestCase.js",
"activity-home.css",
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"DOMTestCase.js",
"exclusions.js",
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"DOMTestCase.js",
"files/anchor.html",
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"DOMTestCase.js",
"files/anchor.html",
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"DOMTestCase.js",
"files/anchor.html",
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"DOMTestCase.js",
"files/anchor.html",
+1
View File
@@ -1,6 +1,7 @@
[DEFAULT]
prefs = [
"apz.zoom-to-focused-input.enabled=false",
"dom.navigation.navigationRateLimit.count=0",
"test.ime_content_observer.assert_invalid_cache=true",
"ui.dragThresholdX=4", # Bug 1873142
"ui.dragThresholdY=4", # Bug 1873142
+2 -1
View File
@@ -1,5 +1,6 @@
[DEFAULT]
prefs = [
"dom.navigation.navigationRateLimit.count=0",
"gfx.omta.background-color=true",
"gfx.font_loader.delay=0",
"layout.css.motion-path-url.enabled=true",
@@ -17,7 +18,7 @@ prefs = [
"layout.css.text-decoration-inset-percentage.enabled=true",
"layout.css.link-parameters.enabled=true",
"layout.css.ellipse-corners.enabled=true",
"layout.css.calc-typed-arithmetic.enabled=true"
"layout.css.calc-typed-arithmetic.enabled=true",
]
support-files = [
"animation_utils.js",
+3 -3
View File
@@ -4008,14 +4008,14 @@
value: false
mirror: always
# Limit of location change caused by content scripts in a time span per
# Limit of navigations initiated by content in a time span per
# BrowsingContext. This includes calls to History and Location APIs.
- name: dom.navigation.navigationRateLimit.count
type: uint32_t
value: 1000
value: 200
mirror: always
# Time span in seconds for location change rate limit.
# Time span in seconds for the navigation rate limit.
- name: dom.navigation.navigationRateLimit.timespan
type: uint32_t
value: 10
@@ -1,4 +1,5 @@
[DEFAULT]
prefs = ["dom.navigation.navigationRateLimit.count=0"]
support-files = [
"bug_502091_iframe.html",
"file_bug102699.sjs",
@@ -1,6 +1,3 @@
[navigate_too_many_calls.optional.html]
[fragment navigations through navigate are ignored after too many calls]
expected: FAIL
[rate-limited navigations cancel pending lazy-load iframe navigations]
expected: FAIL
@@ -1,11 +0,0 @@
[history_go_too_many_calls.optional.html]
expected:
if (os == "mac") and not debug: TIMEOUT
[history go too many calls]
expected:
if (os == "mac") and not debug: TIMEOUT
FAIL
[fully active check should be before rate limit check]
expected:
if (os == "mac") and not debug: NOTRUN
@@ -1,3 +0,0 @@
[history_pushstate_too_many_calls.optional.html]
[history pushState too many calls]
expected: FAIL
@@ -1,12 +0,0 @@
[history_pushstate_too_many_calls_ordering.optional.html]
[pushState serializes state before checking the rate limit]
expected: FAIL
[pushState parses the URL before checking the rate limit]
expected: FAIL
[pushState checks whether the URL can be rewritten before checking the rate limit]
expected: FAIL
[pushState checks fully active before checking the rate limit]
expected: FAIL
@@ -1,3 +0,0 @@
[history_replacestate_too_many_calls.optional.html]
[history replaceState too many calls]
expected: FAIL
@@ -1,12 +0,0 @@
[history_replacestate_too_many_calls_ordering.optional.html]
[replaceState serializes state before checking the rate limit]
expected: FAIL
[replaceState parses the URL before checking the rate limit]
expected: FAIL
[replaceState checks whether the URL can be rewritten before checking the rate limit]
expected: FAIL
[replaceState checks fully active before checking the rate limit]
expected: FAIL