fix(js): resolve relative URLs against the document base URL - #661
Open
aech wants to merge 4 commits into
Open
Conversation
<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.
SGavrl
approved these changes
Aug 14, 2026
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #658
What changed
#242fixed the same class for<script src>, anddocument_base_url()incrates/obscura-js/src/ops.rshas 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
_domParsedispatcher, and routes six call sites through a helper_documentBase():fetch,XMLHttpRequest.send,_anchorBase()(backs<a>and<area>along with all URL components), formaction,_resolveUrl(), and the genericsrcreflection. There is no second implementation of the rule.Three further changes come along, each from a review finding:
HTMLBaseElement.hrefnow 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:orjavascript:base is discarded, and the document URL takes its place. Without this check, every later relative resolution fails silently in thecatchbranch instead of falling back to the fallback base URL.history.pushStatemoves 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 rawhrefattribute.The resolution sits in a cache, see Performance.
The render path remains untouched.
ensure_prepared_renderand the four other callers keep callingdocument_base_url()directly, without a cache. The call is rare there, and the change would gain nothing.Validation
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:Mutation run against the current tests, the full suite for each mutation:
The last line is a known limit, not negligence.
XMLHttpRequest.sendpasses the already absolute URL on tofetch, andfetchresolves 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 withhrefwins),<base>withouthref,<base href="">, a foreign origin as the base, a relative base, adata:base, a<base>inserted after the fact by a script, and anhrefchanged after the fact.The one failure in the stealth configuration is
obscura-net wreq_client::tests::stealth_client_decodes_gzip_response. It fails onmainjust the same. Checked by running the same single test on both revisions. This change does not touchobscura-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()runsquery_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.hrefand, below it,protocol,host,hostname,port,pathname,search,hash, andorigin, plusElement.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: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
ObscuraStateand holds the resolution and the raw attribute. Its key isactivity_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