CVE-2026-16371
Overview
Background
- `RecvLoadURIExternal`
- the parent-process IPC handler in
ContentParentthat services a content process’s request to open a URI with an external protocol handler. - `nsIPrincipal`
- Gecko’s security-context object that identifies the origin/authority whose privileges an action is performed under.
- `ValidatePrincipal`
- a
ContentParenthelper that checks an IPC-supplied principal is legitimate for the sending content process before it is trusted. - `NotNull<T>`
- a Mozilla wrapper type that statically and dynamically enforces a pointer argument is non-null across the IPDL boundary.
Root Cause Analysis
The LoadURIExternal IPDL message declared both uri and triggeringPrincipal as nullable, and RecvLoadURIExternal forwarded the content-process-supplied aTriggeringPrincipal (and aRedirectPrincipal) straight into nsExternalHelperAppService::LoadURI without calling ValidatePrincipal on it. A compromised or malicious content process could therefore hand the trusted parent an arbitrary, spoofed, or null triggering principal, violating the invariant that any principal used to authorize a privileged action in the parent must actually belong to the sending process. Because that principal governs the external-protocol load’s authority and the same-tree spoofing checks inside LoadURI, an unvalidated value amounted to a privilege escalation in the parent process.
The fix adds ValidatePrincipal(aTriggeringPrincipal) and ValidatePrincipal(aRedirectPrincipal, {AllowNullPtr}) checks that fail the IPC channel via PrincipalValidationIpcFail when validation fails. It also tightens the contract by making uri and triggeringPrincipal NotNull/non-nullable and adding NS_ENSURE_ARG_POINTER(aTriggeringPrincipal) in LoadURI, so a triggering principal can no longer be null or attacker-forged.
ValidatePrincipal gate and makes the principal non-nullable so forged or null values are rejected at the IPC boundary.Attack Path
- Compromise a content process The attacker gains code execution in a sandboxed renderer via a prior content-process bug.
- Forge an IPC message
The content process sends
LoadURIExternalwith atriggeringPrincipal(and optionalredirectPrincipal) it does not legitimately own, or a null one. - Reach the unguarded handler
RecvLoadURIExternalaccepts the message without callingValidatePrincipal, so the spoofed principal is treated as authoritative in the parent. - Bypass origin restrictions
The forged principal is passed to
nsExternalHelperAppService::LoadURI, where it drives the external-protocol dispatch and short-circuits the same-tree anti-spoofing checks. - Escalate privilege The load proceeds under an authority the content process should not possess, achieving privilege escalation in the DOM navigation path.
Impact Assessment
PContent IPC channel and affects the DOM Navigation / external-handler path. The precondition is a pre-compromised content process able to send crafted IPC; the rated impact is moderate.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifdom/ipc/ContentParent.cpp |
modified |
Files Changed
dom/ipc/ContentParent.cppdom/ipc/ContentParent.hdom/ipc/PContent.ipdluriloader/exthandler/nsExternalHelperAppService.cpp
Audit Directions
- Unvalidated IPC principalsAudit every
ContentParent::Recv*handler that consumes annsIPrincipalfrom a content process and confirm it callsValidatePrincipalbefore the principal authorizes any action. - Nullable security-sensitive IPDL argsReview
nullable nsIPrincipal/nullable nsIURIdeclarations inPContent.ipdland related protocols; where the value must be trusted, prefer non-nullable/NotNulland explicitAllowNullPtronly where truly optional. - Principal forwarding into servicesTrace principals passed from IPC into privileged services like
nsExternalHelperAppService::LoadURIto ensure origin/same-tree checks are not bypassable by a caller-controlled or null principal.
Patch
diff --git a/dom/ipc/ContentParent.cpp b/dom/ipc/ContentParent.cpp
index c6b9c9e3fe9..517aa43bd05 100644
--- a/dom/ipc/ContentParent.cpp
+++ b/dom/ipc/ContentParent.cpp
@@ -4809,7 +4809,7 @@ mozilla::ipc::IPCResult ContentParent::RecvAccumulateMixedContentHSTS(
}
mozilla::ipc::IPCResult ContentParent::RecvLoadURIExternal(
- nsIURI* uri, nsIPrincipal* aTriggeringPrincipal,
+ NotNull<nsIURI*> uri, NotNull<nsIPrincipal*> aTriggeringPrincipal,
nsIPrincipal* aRedirectPrincipal,
const MaybeDiscarded<BrowsingContext>& aContext,
bool aWasExternallyTriggered, bool aHasValidUserGestureActivation,
@@ -4818,16 +4818,21 @@ mozilla::ipc::IPCResult ContentParent::RecvLoadURIExternal(
return IPC_OK();
}
+ if (!ValidatePrincipal(aTriggeringPrincipal)) {
+ return PrincipalValidationIpcFail(aTriggeringPrincipal, this, __func__);
+ }
+
+ if (!ValidatePrincipal(aRedirectPrincipal,
+ {ValidatePrincipalOptions::AllowNullPtr})) {
+ return PrincipalValidationIpcFail(aRedirectPrincipal, this, __func__);
+ }
+
nsCOMPtr<nsIExternalProtocolService> extProtService(
do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID));
if (!extProtService) {
return IPC_OK();
}
- if (!uri) {
- return IPC_FAIL(this, "uri must not be null.");
- }
-
BrowsingContext* bc = aContext.get();
extProtService->LoadURI(uri, aTriggeringPrincipal, aRedirectPrincipal, bc,
aWasExternallyTriggered,
diff --git a/dom/ipc/ContentParent.h b/dom/ipc/ContentParent.h
index 060864c020a..6a0559b9e3c 100644
--- a/dom/ipc/ContentParent.h
+++ b/dom/ipc/ContentParent.h
@@ -1038,7 +1038,7 @@ class ContentParent final : public PContentParent,
mozilla::ipc::IPCResult RecvSetURITitle(nsIURI* uri, const nsAString& title);
mozilla::ipc::IPCResult RecvLoadURIExternal(
- nsIURI* uri, nsIPrincipal* triggeringPrincipal,
+ NotNull<nsIURI*> uri, NotNull<nsIPrincipal*> triggeringPrincipal,
nsIPrincipal* redirectPrincipal,
const MaybeDiscarded<BrowsingContext>& aContext,
bool aWasExternallyTriggered, bool aHasValidUserGestureActivation,
diff --git a/dom/ipc/PContent.ipdl b/dom/ipc/PContent.ipdl
index d328a07a8e6..ebcde0b2b2c 100644
--- a/dom/ipc/PContent.ipdl
+++ b/dom/ipc/PContent.ipdl
@@ -1202,8 +1202,8 @@ parent:
async StartVisitedQueries(nullable nsIURI[] uri);
async SetURITitle(nullable nsIURI uri, nsString title);
- async LoadURIExternal(nullable nsIURI uri,
- nullable nsIPrincipal triggeringPrincipal,
+ async LoadURIExternal(nsIURI uri,
+ nsIPrincipal triggeringPrincipal,
nullable nsIPrincipal redirectPrincipal,
MaybeDiscardedBrowsingContext browsingContext,
bool wasExternallyTriggered,
diff --git a/uriloader/exthandler/nsExternalHelperAppService.cpp b/uriloader/exthandler/nsExternalHelperAppService.cpp
index d36dc1617cd..862b6cba4ee 100644
--- a/uriloader/exthandler/nsExternalHelperAppService.cpp
+++ b/uriloader/exthandler/nsExternalHelperAppService.cpp
@@ -1036,11 +1036,13 @@ nsExternalHelperAppService::LoadURI(nsIURI* aURI,
bool aHasValidUserGestureActivation,
bool aNewWindowTarget) {
NS_ENSURE_ARG_POINTER(aURI);
+ NS_ENSURE_ARG_POINTER(aTriggeringPrincipal);
if (XRE_IsContentProcess()) {
mozilla::dom::ContentChild::GetSingleton()->SendLoadURIExternal(
- aURI, aTriggeringPrincipal, aRedirectPrincipal, aBrowsingContext,
- aTriggeredExternally, aHasValidUserGestureActivation, aNewWindowTarget);
+ WrapNotNull(aURI), WrapNotNull(aTriggeringPrincipal),
+ aRedirectPrincipal, aBrowsingContext, aTriggeredExternally,
+ aHasValidUserGestureActivation, aNewWindowTarget);
return NS_OK;
}
@@ -1108,7 +1110,7 @@ nsExternalHelperAppService::LoadURI(nsIURI* aURI,
// links can always navigate everywhere, so this is a minor additional
// restriction, only aiming to prevent some types of spoofing attacks
// from otherwise disjoint browsingcontext trees.
- if (aBrowsingContext && aTriggeringPrincipal &&
+ if (aBrowsingContext &&
// Add-on principals are always allowed:
!BasePrincipal::Cast(aTriggeringPrincipal)->AddonPolicy() &&
// As is chrome code: