Skip to content

fix(js): resolve relative URLs against the document base URL - #661

Open
aech wants to merge 4 commits into
h4ckf0r0day:mainfrom
aech:fix/base-href-in-js-url-resolution
Open

fix(js): resolve relative URLs against the document base URL#661
aech wants to merge 4 commits into
h4ckf0r0day:mainfrom
aech:fix/base-href-in-js-url-resolution

Conversation

@aech

@aech aech commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #658

What changed

#242 fixed the same class for <script src>, and document_base_url() in crates/obscura-js/src/ops.rs has resolved per spec since then. It was behind #[cfg(feature = "render")] and not accessible to the JS layer, so every relative URL there kept resolving against the document URL.

This change unlocks the existing function, passes its result to JS through the _domParse dispatcher, and routes six call sites through a helper _documentBase(): fetch, XMLHttpRequest.send, _anchorBase() (backs <a> and <area> along with all URL components), form action, _resolveUrl(), and the generic src reflection. There is no second implementation of the rule.

Three further changes come along, each from a review finding:

HTMLBaseElement.href now returns the resolved address instead of the raw attribute. It resolves against the fallback base URL, that is, against the document URL. A <base> element is affected neither by other base elements nor by itself.

A data: or javascript: base is discarded, and the document URL takes its place. Without this check, every later relative resolution fails silently in the catch branch instead of falling back to the fallback base URL.

history.pushState moves the document URL without reaching the Rust side. If a virtual URL is set, the base is therefore computed in JS. For this, the dispatcher additionally exposes the raw href attribute.

The resolution sits in a cache, see Performance.

The render path remains untouched. ensure_prepared_render and the four other callers keep calling document_base_url() directly, without a cache. The call is rare there, and the change would gain nothing.

Validation

cargo nextest run --release --features render --no-fail-fast              1420 passed, 4 skipped
cargo nextest run --release --features render,stealth --no-fail-fast      1422 passed, 1 failed, 4 skipped
cargo nextest run --release --no-default-features -p obscura-js             296 passed
cargo check -p obscura-js --no-default-features                           clean
cargo check -p obscura-js --features render,stealth                       clean
cargo build --release --features render                                   clean

14 new tests in crates/obscura-js/src/runtime.rs. Each was checked against a deliberately broken version, not only against the healthy one.

The first version of these tests had no teeth. It used <base href="/"> on a document under /deep/page, and that is exactly the origin root. Resolution against the base and resolution against the origin root then yielded the same value, and four mutations survived. The base is now /app/, which puts the three candidates on three different paths:

document base URL  ->  /app/data/x.json    (intended)
document URL       ->  /deep/data/x.json   (the defect)
origin root        ->  /data/x.json        (indistinguishable with <base href="/">)

Mutation run against the current tests, the full suite for each mutation:

base always resolves to the origin root      caught
Element.src back to location.href            caught
_resolveUrl back to the document URL         caught
fetch resolves to the origin root            caught
cache disabled                               caught
cache invalidation disabled                  caught
XHR resolution removed outright              SURVIVES

The last line is a known limit, not negligence. XMLHttpRequest.send passes the already absolute URL on to fetch, and fetch resolves anyway. From the outside, this layer therefore cannot be isolated. This is stated in a comment in the test, so that the existence of a test does not mask the gap.

Covered special forms: multiple <base> elements (the first with href wins), <base> without href, <base href="">, a foreign origin as the base, a relative base, a data: base, a <base> inserted after the fact by a script, and an href changed after the fact.

The one failure in the stealth configuration is obscura-net wreq_client::tests::stealth_client_decodes_gzip_response. It fails on main just the same. Checked by running the same single test on both revisions. This change does not touch obscura-net.

Rendering

Not applicable. No layout, no paint, no output changed. The render path still calls document_base_url() directly.

Performance

The first cut of this change was a serious regression, and it is the reason for the cache.

document_base_url() runs query_selector("base[href]"). This allocates a vector of all nodes and runs the selector engine against every element. In the render path this has no consequences, because the call is rare. On the JS side, by contrast, the function sits on the hottest getters: a.href and, below it, protocol, host, hostname, port, pathname, search, hash, and origin, plus Element.src. Before, the base was a field read there.

The worst case is also the most common one. Without <base href>, the scan runs to the end and returns nothing.

Measured with the same probe on both revisions, release build, probes/bench-anchor-href.sh:

10,000 reads of a.href on a document of roughly 12,000 nodes, two runs each:

main                        26 ms     21 ms
this PR                     19 ms     28 ms
this PR without the cache 2572 ms   3476 ms

The first two lines of the measurement overlap across the runs. The cache is therefore indistinguishable from main. The third line is the same change without it and is there only so that the first two can be read.

The cache lives in ObscuraState and holds the resolution and the raw attribute. Its key is activity_generation, document_generation, and the document URL. A <base> inserted or changed later therefore reaches it, and two tests pin down exactly that. Both fail if the key check is removed.

Checklist

  • The change is focused and does not remove existing behavior without justification.
  • Tests cover the failure or feature.
  • Existing tests pass, including render and no-render configurations when affected.
  • I checked for CPU, latency, and memory regressions.
  • Public API or user-facing behavior changes are documented.

aech added 4 commits August 13, 2026 13:39
<base href> overrides the document URL as the base for relative URLs. The JS layer
resolved against the document URL instead, so a SPA served from a sub-path asked for
its data under the current route and got 404.

h4ckf0r0day#242 fixed this for <script src> in the Rust script loader. document_base_url() there
is already spec-correct; this exposes it to the JS layer and routes fetch, XHR, anchor
href, form action and the generic src reflection through it.
Review found three defects in the previous commit.

The base lookup walks the tree and runs the selector engine, and the JS layer asks
for it on every relative URL, including the <a> URL-decomposition getters. That turned
a.href from a field read into an O(nodes) read, measured at 2000 reads on a 12000-node
document: 5679 ms without the memo, 249 ms with it. The memo keys on the activity and
document generations plus the URL, so a <base> added or changed later still lands.

A data: or javascript: base is now rejected in favour of the document URL, as the spec
requires. Honouring it made every later relative resolution fail instead.

history.pushState moves the document URL without telling the Rust side, so the base is
built in JS when a virtual URL is set.
The fixture used <base href="/">, which is the origin root on a document at /deep/page.
Resolving against the base and resolving against the origin root gave the same answer,
so four mutations survived: Element.src, _resolveUrl, fetch and the base lookup itself.
The base is now /app/, which puts the three candidate bases on three different paths.

Adds the call sites nothing held (Element.src, location assignment), turns the bundled
assertions into one array comparison so a failure shows every broken path, widens the
negative control to all four sites, and covers multiple <base>, a base without href,
an empty href and a cross-origin base.

HTMLBaseElement.href now returns the resolved URL against the fallback base URL, as
the spec requires. Apps read it to compute their own base.

The XHR layer stays untestable from the outside: send() hands an already absolute URL
to fetch, so dropping its resolution changes nothing observable. Said so in the test.
No behaviour change. The comments were drafted in German and translated after the
fact, which is how the issue and the PR description were written too. Wording only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fetch, XHR and anchor href ignore <base href>, the same class as #242

2 participants