From 117415848eee468e8b15ae467364fcabeab40c8a Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:48:20 +0000 Subject: [PATCH 1/3] js: implement SVGAnimatedString for SVG className and href (#330) SVG className and href reflected as plain strings, so el.className.animVal and el.href.animVal were undefined, which made new URL(svgEl.href.animVal) throw and broke any SVG-aware script. Add an SVGAnimatedString class (baseVal/animVal both read the live attribute; baseVal writable) and make the className and href getters return it for SVG-namespace elements. href falls back to the legacy xlink:href. HTML elements are unchanged. --- crates/obscura-js/js/bootstrap.js | 45 ++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index acc2912..5f1d1df 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -959,7 +959,15 @@ class Element extends Node { } get id() { return this.getAttribute("id") || ""; } set id(v) { this.setAttribute("id", v); } - get className() { return this.getAttribute("class") || ""; } + get className() { + // SVG elements reflect class as an SVGAnimatedString (.baseVal/.animVal), + // not a plain string. Anti-fraud sensors read el.className.animVal. + if (this.namespaceURI === "http://www.w3.org/2000/svg") { + if (!this._svgClassName) this._svgClassName = new SVGAnimatedString(this, "class"); + return this._svgClassName; + } + return this.getAttribute("class") || ""; + } set className(v) { this.setAttribute("class", v); } get namespaceURI() { // createElementNS records the requested namespace on _ns; an empty string @@ -1581,6 +1589,16 @@ class Element extends Node { // members use. Other elements reflect the raw attribute. get href() { const ln = this.localName; + // SVG href-bearing elements reflect href as an SVGAnimatedString (with the + // legacy xlink:href as a fallback), not a resolved URL string. Checked + // before the HTML path because an SVG also has localName 'a'. + if (this.namespaceURI === "http://www.w3.org/2000/svg" && + (ln === 'a' || ln === 'image' || ln === 'use' || ln === 'script' || + ln === 'pattern' || ln === 'filter' || ln === 'textPath' || ln === 'mpath' || + ln === 'linearGradient' || ln === 'radialGradient' || ln === 'feImage' || ln === 'tref')) { + if (!this._svgHref) this._svgHref = new SVGAnimatedString(this, "href", "xlink:href"); + return this._svgHref; + } if (ln === 'a' || ln === 'area') { const raw = this.getAttribute('href'); if (raw === null) return ''; @@ -4979,6 +4997,31 @@ globalThis.HTMLLegendElement = Element; globalThis.HTMLProgressElement = Element; globalThis.HTMLDetailsElement = Element; globalThis.HTMLDialogElement = Element; +// SVGAnimatedString backs the className and href reflections on SVG elements. +// baseVal and animVal both read the live attribute (no SMIL animation), and +// baseVal is writable. Used by the SVG-aware get className()/get href() above. +function SVGAnimatedString(el, attr, fallbackAttr) { + this._el = el; + this._attr = attr; + this._fallback = fallbackAttr || null; +} +SVGAnimatedString.prototype._read = function() { + let v = this._el.getAttribute(this._attr); + if (v === null && this._fallback) v = this._el.getAttribute(this._fallback); + return v == null ? '' : v; +}; +Object.defineProperty(SVGAnimatedString.prototype, 'baseVal', { + get() { return this._read(); }, + set(v) { this._el.setAttribute(this._attr, String(v)); }, + configurable: true, enumerable: true, +}); +Object.defineProperty(SVGAnimatedString.prototype, 'animVal', { + get() { return this._read(); }, + configurable: true, enumerable: true, +}); +Object.defineProperty(SVGAnimatedString.prototype, Symbol.toStringTag, { value: 'SVGAnimatedString', configurable: true }); +_markNative(SVGAnimatedString); + globalThis.SVGElement = Element; globalThis.SVGSVGElement = Element; globalThis.CharacterData = CharacterData; From 2840b08b5690f83cb8a12e915fb3bb0a2a55baf8 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:48:20 +0000 Subject: [PATCH 2/3] dom: support :has() selectors (#331) querySelectorAll / --selector silently returned nothing for :has(). Two fixes were needed: - Enable :has() parsing: the selectors crate gates it on the parser's parse_has() (default false), so a:has(p) failed to parse and the error was swallowed into an empty match set. - Fix OpaqueElement stability: opaque() was keyed off the transient DomElement (which is Copy and gets a fresh stack address each traversal step), so the same node produced a different id every call. :has anchor matching compares the anchor's opaque against the element reached by walking up the tree, so it never matched. Key opaque() off the node's stable slot in the tree's Vec instead. --- crates/obscura-dom/src/selector.rs | 37 +++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index 8ede02f..4c5acf4 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -167,6 +167,15 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser { type Impl = ObscuraSelector; type Error = SelectorParseErrorKind<'i>; + // Allow `:has()`. The selectors crate gates relative-selector parsing on + // this (default false), so without it `a:has(p)` failed to parse and the + // error was swallowed into an empty match set. Matching already passes a + // SelectorCaches (which holds the relative-selector cache), so enabling + // parsing is sufficient. + fn parse_has(&self) -> bool { + true + } + fn parse_non_ts_pseudo_class( &self, _location: cssparser::SourceLocation, @@ -219,7 +228,18 @@ impl<'a> Element for DomElement<'a> { type Impl = ObscuraSelector; fn opaque(&self) -> OpaqueElement { - OpaqueElement::new(self) + // Must be stable per node. DomElement is Copy and gets a fresh stack + // address on every traversal step, so OpaqueElement::new(self) returns a + // different id for the same node each call. That breaks `:has` anchor + // matching, which compares the anchor's opaque against the element + // reached by walking up the tree. Key off the node's stable slot in the + // tree's Vec instead (OpaqueElement only compares the address, never + // dereferences it, and the Vec is not mutated during a query). + let inner = self.tree.borrow_inner(); + match inner.nodes.get(self.node_id.index()) { + Some(slot) => OpaqueElement::new(slot), + None => OpaqueElement::new(self), + } } fn parent_element(&self) -> Option { @@ -666,6 +686,21 @@ mod tests { assert_eq!(results.len(), 3); } + #[test] + fn test_has_parses() { + // Isolate parse from match: :has must parse, not error. + assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse"); + } + + #[test] + fn test_query_selector_has() { + let tree = parse_html(r#"

x

"#); + let all = tree.query_selector_all("a:has(p.bt)").unwrap(); + assert_eq!(all.len(), 1, "a:has(p.bt) should match the "); + let none = tree.query_selector_all("a:has(span.bt)").unwrap(); + assert_eq!(none.len(), 0, "a:has(span.bt) should match nothing"); + } + #[test] fn test_query_selector_descendant() { let tree = From b63125faeda082013be80484fc85d409d4633205 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:48:20 +0000 Subject: [PATCH 3/3] cdp: flush cookies on SIGTERM in serve (#333) serve --storage-dir saved cookies only on Ctrl-C (SIGINT). docker stop and kill send SIGTERM, which was not caught, so the save_cookies() call at shutdown never ran and cookies were lost on restart. Watch SIGTERM as well as Ctrl-C on Unix. --- crates/obscura-cdp/src/server.rs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index fd1fd0e..180faae 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -366,11 +366,33 @@ async fn cdp_processor( let mut deferred: std::collections::VecDeque = std::collections::VecDeque::new(); - // Subscribe to Ctrl-C once. The future is single-shot, so we break out of - // the outer loop when it fires and never poll it again. Without this the - // accept loop just exits and any cookies set during the session are lost - // before `BrowserContext::save_cookies()` runs. - let mut shutdown = Box::pin(tokio::signal::ctrl_c()); + // Subscribe to graceful-shutdown signals once. The future is single-shot, + // so we break out of the outer loop when it fires and never poll it again. + // Without this the accept loop just exits and any cookies set during the + // session are lost before `BrowserContext::save_cookies()` runs. We watch + // SIGTERM as well as Ctrl-C (SIGINT) so `docker stop` / `kill`, which send + // SIGTERM, also flush cookies to the storage dir (issue #333). + let mut shutdown = Box::pin(async { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + match signal(SignalKind::terminate()) { + Ok(mut term) => { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + } + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + } + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } + }); loop { // Drain any deferred messages from the previous interception window