CVE-2024-10464
Overview
Background
- `BrowsingContext`
- The docshell-level object representing a browsing context (a top-level page or frame) that owns per-context state, including the counters used to rate limit navigation and history operations.
- Rate limiting
- A defensive mechanism that caps how many times a web-exposed API can be invoked within a fixed time span, returning an error once the threshold is exceeded to prevent abuse.
- `CallerType`
- An enum distinguishing
CallerType::System(privileged chrome/internal callers) fromCallerType::NonSystem(untrusted web content), used to decide whether rate limiting should apply. - History interface
- The web-exposed
HistoryAPI (nsHistory, backingwindow.history) whose attribute and method calls (e.g.pushState,go,scrollRestoration) can trigger session-history mutations.
Root Cause Analysis
Firefox already had a rate limiter, BrowsingContext::CheckLocationChangeRateLimit, that throttled non-system callers of the Location API, but it was named and conceptually scoped to location changes only. Calls originating from the History interface attributes and methods — routed through code such as ChildSHistory::AsyncGo and nsHistory — were not consistently subjected to the same per-BrowsingContext throttle, so untrusted content could invoke history operations in a tight loop without ever hitting a cap. This violated the invariant that all navigation-affecting, web-reachable APIs on a BrowsingContext should share a single enforced rate limit for non-system callers.
The fix generalizes the mechanism by renaming CheckLocationChangeRateLimit to CheckNavigationRateLimit (and the associated counters mLocationChangeRateLimitCount / mLocationChangeRateLimitSpanStart to mNavigationRateLimitCount / mNavigationRateLimitSpanStart, and prefs dom.navigation.locationChangeRateLimit.* to dom.navigation.navigationRateLimit.*), reflecting that the same limiter now governs both Location and History navigation paths. It also threads CallerType into nsHistory::GetScrollRestoration so history-attribute access participates in the caller-aware throttling. Because every navigation entry point now funnels through the unified CheckNavigationRateLimit counter, repeated history writes are counted and rejected with NS_ERROR_DOM_SECURITY_ERR once the limit is reached.
History API able to bypass the throttle; the fix renames and re-scopes the check into a single CheckNavigationRateLimit that all navigation and history paths share.Attack Path
- Obtain script execution
An attacker gets untrusted JavaScript running in a normal web page, so all calls are classified as
CallerType::NonSystem. - Target the unthrottled History API
The script repeatedly writes to
window.historyinterface attributes and methods, whose path did not pass through theLocation-scopedCheckLocationChangeRateLimitlimiter. - Spin a tight loop
Because no unified per-
BrowsingContextcounter gated these history operations, the script issues history writes far faster than any intended cap. - Exhaust resources The unbounded stream of history mutations consumes CPU and session-history processing, degrading or hanging the browser into a Denial of Service condition.
Impact Assessment
History interface rather than any memory-safety flaw.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifdocshell/base/BrowsingContext.cpp |
modified | |
ifdom/base/nsHistory.cpp |
modified | |
fordocshell/test/navigation/test_rate_limit_location_change.html |
modified |
Files Changed
docshell/base/BrowsingContext.cppdocshell/base/BrowsingContext.hdocshell/shistory/ChildSHistory.cppdocshell/test/navigation/test_rate_limit_location_change.htmldom/base/Location.cppdom/base/LocationBase.cppdom/base/nsHistory.cppdom/base/nsHistory.hdom/chrome-webidl/BrowsingContext.webidldom/webidl/History.webidlmodules/libpref/init/StaticPrefList.yamlremote/shared/RecommendedPreferences.sys.mjstesting/marionette/client/marionette_driver/geckoinstance.pytools/@types/lib.gecko.dom.d.ts
Audit Directions
- Partial API coverage of a limiterLook for security throttles named or scoped for one API surface while sibling APIs that reach the same underlying state (e.g.
LocationvsHistory) skip the check. - Missing `CallerType` propagationAudit web-exposed getters/setters that do not receive or honor
CallerType, since privilege-blind entry points can evade non-system rate limits or checks. - Per-`BrowsingContext` countersReview other per-context rate-limit or quota counters (
mNavigationRateLimitCountand analogues) to confirm every reachable caller funnels through the same increment-and-reject logic.
Patch
diff --git a/docshell/base/BrowsingContext.cpp b/docshell/base/BrowsingContext.cpp
index cd4d788c654..9b5c8143cb7 100644
--- a/docshell/base/BrowsingContext.cpp
+++ b/docshell/base/BrowsingContext.cpp
@@ -3830,17 +3830,16 @@ bool BrowsingContext::ShouldUpdateSessionHistory(uint32_t aLoadType) {
(IsForceReloadType(aLoadType) && IsSubframe()));
}
-nsresult BrowsingContext::CheckLocationChangeRateLimit(CallerType aCallerType) {
+nsresult BrowsingContext::CheckNavigationRateLimit(CallerType aCallerType) {
// We only rate limit non system callers
if (aCallerType == CallerType::System) {
return NS_OK;
}
// Fetch rate limiting preferences
- uint32_t limitCount =
- StaticPrefs::dom_navigation_locationChangeRateLimit_count();
+ uint32_t limitCount = StaticPrefs::dom_navigation_navigationRateLimit_count();
uint32_t timeSpanSeconds =
- StaticPrefs::dom_navigation_locationChangeRateLimit_timespan();
+ StaticPrefs::dom_navigation_navigationRateLimit_timespan();
// Disable throttling if either of the preferences is set to 0.
if (limitCount == 0 || timeSpanSeconds == 0) {
@@ -3849,15 +3848,15 @@ nsresult BrowsingContext::CheckLocationChangeRateLimit(CallerType aCallerType) {
TimeDuration throttleSpan = TimeDuration::FromSeconds(timeSpanSeconds);
- if (mLocationChangeRateLimitSpanStart.IsNull() ||
- ((TimeStamp::Now() - mLocationChangeRateLimitSpanStart) > throttleSpan)) {
+ if (mNavigationRateLimitSpanStart.IsNull() ||
+ ((TimeStamp::Now() - mNavigationRateLimitSpanStart) > throttleSpan)) {
// Initial call or timespan exceeded, reset counter and timespan.
- mLocationChangeRateLimitSpanStart = TimeStamp::Now();
- mLocationChangeRateLimitCount = 1;
+ mNavigationRateLimitSpanStart = TimeStamp::Now();
+ mNavigationRateLimitCount = 1;
return NS_OK;
}
- if (mLocationChangeRateLimitCount >= limitCount) {
+ if (mNavigationRateLimitCount >= limitCount) {
// Rate limit reached
Document* doc = GetDocument();
@@ -3870,14 +3869,14 @@ nsresult BrowsingContext::CheckLocationChangeRateLimit(CallerType aCallerType) {
return NS_ERROR_DOM_SECURITY_ERR;
}
- mLocationChangeRateLimitCount++;
+ mNavigationRateLimitCount++;
return NS_OK;
}
-void BrowsingContext::ResetLocationChangeRateLimit() {
+void BrowsingContext::ResetNavigationRateLimit() {
// Resetting the timestamp object will cause the check function to
// init again and reset the rate limit.
- mLocationChangeRateLimitSpanStart = TimeStamp();
+ mNavigationRateLimitSpanStart = TimeStamp();
}
void BrowsingContext::LocationCreated(dom::Location* aLocation) {
diff --git a/docshell/base/BrowsingContext.h b/docshell/base/BrowsingContext.h
index e566841106c..98a34cab1ff 100644
--- a/docshell/base/BrowsingContext.h
+++ b/docshell/base/BrowsingContext.h
@@ -900,13 +900,13 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache {
// Checks if we reached the rate limit for calls to Location and History API.
// The rate limit is controlled by the
- // "dom.navigation.locationChangeRateLimit" prefs.
+ // "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 CheckLocationChangeRateLimit(CallerType aCallerType);
+ nsresult CheckNavigationRateLimit(CallerType aCallerType);
- void ResetLocationChangeRateLimit();
+ void ResetNavigationRateLimit();
mozilla::dom::DisplayMode DisplayMode() { return Top()->GetDisplayMode(); }
@@ -1453,9 +1453,9 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache {
nsTArray<std::function<void(uint64_t)>> mDiscardListeners;
// Counter and time span for rate limiting Location and History API calls.
- // Used by CheckLocationChangeRateLimit. Do not apply cross-process.
- uint32_t mLocationChangeRateLimitCount;
- mozilla::TimeStamp mLocationChangeRateLimitSpanStart;
+ // Used by CheckNavigationRateLimit. Do not apply cross-process.
+ uint32_t mNavigationRateLimitCount;
+ mozilla::TimeStamp mNavigationRateLimitSpanStart;
mozilla::LinkedList<dom::Location> mLocations;
};
diff --git a/docshell/shistory/ChildSHistory.cpp b/docshell/shistory/ChildSHistory.cpp
index 3e3cf09493d..855d7646177 100644
--- a/docshell/shistory/ChildSHistory.cpp
+++ b/docshell/shistory/ChildSHistory.cpp
@@ -177,7 +177,7 @@ void ChildSHistory::AsyncGo(int32_t aOffset, bool aRequireUserInteraction,
MOZ_LOG(gSHLog, LogLevel::Debug,
("ChildSHistory::AsyncGo(%d), current index = %d", aOffset,
index.value()));
- nsresult rv = mBrowsingContext->CheckLocationChangeRateLimit(aCallerType);
+ nsresult rv = mBrowsingContext->CheckNavigationRateLimit(aCallerType);
if (NS_FAILED(rv)) {
MOZ_LOG(gSHLog, LogLevel::Debug, ("Rejected"));
aRv.Throw(rv);
diff --git a/dom/base/Location.cpp b/dom/base/Location.cpp
index 9edb9e0b6f8..11603ce493e 100644
--- a/dom/base/Location.cpp
+++ b/dom/base/Location.cpp
@@ -562,7 +562,7 @@ void Location::Reload(bool aForceget, nsIPrincipal& aSubjectPrincipal,
? CallerType::System
: CallerType::NonSystem;
- nsresult rv = bc->CheckLocationChangeRateLimit(callerType);
+ nsresult rv = bc->CheckNavigationRateLimit(callerType);
if (NS_FAILED(rv)) {
aRv.Throw(rv);
return;
diff --git a/dom/base/LocationBase.cpp b/dom/base/LocationBase.cpp
index 7ca3cf83a42..94ab819c94c 100644
--- a/dom/base/LocationBase.cpp
+++ b/dom/base/LocationBase.cpp
@@ -128,7 +128,7 @@ void LocationBase::SetURI(nsIURI* aURI, nsIPrincipal& aSubjectPrincipal,
? CallerType::System
: CallerType::NonSystem;
- nsresult rv = bc->CheckLocationChangeRateLimit(callerType);
+ nsresult rv = bc->CheckNavigationRateLimit(callerType);
if (NS_FAILED(rv)) {
aRv.Throw(rv);
return;
diff --git a/dom/base/nsHistory.cpp b/dom/base/nsHistory.cpp
index 99994a73ccd..39d2f12df3f 100644
--- a/dom/base/nsHistory.cpp
+++ b/dom/base/nsHistory.cpp
@@ -72,7 +72,8 @@ uint32_t nsHistory::GetLength(ErrorResult& aRv) const {
return len >= 0 ? len : 0;
}
-ScrollRestoration nsHistory::GetScrollRestoration(mozilla::ErrorResult& aRv) {
+ScrollRestoration nsHistory::GetScrollRestoration(
+ mozilla::dom::CallerType aCallerType, mozilla::ErrorResult& aRv) {
nsCOMPtr<nsPIDOMWindowInner> win(do_QueryReferent(mInnerWindow));
if (!win || !win->HasActiveDocument() || !win->GetDocShell()) {
aRv.Throw(NS_ERROR_DOM_SECURITY_ERR);
@@ -88,6 +89,7 @@ ScrollRestoration nsHistory::GetScrollRestoration(mozilla::ErrorResult& aRv) {
}
void nsHistory::SetScrollRestoration(mozilla::dom::ScrollRestoration aMode,
+ mozilla::dom::CallerType aCallerType,
mozilla::ErrorResult& aRv) {
nsCOMPtr<nsPIDOMWindowInner> win(do_QueryReferent(mInnerWindow));
if (!win || !win->HasActiveDocument() || !win->GetDocShell()) {
@@ -95,6 +97,15 @@ void nsHistory::SetScrollRestoration(mozilla::dom::ScrollRestoration aMode,
return;
}
+ BrowsingContext* bc = win->GetBrowsingContext();
+ if (bc) {
+ nsresult rv = bc->CheckNavigationRateLimit(aCallerType);
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ return;
+ }
+ }
+
win->GetDocShell()->SetCurrentScrollRestorationIsManual(
aMode == mozilla::dom::ScrollRestoration::Manual);
}
@@ -152,7 +163,7 @@ void nsHistory::Go(int32_t aDelta, nsIPrincipal& aSubjectPrincipal,
? CallerType::System
: CallerType::NonSystem;
- // AsyncGo throws if we hit the location change rate limit.
+ // AsyncGo throws if we hit the navigation rate limit.
session_history->AsyncGo(aDelta, /* aRequireUserInteraction = */ false,
userActivation, callerType, aRv);
}
@@ -237,7 +248,7 @@ void nsHistory::PushOrReplaceState(JSContext* aCx, JS::Handle<JS::Value> aData,
BrowsingContext* bc = win->GetBrowsingContext();
if (bc) {
- nsresult rv = bc->CheckLocationChangeRateLimit(aCallerType);
+ nsresult rv = bc->CheckNavigationRateLimit(aCallerType);
if (NS_FAILED(rv)) {
aRv.Throw(rv);
return;
diff --git a/dom/base/nsHistory.h b/dom/base/nsHistory.h
index 157052fe44c..5f8b7cd1a58 100644
--- a/dom/base/nsHistory.h
+++ b/dom/base/nsHistory.h
@@ -40,8 +40,9 @@ class nsHistory final : public nsISupports, public nsWrapperCache {
uint32_t GetLength(mozilla::ErrorResult& aRv) const;
mozilla::dom::ScrollRestoration GetScrollRestoration(
- mozilla::ErrorResult& aRv);
+ mozilla::dom::CallerType aCallerType, mozilla::ErrorResult& aRv);
void SetScrollRestoration(mozilla::dom::ScrollRestoration aMode,
+ mozilla::dom::CallerType aCallerType,
mozilla::ErrorResult& aRv);
void GetState(JSContext* aCx, JS::MutableHandle<JS::Value> aResult,
mozilla::ErrorResult& aRv) const;
diff --git a/dom/chrome-webidl/BrowsingContext.webidl b/dom/chrome-webidl/BrowsingContext.webidl
index c63f2a69c90..28e8d8cb9c6 100644
--- a/dom/chrome-webidl/BrowsingContext.webidl
+++ b/dom/chrome-webidl/BrowsingContext.webidl
@@ -275,7 +275,7 @@ interface BrowsingContext {
readonly attribute ChildSHistory? childSessionHistory;
// Resets the location change rate limit. Used for testing.
- undefined resetLocationChangeRateLimit();
+ undefined resetNavigationRateLimit();
readonly attribute long childOffset;
};
diff --git a/dom/webidl/History.webidl b/dom/webidl/History.webidl
index 934214965dc..d0aa7d2c882 100644
--- a/dom/webidl/History.webidl
+++ b/dom/webidl/History.webidl
@@ -17,7 +17,7 @@ enum ScrollRestoration { "auto", "manual" };
interface History {
[Throws]
readonly attribute unsigned long length;
- [Throws]
+ [Throws, NeedsCallerType]
attribute ScrollRestoration scrollRestoration;
[Throws]
readonly attribute any state;
diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml
index 3094104d81a..14bfebcd2fc 100644
--- a/modules/libpref/init/StaticPrefList.yaml
+++ b/modules/libpref/init/StaticPrefList.yaml
@@ -3273,13 +3273,13 @@
# Limit of location change caused by content scripts in a time span per
# BrowsingContext. This includes calls to History and Location APIs.
-- name: dom.navigation.locationChangeRateLimit.count
+- name: dom.navigation.navigationRateLimit.count
type: uint32_t
value: 200
mirror: always
# Time span in seconds for location change rate limit.
-- name: dom.navigation.locationChangeRateLimit.timespan
+- name: dom.navigation.navigationRateLimit.timespan
type: uint32_t
value: 10
mirror: always
diff --git a/remote/shared/RecommendedPreferences.sys.mjs b/remote/shared/RecommendedPreferences.sys.mjs
index 236a7f0e664..480560b8def 100644
--- a/remote/shared/RecommendedPreferences.sys.mjs
+++ b/remote/shared/RecommendedPreferences.sys.mjs
@@ -223,7 +223,7 @@ const COMMON_PREFERENCES = new Map([
["dom.max_script_run_time", 0],
// Disable location change rate limitation
- ["dom.navigation.locationChangeRateLimit.count", 0],
+ ["dom.navigation.navigationRateLimit.count", 0],
// DOM Push
["dom.push.connection.enabled", false],
diff --git a/tools/@types/lib.gecko.dom.d.ts b/tools/@types/lib.gecko.dom.d.ts
index 2a3aef627d0..13a8171fe5a 100644
--- a/tools/@types/lib.gecko.dom.d.ts
+++ b/tools/@types/lib.gecko.dom.d.ts
@@ -5067,7 +5067,7 @@ interface BrowsingContext extends LoadContextMixin {
watchedByDevTools: boolean;
readonly window: WindowProxy | null;
getAllBrowsingContextsInSubtree(): BrowsingContext[];
- resetLocationChangeRateLimit(): void;
+ resetNavigationRateLimit(): void;
setRDMPaneMaxTouchPoints(maxTouchPoints: number): void;
setRDMPaneOrientation(type: OrientationType, rotationAngle: number): void;
}
diff --git a/docshell/test/navigation/test_rate_limit_location_change.html b/docshell/test/navigation/test_rate_limit_location_change.html
index b1b51b92dde..9fefce12af8 100644
--- a/docshell/test/navigation/test_rate_limit_location_change.html
+++ b/docshell/test/navigation/test_rate_limit_location_change.html
@@ -17,8 +17,8 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
async function setup() {
await SpecialPowers.pushPrefEnv({set: [
- ["dom.navigation.locationChangeRateLimit.count", RATE_LIMIT_COUNT],
- ["dom.navigation.locationChangeRateLimit.timespan", RATE_LIMIT_TIME_SPAN]]});
+ ["dom.navigation.navigationRateLimit.count", RATE_LIMIT_COUNT],
+ ["dom.navigation.navigationRateLimit.timespan", RATE_LIMIT_TIME_SPAN]]});
}
let inc = 0;
@@ -26,6 +26,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
const rateLimitedFunctions = (win) => ({
"history.replaceState": () => win.history.replaceState(null, "test", `${win.location.href}#${inc++}`),
"history.pushState": () => win.history.pushState(null, "test", `${win.location.href}#${inc++}`),
+ "history.SetScrollRestoration": () => win.history.scrollRestoration = "auto",
"history.back": () => win.history.back(),
"history.forward": () => win.history.forward(),
"history.go": () => win.history.go(-1),
@@ -53,7 +54,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
Object.entries(rateLimitedFunctions(win)).forEach(([name, fn]) => {
// Reset the rate limit for the next run.
info("Reset rate limit.");
- SpecialPowers.wrap(win).browsingContext.resetLocationChangeRateLimit();
+ SpecialPowers.wrap(win).browsingContext.resetNavigationRateLimit();
info(`Calling ${name} ${RATE_LIMIT_COUNT} times to reach the rate limit.`);
for(let i = 0; i< RATE_LIMIT_COUNT; i++) {
@@ -83,7 +84,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
// Cleanup
win.close();
- SpecialPowers.wrap(win).browsingContext.resetLocationChangeRateLimit();
+ SpecialPowers.wrap(win).browsingContext.resetNavigationRateLimit();
SimpleTest.finish();
}
diff --git a/testing/marionette/client/marionette_driver/geckoinstance.py b/testing/marionette/client/marionette_driver/geckoinstance.py
index 1957d77f158..56a69260518 100644
--- a/testing/marionette/client/marionette_driver/geckoinstance.py
+++ b/testing/marionette/client/marionette_driver/geckoinstance.py
@@ -83,8 +83,8 @@ class GeckoInstance(object):
# No slow script dialogs
"dom.max_chrome_script_run_time": 0,
"dom.max_script_run_time": 0,
- # Disable location change rate limitation
- "dom.navigation.locationChangeRateLimit.count": 0,
+ # Disable navigation change rate limitation
+ "dom.navigation.navigationRateLimit.count": 0,
# DOM Push
"dom.push.connection.enabled": False,
# Screen Orientation API
Regression Test / PoC
diff --git a/docshell/test/navigation/test_rate_limit_location_change.html b/docshell/test/navigation/test_rate_limit_location_change.html
index b1b51b92dde..9fefce12af8 100644
--- a/docshell/test/navigation/test_rate_limit_location_change.html
+++ b/docshell/test/navigation/test_rate_limit_location_change.html
@@ -17,8 +17,8 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
async function setup() {
await SpecialPowers.pushPrefEnv({set: [
- ["dom.navigation.locationChangeRateLimit.count", RATE_LIMIT_COUNT],
- ["dom.navigation.locationChangeRateLimit.timespan", RATE_LIMIT_TIME_SPAN]]});
+ ["dom.navigation.navigationRateLimit.count", RATE_LIMIT_COUNT],
+ ["dom.navigation.navigationRateLimit.timespan", RATE_LIMIT_TIME_SPAN]]});
}
let inc = 0;
@@ -26,6 +26,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
const rateLimitedFunctions = (win) => ({
"history.replaceState": () => win.history.replaceState(null, "test", `${win.location.href}#${inc++}`),
"history.pushState": () => win.history.pushState(null, "test", `${win.location.href}#${inc++}`),
+ "history.SetScrollRestoration": () => win.history.scrollRestoration = "auto",
"history.back": () => win.history.back(),
"history.forward": () => win.history.forward(),
"history.go": () => win.history.go(-1),
@@ -53,7 +54,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
Object.entries(rateLimitedFunctions(win)).forEach(([name, fn]) => {
// Reset the rate limit for the next run.
info("Reset rate limit.");
- SpecialPowers.wrap(win).browsingContext.resetLocationChangeRateLimit();
+ SpecialPowers.wrap(win).browsingContext.resetNavigationRateLimit();
info(`Calling ${name} ${RATE_LIMIT_COUNT} times to reach the rate limit.`);
for(let i = 0; i< RATE_LIMIT_COUNT; i++) {
@@ -83,7 +84,7 @@ https://bugzilla.mozilla.org/show_bug.cgi?id=1314912
// Cleanup
win.close();
- SpecialPowers.wrap(win).browsingContext.resetLocationChangeRateLimit();
+ SpecialPowers.wrap(win).browsingContext.resetNavigationRateLimit();
SimpleTest.finish();
}
diff --git a/testing/marionette/client/marionette_driver/geckoinstance.py b/testing/marionette/client/marionette_driver/geckoinstance.py
index 1957d77f158..56a69260518 100644
--- a/testing/marionette/client/marionette_driver/geckoinstance.py
+++ b/testing/marionette/client/marionette_driver/geckoinstance.py
@@ -83,8 +83,8 @@ class GeckoInstance(object):
# No slow script dialogs
"dom.max_chrome_script_run_time": 0,
"dom.max_script_run_time": 0,
- # Disable location change rate limitation
- "dom.navigation.locationChangeRateLimit.count": 0,
+ # Disable navigation change rate limitation
+ "dom.navigation.navigationRateLimit.count": 0,
# DOM Push
"dom.push.connection.enabled": False,
# Screen Orientation API