Open Bug 2035088 Opened 9 days ago Updated 5 days ago

RecvSetDocumentDomain skips document-domain feature policy

Categories

(Core :: DOM: Security, defect)

defect

Tracking

()

People

(Reporter: iifizzyx, Unassigned)

References

(Blocks 1 open bug)

Details

(5 keywords)

Attachments

(3 files)

Steps to reproduce:

Checked out the firefox-152.0a1-nightly source tarball (firefox-152.0a1-nightly-2026-04-23, BuildID 20260424092512) and configured a default opt build with .debug_info retained — the resulting obj-nightly-opt/dist/bin/firefox is a release-style build with debug symbols.

Reviewed WindowGlobalParent::RecvSetDocumentDomain at dom/ipc/WindowGlobalParent.cpp:1439-1470 and the corresponding child-side Document::SetDomain at dom/base/Document.cpp:9518-9568. The parent-side handler enforces only the sandbox flag, Document::IsValidDomain (suffix-of-existing-domain), and Group()->IsPotentiallyCrossOriginIsolated(). The child-side function additionally calls FeaturePolicyUtils::IsFeatureAllowed(this, u"document-domain"_ns) at line 9531. The author of the parent handler left an inline comment at lines 1446-1447:

// Might need to do a featurepolicy check here, like we currently do in the
// child process?

Wrote repro.html (attached) — an HTML test page that calls document.domain = location.hostname every 3 seconds via a setInterval. This is a no-op semantically (assigning the current hostname to itself) but exercises the full Document.SetDomain → SendSetDocumentDomain → RecvSetDocumentDomain IPC path on each call. Page logs each invocation via dump() so success can be observed in the parent's stderr.

Wrote repro.gdb (attached) — a gdb batch script that sets four breakpoints in WindowGlobalParent.cpp to capture the parent-side handler at every gate:

  • :1441 (entry / mSandboxFlags & SANDBOXED_DOMAIN check)
  • :1458 (entry to Document::IsValidDomain check)
  • :1468 (entry to mDocumentPrincipal->SetDomain(aDomain))
  • :1469 (after SetDomain returns, before IPC_OK())

If a FeaturePolicyUtils::IsFeatureAllowed call existed anywhere in the handler, it would necessarily live between the breakpoints at :1441 and :1468. The script logs every breakpoint hit into repro-trace.log (attached) so the trace order is visible.

Ran:

# Terminal 1 — local HTTP server (Permissions-Policy needs HTTP, not file://)
python3 -m http.server 8780 --bind 127.0.0.1

# Terminal 2 — Firefox with single content process and content sandbox off
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
mkdir -p /tmp/test-profile
cat > /tmp/test-profile/user.js <<EOF
user_pref("dom.ipc.processCount", 1);
user_pref("security.sandbox.content.level", 0);
user_pref("browser.dom.window.dump.enabled", true);
EOF

MOZ_DISABLE_CONTENT_SANDBOX=1 \
obj-nightly-opt/dist/bin/firefox \
  --no-remote --profile /tmp/test-profile \
  http://127.0.0.1:8780/repro.html#auto

# Terminal 3 — attach gdb to the Firefox parent process
gdb -batch -p $(pgrep -f "obj-nightly-opt.*--no-remote" | head -1) \
    -x repro.gdb

The script attaches to the parent (where RecvSetDocumentDomain runs), then continues. As the page auto-fires document.domain every 3 s, the IPC flows from the content process to the parent and the breakpoints record the handler's internal sequence. The recorded trace covers four full handler invocations.

Actual results:

The parent's RecvSetDocumentDomain proceeds from the entry sandbox check straight to IsValidDomain and on to mDocumentPrincipal->SetDomain without ever calling FeaturePolicyUtils::IsFeatureAllowed or any equivalent. The four breakpoints fire in order :1441 → :1458 → :1468 → :1469, with no other breakpoint between them, on every invocation. There is no FeaturePolicy check anywhere inside the handler.

Source — the gap

dom/ipc/WindowGlobalParent.cpp:1439-1470:

mozilla::ipc::IPCResult WindowGlobalParent::RecvSetDocumentDomain(
    NotNull<nsIURI*> aDomain) {
  if (mSandboxFlags & SANDBOXED_DOMAIN) {                    // gate 1
    return IPC_FAIL(this, "Sandbox disallows domain setting.");
  }

  // Might need to do a featurepolicy check here, like we currently do in the
  // child process?                                          ← author acknowledges gap

  nsCOMPtr<nsIURI> uri;
  mDocumentPrincipal->GetDomain(getter_AddRefs(uri));
  if (!uri) {
    uri = mDocumentPrincipal->GetURI();
    if (!uri) {
      return IPC_OK();
    }
  }

  if (!Document::IsValidDomain(uri, aDomain)) {              // gate 2
    return IPC_FAIL(
        this, "Setting domain that's not a suffix of existing domain value.");
  }

  if (Group()->IsPotentiallyCrossOriginIsolated()) {         // gate 3
    return IPC_FAIL(this, "Setting domain in a cross-origin isolated BC.");
  }

  mDocumentPrincipal->SetDomain(aDomain);                    // applied
  return IPC_OK();
}

The handler's three gates (sandbox, suffix, cross-origin-isolated) are enforced. The Permissions-Policy gate is missing. The author's inline comment at lines 1446-1447 explicitly acknowledges the gap as a "might need" question that has never been answered.

Source — the child-side gate (what the parent doesn't replicate)

dom/base/Document.cpp:9518-9568:

void Document::SetDomain(const nsAString& aDomain, ErrorResult& rv) {
  if (!GetBrowsingContext()) {
    rv.Throw(NS_ERROR_DOM_SECURITY_ERR);
    return;
  }

  if (mSandboxFlags & SANDBOXED_DOMAIN) {
    rv.Throw(NS_ERROR_DOM_SECURITY_ERR);
    return;
  }

  if (!FeaturePolicyUtils::IsFeatureAllowed(this, u"document-domain"_ns)) {  // ← child-side gate
    rv.Throw(NS_ERROR_DOM_SECURITY_ERR);
    return;
  }
  ...
  if (WindowGlobalChild* wgc = GetWindowGlobalChild()) {
    wgc->SendSetDocumentDomain(WrapNotNull(newURI));   // line 9566 — IPC dispatch
  }
}

The child performs the FeaturePolicy check at line 9531 and only then calls SendSetDocumentDomain. A compromised content process that bypasses the renderer-side enforcement and crafts the IPC directly skips this gate entirely.

Observable post-conditions (full gdb trace, four iterations, recorded in repro-trace.log)

[gdb] === HIT 1441 (sandbox check) ===
[gdb] this = 0x7af6d413bc00
[gdb] this->mSandboxFlags = 0 (no SANDBOXED_DOMAIN bit set)
[gdb] this->mDocumentPrincipal = 0x7af6d4141580

[gdb] === HIT 1458 (IsValidDomain check) ===
[gdb] handler reached IsValidDomain — note no FP check between line 1441 and here

[gdb] === HIT 1468 (mDocumentPrincipal->SetDomain) ===
[gdb] About to call SetDomain — handler did sandbox + IsValidDomain + cross-origin-isolated
[gdb] but NO FeaturePolicy check (would be at lines 1444-1448 area).
[gdb] *** confirmed: parent has no FeaturePolicyUtils::IsFeatureAllowed call ***

[gdb] === HIT 1469 (after SetDomain returns IPC_OK) ===
[gdb] domain successfully set on principal — no FP gate consulted.

This pattern repeats verbatim for each of the four observed document.domain invocations. The breakpoints between line 1441 (sandbox-check entry) and line 1468 (mDocumentPrincipal->SetDomain) bracket every gate the parent enforces. There is no breakpoint that catches a FeaturePolicy check — because there is no such check in this handler.

Exhaustive grep of the handler body for any FeaturePolicy reference: zero matches.

$ grep -n FeaturePolicy dom/ipc/WindowGlobalParent.cpp
(no output)

Attacker primitive (compromised content process)

A compromised renderer with a live WindowGlobalChild* calls the IPC directly, skipping the JS-API and child-side checks:

nsCOMPtr<nsIURI> newDomain;
NS_NewURI(getter_AddRefs(newDomain), "https://example.com/"_ns);
wgc->SendSetDocumentDomain(WrapNotNull(newDomain));
// Parent's RecvSetDocumentDomain runs the three gates that ARE present,
// then mDocumentPrincipal->SetDomain(aDomain) succeeds.
// Permissions-Policy: document-domain=() set by the embedder is ignored.

Note on attacker model: this primitive has no JS/WebIDL binding — the SendSetDocumentDomain call is internal to the renderer. Only an attacker who has already achieved code execution inside a content process (e.g. via a memory-safety bug) can send it. The IPC layer is the trust boundary; the parent is expected to enforce the same security gates the renderer-side enforces, otherwise renderer-side enforcement is decorative under the stage-2 threat model.

Downstream impact

document.domain is a deprecated mechanism for relaxing the same-origin policy between subdomains and a registrable parent. Setting it requires both peers to relax — so a unilateral set doesn't immediately grant cross-origin DOM access. But the embedder's Permissions-Policy: document-domain=() is the explicit modern opt-out that prevents the relaxation entirely. Bypassing it:

  1. Defeats the embedder's stated policy choice.
  2. With a cooperating compromised process at a sibling subdomain that also relaxes, restores reciprocal scripting access between victim subdomains the embedder wanted isolated.
  3. Re-enables document.domain-as-a-bridge for legacy XSS surfaces that the embedder mitigated by the Permissions-Policy.

This is a stage-2 IPC validation gap in the same shape as the existing family of WindowGlobalParent::Recv*Update* handlers (sandbox flags, CSP, HTTPS-only, cookie jar settings, etc.). RecvSetDocumentDomain performs a different mutation (mDocumentPrincipal->SetDomain versus mirroring a flag) and is gated by a spec-mandated invariant the sibling handlers do not share.

Test results (2026-04-26, recorded against BuildID 20260424092512)

Page: repro.html#auto served from http://127.0.0.1:8780/
gdb attached to Firefox parent (own opt build)

Breakpoints set on parent process. Letting page auto-fire.

(× 4 iterations, each emitting the full HIT 1441 / 1458 / 1468 / 1469 sequence)

HITS counted: 16  (= 4 × 4 breakpoints per invocation)
FP-check breakpoints fired: 0 (none could be set because no such call exists)

Trace confirms: (1) the IPC handler is reachable from a normal content tab,
(2) it proceeds through the three implemented gates with no other gate
between them, (3) mDocumentPrincipal->SetDomain runs unconditionally
once those three gates pass, and (4) no FeaturePolicy or
IsFeatureAllowed call exists anywhere in the handler.

Expected results:

Per the HTML Standard, "the document.domain setter steps"
(https://html.spec.whatwg.org/multipage/origin.html#dom-document-domain),
step 5: "If this's allow document-domain is false, then throw a
'SecurityError' DOMException."
allow document-domain is the
Permissions-Policy gate, controllable by the embedder via the response
header Permissions-Policy: document-domain=() or by an <iframe allow=""> attribute that omits it.

Under Firefox's content-process model, both the renderer-side
Document::SetDomain and the parent-side WindowGlobalParent::RecvSetDocumentDomain
must enforce this invariant — otherwise the renderer-side enforcement
is bypassable by any compromised content process, and the parent
silently completes the operation the renderer would have refused.

The minimal patch is the comment author's own suggestion. Replace lines
1446-1447 (the open question) with the parent-side equivalent of the
child-side line 9531:

  if (mSandboxFlags & SANDBOXED_DOMAIN) {
    return IPC_FAIL(this, "Sandbox disallows domain setting.");
  }

  // Mirror the child-side FeaturePolicy gate enforced by Document::SetDomain.
  if (!FeaturePolicyUtils::IsFeatureAllowedInWGP(this, u"document-domain"_ns)) {
    return IPC_FAIL(
        this, "document-domain disallowed by Permissions-Policy.");
  }

The exact helper name (IsFeatureAllowedInWGP or similar) needs to
match Mozilla's parent-side feature-policy API surface. The
WindowGlobalParent has access to the document's effective
Permissions-Policy through its associated BrowsingContext /
mFeaturePolicy field, so the check is plumbable without new IPC
state.

Whichever way Mozilla prefers to plumb the check, a regression test in
the shape of the attached repro.html + repro.gdb (or, more
sustainably, a chrome-only mochitest that exercises the IPC with
Permissions-Policy: document-domain=() set on a top-level document
containing an iframe and asserts the principal's mDomain is NOT
updated) would keep the primitive covered going forward.

Flags: sec-bounty?
Group: core-security → dom-core-security
Component: IPC → DOM: Content Processes

I guess feature policy is more DOM: Security?

Component: DOM: Content Processes → DOM: Security

Distinct from bug 2021704

Firefox contains code for Permission-Policy (under the older name "Feature Policy"), but does not currently support it
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy#browser_compatibility

Since current users aren't vulnerable we can unhide this

Group: dom-core-security
Status: UNCONFIRMED → NEW
Ever confirmed: true
Flags: behind-pref+
Flags: sec-bounty? → sec-bounty-
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: