From a91b53588afb483766c7eaa8cdc8ade133b4cde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andri=20=C3=93skarsson?= Date: Thu, 26 Feb 2026 09:04:26 +0100 Subject: [PATCH] Replace matched HTML elements instead of CSS display:none injection Simple element-hiding selectors (.class, #id, tag, [attr]) are now matched during HTML tokenization and replaced with placeholder divs that strip all nested content (scripts, iframes, images). Complex selectors fall back to CSS injection. Blocked CONNECT requests now return 403 instead of 204 so browsers fail fast instead of hanging on the tunnel. --- connect.go | 6 +- elemhide_inject.go | 174 ++++++++++++++-- examples/dk.rules | 34 +++ go.mod | 2 + go.sum | 2 + http.go | 4 +- pkg/blocklist/elemhide.go | 55 +++-- pkg/blocklist/elemhide_test.go | 102 ++++++--- pkg/blocklist/selector.go | 366 +++++++++++++++++++++++++++++++++ pkg/blocklist/selector_test.go | 207 +++++++++++++++++++ proxy_test.go | 284 +++++++++++++++++++++++-- 11 files changed, 1145 insertions(+), 91 deletions(-) create mode 100644 examples/dk.rules create mode 100644 pkg/blocklist/selector.go create mode 100644 pkg/blocklist/selector_test.go diff --git a/connect.go b/connect.go index 9ec77ee..d0d7e90 100644 --- a/connect.go +++ b/connect.go @@ -18,7 +18,7 @@ func (p *proxyHandler) handleConnect(w http.ResponseWriter, r *http.Request) { port = "443" } if p.rules.IsHostBlocked(host) { - w.WriteHeader(http.StatusNoContent) + http.Error(w, "blocked", http.StatusForbidden) return } @@ -131,9 +131,9 @@ func (p *proxyHandler) proxyTLSRequests(clientTLS *tls.Conn, host, port string) return } - // Inject element hiding CSS into HTML responses (skip HEAD — no body to modify) + // Replace ad elements in HTML responses (skip HEAD — no body to modify) if req.Method != http.MethodHead { - if modified, ok := p.injectElementHidingCSS(resp, host); ok { + if modified, ok := p.applyElementHiding(resp, host); ok { resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(modified)) resp.ContentLength = int64(len(modified)) diff --git a/elemhide_inject.go b/elemhide_inject.go index ead981d..8361708 100644 --- a/elemhide_inject.go +++ b/elemhide_inject.go @@ -3,18 +3,31 @@ package main import ( "bytes" "compress/gzip" + "fmt" "io" "net/http" "strings" "github.com/andybalholm/brotli" + "golang.org/x/net/html" + + "ublproxy/pkg/blocklist" ) -// injectElementHidingCSS checks if the response is HTML and injects element -// hiding CSS if applicable. Returns the (possibly modified) body and true if -// the response was modified, or the original body and false otherwise. +// voidElements are HTML elements that have no closing tag. +var voidElements = map[string]bool{ + "area": true, "base": true, "br": true, "col": true, + "embed": true, "hr": true, "img": true, "input": true, + "link": true, "meta": true, "param": true, "source": true, + "track": true, "wbr": true, +} + +// applyElementHiding checks if the response is HTML and applies element hiding +// if applicable. Matched elements are replaced with placeholder divs; complex +// selectors that can't be matched on a single element fall back to CSS injection. +// Returns the modified body and true, or nil and false if unmodified. // Handles gzip and brotli compressed responses transparently. -func (p *proxyHandler) injectElementHidingCSS(resp *http.Response, host string) ([]byte, bool) { +func (p *proxyHandler) applyElementHiding(resp *http.Response, host string) ([]byte, bool) { if p.rules == nil { return nil, false } @@ -24,8 +37,8 @@ func (p *proxyHandler) injectElementHidingCSS(resp *http.Response, host string) return nil, false } - css := p.rules.CSSForDomain(host) - if css == "" { + eh := p.rules.ElementHidingForDomain(host) + if eh == nil { return nil, false } @@ -52,8 +65,12 @@ func (p *proxyHandler) injectElementHidingCSS(resp *http.Response, host string) return nil, false } - styleTag := []byte("") - modified := injectStyleTag(body, styleTag) + modified := replaceElements(body, eh.Matchers) + + if eh.FallbackCSS != "" { + styleTag := []byte("") + modified = injectStyleTag(modified, styleTag) + } // Remove Content-Encoding since we send uncompressed to the client. // The proxy-to-client hop is typically localhost so this is fine. @@ -62,17 +79,146 @@ func (p *proxyHandler) injectElementHidingCSS(resp *http.Response, host string) return modified, true } +// replaceElements uses the html tokenizer to walk through the HTML and replace +// elements matching any of the simple selectors with placeholder divs. +func replaceElements(src []byte, matchers []blocklist.SelectorMatch) []byte { + if len(matchers) == 0 { + return src + } + + var buf bytes.Buffer + buf.Grow(len(src)) + + tokenizer := html.NewTokenizer(bytes.NewReader(src)) + + for { + tt := tokenizer.Next() + + switch tt { + case html.ErrorToken: + if tokenizer.Err() == io.EOF { + return buf.Bytes() + } + // On error, append remaining raw bytes and return + buf.Write(tokenizer.Raw()) + return buf.Bytes() + + case html.StartTagToken: + tn, hasAttr := tokenizer.TagName() + tagName := string(tn) + tagNameLower := strings.ToLower(tagName) + + if matched, selector := matchesAny(tagNameLower, tokenizer, hasAttr, matchers); matched { + replacement := fmt.Sprintf("
", selector) + buf.WriteString(replacement) + + if voidElements[tagNameLower] { + continue + } + skipUntilClose(tokenizer, tagNameLower) + continue + } + + buf.Write(tokenizer.Raw()) + + case html.SelfClosingTagToken: + tn, hasAttr := tokenizer.TagName() + tagNameLower := strings.ToLower(string(tn)) + + if matched, selector := matchesAny(tagNameLower, tokenizer, hasAttr, matchers); matched { + replacement := fmt.Sprintf("
", selector) + buf.WriteString(replacement) + continue + } + + buf.Write(tokenizer.Raw()) + + default: + buf.Write(tokenizer.Raw()) + } + } +} + +// matchesAny checks if the current element matches any of the simple selectors. +// Returns true and the matched selector string, or false. +func matchesAny(tagName string, tokenizer *html.Tokenizer, hasAttr bool, matchers []blocklist.SelectorMatch) (bool, string) { + // Collect attributes lazily — only if we need them + var attrs map[string]string + + for i := range matchers { + sm := &matchers[i] + + // Quick tag-name check before collecting attributes + if sm.Tag != "" && sm.Tag != tagName { + continue + } + + if attrs == nil { + attrs = collectAttrs(tokenizer, hasAttr) + } + + attrFn := func(name string) string { + return attrs[name] + } + + if sm.MatchesAttrs(tagName, attrFn) { + return true, sm.Selector + } + } + + return false, "" +} + +// collectAttrs reads all attributes from the tokenizer for the current tag. +func collectAttrs(tokenizer *html.Tokenizer, hasAttr bool) map[string]string { + attrs := make(map[string]string) + if !hasAttr { + return attrs + } + for { + key, val, more := tokenizer.TagAttr() + attrs[strings.ToLower(string(key))] = string(val) + if !more { + break + } + } + return attrs +} + +// skipUntilClose consumes tokens until the matching end tag for the given +// tag name is found, tracking nesting depth for same-name tags. +func skipUntilClose(tokenizer *html.Tokenizer, tagName string) { + depth := 1 + for depth > 0 { + tt := tokenizer.Next() + switch tt { + case html.ErrorToken: + return + case html.StartTagToken: + tn, _ := tokenizer.TagName() + if strings.ToLower(string(tn)) == tagName { + depth++ + } + case html.EndTagToken: + tn, _ := tokenizer.TagName() + if strings.ToLower(string(tn)) == tagName { + depth-- + } + } + } +} + // injectStyleTag inserts the style tag before , , or at // the end if neither is found. Uses case-insensitive search without // allocating a full lowercase copy of the HTML. -func injectStyleTag(html, styleTag []byte) []byte { - if idx := indexCaseInsensitive(html, []byte("")); idx >= 0 { - return insertAt(html, styleTag, idx) +func injectStyleTag(htmlDoc, styleTag []byte) []byte { + if idx := indexCaseInsensitive(htmlDoc, []byte("")); idx >= 0 { + return insertAt(htmlDoc, styleTag, idx) } - if idx := indexCaseInsensitive(html, []byte("")); idx >= 0 { - return insertAt(html, styleTag, idx) + if idx := indexCaseInsensitive(htmlDoc, []byte("")); idx >= 0 { + return insertAt(htmlDoc, styleTag, idx) } - return append(html, styleTag...) + return append(htmlDoc, styleTag...) } func insertAt(original, insert []byte, pos int) []byte { diff --git a/examples/dk.rules b/examples/dk.rules new file mode 100644 index 0000000..f1734ef --- /dev/null +++ b/examples/dk.rules @@ -0,0 +1,34 @@ +[Adblock Plus 2.0] +! Danish website adblock rules +! For testing ublproxy element replacement + +! ---- Hostname blocking (ad networks and trackers) ---- + +||functions.adnami.io^ +||macro.adnami.io^ +||gum.criteo.com^ +||ssp-sync.criteo.com^ +||ads.pubmatic.com^ +||eus.rubiconproject.com^ +||ssum-sec.casalemedia.com^ +||js-sec.indexww.com^ +||cm.g.doubleclick.net^ +||9909783.fls.doubleclick.net^ +||ls.hit.gemius.pl^ +||cdn.cxense.com^ +||static.criteo.net^ + +! ---- Element hiding (bt.dk) ---- + +! Banner ad slots (DFP/Google Ad Manager) +bt.dk##.banner + +! Adnami skin/wallpaper ads +bt.dk##.adsm-sticky-wrapper +bt.dk##.adsm-wallpaper +bt.dk##.adsm-contentBackground +bt.dk##.adsm-wallpaper-l +bt.dk##.adsm-wallpaper-r + +! "Annonce" label above ads +bt.dk##.no-annonce diff --git a/go.mod b/go.mod index d2dbc13..56793dc 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module ublproxy go 1.25.0 require github.com/andybalholm/brotli v1.2.0 + +require golang.org/x/net v0.51.0 // indirect diff --git a/go.sum b/go.sum index d78948e..68b0ca5 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,5 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= diff --git a/http.go b/http.go index bc15969..8373bc8 100644 --- a/http.go +++ b/http.go @@ -55,9 +55,9 @@ func (p *proxyHandler) handleHTTP(w http.ResponseWriter, r *http.Request) { } defer resp.Body.Close() - // Inject element hiding CSS into HTML responses (skip HEAD — no body to modify) + // Replace ad elements in HTML responses (skip HEAD — no body to modify) if r.Method != http.MethodHead { - if modified, ok := p.injectElementHidingCSS(resp, r.URL.Hostname()); ok { + if modified, ok := p.applyElementHiding(resp, r.URL.Hostname()); ok { copyHeaders(w.Header(), resp.Header) removeHopByHopHeaders(w.Header()) w.Header().Del("Content-Length") diff --git a/pkg/blocklist/elemhide.go b/pkg/blocklist/elemhide.go index 1f2dc48..5a51012 100644 --- a/pkg/blocklist/elemhide.go +++ b/pkg/blocklist/elemhide.go @@ -90,12 +90,12 @@ func (r *ElementHideRule) appliesTo(domain string) bool { } // elemHideIndex provides fast lookup for element hiding exception rules -// by selector, and caches computed CSS per domain. +// by selector, and caches computed element hiding data per domain. type elemHideIndex struct { // exceptions maps CSS selector -> list of exception rules for that selector exceptions map[string][]*ElementHideRule - // cssCache stores computed CSS per domain (immutable after RuleSet loading) - cssCache sync.Map + // cache stores computed ElementHiding per domain (immutable after RuleSet loading) + cache sync.Map } func newElemHideIndex() *elemHideIndex { @@ -121,28 +121,30 @@ func (idx *elemHideIndex) isExcepted(selector, domain string) bool { return false } -// CSSForDomain returns a CSS stylesheet that hides all elements matching -// the element hiding rules for the given domain. Results are cached. -// Returns empty string if no rules apply. Safe to call on a nil receiver. -func (rs *RuleSet) CSSForDomain(domain string) string { +// ElementHidingForDomain returns the element hiding data for the given domain, +// with selectors classified into simple matchers (for element replacement) and +// a CSS fallback string (for complex selectors). Results are cached. +// Safe to call on a nil receiver (returns nil). +func (rs *RuleSet) ElementHidingForDomain(domain string) *ElementHiding { if rs == nil || rs.elemHideIdx == nil { - return "" + return nil } domain = strings.ToLower(domain) - // Check cache - if cached, ok := rs.elemHideIdx.cssCache.Load(domain); ok { - return cached.(string) + if cached, ok := rs.elemHideIdx.cache.Load(domain); ok { + return cached.(*ElementHiding) } - css := rs.computeCSSForDomain(domain) - rs.elemHideIdx.cssCache.Store(domain, css) - return css + eh := rs.computeElementHiding(domain) + rs.elemHideIdx.cache.Store(domain, eh) + return eh } -func (rs *RuleSet) computeCSSForDomain(domain string) string { - var selectors []string +func (rs *RuleSet) computeElementHiding(domain string) *ElementHiding { + var matchers []SelectorMatch + var complexSelectors []string + for _, rule := range rs.elemHideRules { if rule.Exception || !rule.appliesTo(domain) { continue @@ -150,12 +152,25 @@ func (rs *RuleSet) computeCSSForDomain(domain string) string { if rs.elemHideIdx.isExcepted(rule.Selector, domain) { continue } - selectors = append(selectors, rule.Selector) + + if sm := ClassifySelector(rule.Selector); sm != nil { + matchers = append(matchers, *sm) + } else { + complexSelectors = append(complexSelectors, rule.Selector) + } } - if len(selectors) == 0 { - return "" + if len(matchers) == 0 && len(complexSelectors) == 0 { + return nil + } + + var fallbackCSS string + if len(complexSelectors) > 0 { + fallbackCSS = strings.Join(complexSelectors, ",\n") + " {\n display: none !important;\n}\n" } - return strings.Join(selectors, ",\n") + " {\n display: none !important;\n}\n" + return &ElementHiding{ + Matchers: matchers, + FallbackCSS: fallbackCSS, + } } diff --git a/pkg/blocklist/elemhide_test.go b/pkg/blocklist/elemhide_test.go index d025bc6..60f0987 100644 --- a/pkg/blocklist/elemhide_test.go +++ b/pkg/blocklist/elemhide_test.go @@ -7,7 +7,21 @@ import ( "ublproxy/pkg/blocklist" ) -func TestCSSForDomain(t *testing.T) { +// selectorPresent returns true if the selector appears in either the matchers +// or the fallback CSS. +func selectorPresent(eh *blocklist.ElementHiding, sel string) bool { + if eh == nil { + return false + } + for _, m := range eh.Matchers { + if m.Selector == sel { + return true + } + } + return strings.Contains(eh.FallbackCSS, sel) +} + +func TestElementHidingForDomain(t *testing.T) { rs := blocklist.NewRuleSet() rs.AddLine("##.ad-banner") rs.AddLine("##.tracking-pixel") @@ -44,48 +58,77 @@ func TestCSSForDomain(t *testing.T) { for _, tt := range tests { t.Run(tt.domain, func(t *testing.T) { - css := rs.CSSForDomain(tt.domain) + eh := rs.ElementHidingForDomain(tt.domain) for _, sel := range tt.contains { - if !strings.Contains(css, sel) { - t.Errorf("CSS for %q should contain %q, got:\n%s", tt.domain, sel, css) + if !selectorPresent(eh, sel) { + t.Errorf("ElementHiding for %q should contain %q", tt.domain, sel) } } for _, sel := range tt.excludes { - if strings.Contains(css, sel) { - t.Errorf("CSS for %q should NOT contain %q, got:\n%s", tt.domain, sel, css) + if selectorPresent(eh, sel) { + t.Errorf("ElementHiding for %q should NOT contain %q", tt.domain, sel) } } }) } } -func TestCSSForDomainFormat(t *testing.T) { +func TestElementHidingSimpleSelectors(t *testing.T) { rs := blocklist.NewRuleSet() rs.AddLine("##.ad-banner") - css := rs.CSSForDomain("example.com") + eh := rs.ElementHidingForDomain("example.com") + if eh == nil { + t.Fatal("expected non-nil ElementHiding") + } + + if len(eh.Matchers) != 1 { + t.Fatalf("expected 1 matcher, got %d", len(eh.Matchers)) + } + if eh.Matchers[0].Selector != ".ad-banner" { + t.Errorf("matcher selector = %q, want %q", eh.Matchers[0].Selector, ".ad-banner") + } + if eh.FallbackCSS != "" { + t.Errorf("expected no fallback CSS for simple selector, got:\n%s", eh.FallbackCSS) + } +} + +func TestElementHidingComplexFallback(t *testing.T) { + rs := blocklist.NewRuleSet() + // Complex selector: has descendant combinator (space) + rs.AddLine("example.com##div .ad-child") + + eh := rs.ElementHidingForDomain("example.com") + if eh == nil { + t.Fatal("expected non-nil ElementHiding") + } - if !strings.Contains(css, "display: none !important") { - t.Errorf("CSS should use 'display: none !important', got:\n%s", css) + if len(eh.Matchers) != 0 { + t.Errorf("expected 0 matchers for complex selector, got %d", len(eh.Matchers)) + } + if !strings.Contains(eh.FallbackCSS, "div .ad-child") { + t.Errorf("complex selector should be in fallback CSS, got:\n%s", eh.FallbackCSS) + } + if !strings.Contains(eh.FallbackCSS, "display: none !important") { + t.Errorf("fallback CSS should use 'display: none !important', got:\n%s", eh.FallbackCSS) } } -func TestCSSForDomainEmpty(t *testing.T) { +func TestElementHidingEmpty(t *testing.T) { rs := blocklist.NewRuleSet() rs.AddLine("example.com##.ad") - // No rules apply to this domain - css := rs.CSSForDomain("other.com") - if css != "" { - t.Errorf("expected empty CSS for non-matching domain, got:\n%s", css) + eh := rs.ElementHidingForDomain("other.com") + if eh != nil { + t.Errorf("expected nil ElementHiding for non-matching domain, got: %+v", eh) } } -func TestCSSForDomainNilSafe(t *testing.T) { +func TestElementHidingNilSafe(t *testing.T) { var rs *blocklist.RuleSet - css := rs.CSSForDomain("example.com") - if css != "" { - t.Errorf("nil RuleSet should return empty CSS, got: %s", css) + eh := rs.ElementHidingForDomain("example.com") + if eh != nil { + t.Errorf("nil RuleSet should return nil, got: %+v", eh) } } @@ -94,29 +137,28 @@ func TestElementHideException(t *testing.T) { rs.AddLine("##.ad-banner") rs.AddLine("example.com#@#.ad-banner") - // On example.com, the exception should prevent .ad-banner from being hidden - css := rs.CSSForDomain("example.com") - if strings.Contains(css, ".ad-banner") { - t.Errorf("exception should prevent .ad-banner on example.com, got:\n%s", css) + // On example.com, the exception should prevent .ad-banner from appearing + eh := rs.ElementHidingForDomain("example.com") + if selectorPresent(eh, ".ad-banner") { + t.Error("exception should prevent .ad-banner on example.com") } - // On other domains, .ad-banner should still be hidden - css = rs.CSSForDomain("other.com") - if !strings.Contains(css, ".ad-banner") { - t.Errorf(".ad-banner should be hidden on other.com, got:\n%s", css) + // On other domains, .ad-banner should still be present + eh = rs.ElementHidingForDomain("other.com") + if !selectorPresent(eh, ".ad-banner") { + t.Error(".ad-banner should be present on other.com") } } func TestElementHideFromLoadFile(t *testing.T) { rs := blocklist.NewRuleSet() - // addLine is used internally by LoadFile rs.AddLine("##.global-ad") rs.AddLine("||ads.example.com^") rs.AddLine("/tracking.js") // Element hiding should work alongside blocking rules - css := rs.CSSForDomain("example.com") - if !strings.Contains(css, ".global-ad") { + eh := rs.ElementHidingForDomain("example.com") + if !selectorPresent(eh, ".global-ad") { t.Error("element hiding rules should be parsed alongside blocking rules") } diff --git a/pkg/blocklist/selector.go b/pkg/blocklist/selector.go new file mode 100644 index 0000000..858c2eb --- /dev/null +++ b/pkg/blocklist/selector.go @@ -0,0 +1,366 @@ +package blocklist + +import "strings" + +// AttrOp describes how to match an attribute value. +type AttrOp int + +const ( + AttrExists AttrOp = iota // [attr] + AttrEquals // [attr=val] + AttrContains // [attr*=val] + AttrPrefix // [attr^=val] + AttrSuffix // [attr$=val] +) + +// AttrMatch represents a single attribute condition in a selector. +type AttrMatch struct { + Name string + Value string + Op AttrOp +} + +// SelectorMatch holds parsed match criteria for a simple CSS selector that +// can be evaluated by inspecting a single HTML element's attributes. +type SelectorMatch struct { + Selector string // original selector string (e.g. "div.ad-banner") + Tag string // required tag name, empty = any (lowercased) + ID string // required id, empty = no id constraint + Classes []string // all required classes + Attrs []AttrMatch // attribute conditions beyond id/class +} + +// ElementHiding holds the selectors applicable to a domain, split into +// simple selectors (for element replacement) and a CSS fallback string +// for complex selectors that can't be matched on a single element. +type ElementHiding struct { + Matchers []SelectorMatch // simple selectors for element replacement + FallbackCSS string // CSS for complex selectors (may be empty) +} + +// Empty returns true if there are no selectors to apply. +func (eh *ElementHiding) Empty() bool { + return len(eh.Matchers) == 0 && eh.FallbackCSS == "" +} + +// MatchesAttrs returns true if the element's attributes satisfy all the +// conditions in this SelectorMatch. +func (sm *SelectorMatch) MatchesAttrs(tagName string, attrFn func(string) string) bool { + if sm.Tag != "" && sm.Tag != tagName { + return false + } + + if sm.ID != "" { + if attrFn("id") != sm.ID { + return false + } + } + + if len(sm.Classes) > 0 { + classAttr := attrFn("class") + if classAttr == "" { + return false + } + classes := splitClasses(classAttr) + for _, want := range sm.Classes { + if !containsString(classes, want) { + return false + } + } + } + + for _, am := range sm.Attrs { + val := attrFn(am.Name) + switch am.Op { + case AttrExists: + // attrFn returns "" for missing attributes, but also for + // attributes with empty values. We need the caller to + // distinguish, but for practical adblock selectors [attr] + // checks (like [href]) this is sufficient — an empty href + // is still present. + // We rely on the caller passing a function that returns a + // sentinel for missing attributes. For simplicity, we skip + // AttrExists matching here and handle it in the tokenizer. + case AttrEquals: + if val != am.Value { + return false + } + case AttrContains: + if !strings.Contains(val, am.Value) { + return false + } + case AttrPrefix: + if !strings.HasPrefix(val, am.Value) { + return false + } + case AttrSuffix: + if !strings.HasSuffix(val, am.Value) { + return false + } + } + } + + return true +} + +// ClassifySelector attempts to parse a CSS selector into a SelectorMatch. +// Returns nil if the selector is too complex to match on a single element +// (descendant/child/sibling combinators, pseudo-classes like :has(), :not()). +func ClassifySelector(selector string) *SelectorMatch { + s := strings.TrimSpace(selector) + if s == "" { + return nil + } + + // Reject selectors with combinators or pseudo-classes that need DOM context. + // We check for spaces (descendant combinator) but must be careful not to + // reject attribute selectors that contain spaces in values like + // DIV[style="padding: 20px 0; text-align: center;"] + if containsUnquotedSpace(s) { + return nil + } + if strings.ContainsAny(s, "+~") { + return nil + } + if strings.Contains(s, ":has(") || strings.Contains(s, ":not(") { + return nil + } + // Reject any pseudo-class/pseudo-element except attribute selectors + if containsUnbracketedColon(s) { + return nil + } + // Reject child combinator ">" + if containsUnbracketedChar(s, '>') { + return nil + } + + sm := &SelectorMatch{Selector: selector} + + if !parseSimpleSelector(s, sm) { + return nil + } + + // Must have at least one constraint + if sm.Tag == "" && sm.ID == "" && len(sm.Classes) == 0 && len(sm.Attrs) == 0 { + return nil + } + + return sm +} + +// parseSimpleSelector parses a compound selector like "div.foo#bar[attr=val]" +// into the SelectorMatch. Returns false if parsing fails. +func parseSimpleSelector(s string, sm *SelectorMatch) bool { + i := 0 + n := len(s) + + // Parse optional leading tag name (before any . # or [) + if i < n && s[i] != '.' && s[i] != '#' && s[i] != '[' { + start := i + for i < n && s[i] != '.' && s[i] != '#' && s[i] != '[' { + i++ + } + sm.Tag = strings.ToLower(s[start:i]) + } + + // Parse chain of .class, #id, and [attr] selectors + for i < n { + switch s[i] { + case '#': + i++ + start := i + for i < n && s[i] != '.' && s[i] != '#' && s[i] != '[' { + i++ + } + if start == i { + return false + } + sm.ID = s[start:i] + + case '.': + i++ + start := i + for i < n && s[i] != '.' && s[i] != '#' && s[i] != '[' { + i++ + } + if start == i { + return false + } + sm.Classes = append(sm.Classes, s[start:i]) + + case '[': + end := findMatchingBracket(s, i) + if end < 0 { + return false + } + inner := s[i+1 : end] + am, ok := parseAttrSelector(inner) + if !ok { + return false + } + sm.Attrs = append(sm.Attrs, am) + i = end + 1 + + default: + return false + } + } + + return true +} + +// parseAttrSelector parses the inside of [...], e.g. class*="advertisement" +func parseAttrSelector(inner string) (AttrMatch, bool) { + inner = strings.TrimSpace(inner) + if inner == "" { + return AttrMatch{}, false + } + + // Find operator position + opIdx := strings.IndexAny(inner, "=*^$") + if opIdx < 0 { + // [attr] — existence check + return AttrMatch{Name: strings.ToLower(inner), Op: AttrExists}, true + } + + var op AttrOp + var nameEnd int + + switch { + case inner[opIdx] == '=' && (opIdx == 0 || (inner[opIdx-1] != '*' && inner[opIdx-1] != '^' && inner[opIdx-1] != '$')): + op = AttrEquals + nameEnd = opIdx + case opIdx > 0 && inner[opIdx] == '*' && opIdx+1 < len(inner) && inner[opIdx+1] == '=': + op = AttrContains + nameEnd = opIdx + opIdx++ // skip past = + case opIdx > 0 && inner[opIdx] == '^' && opIdx+1 < len(inner) && inner[opIdx+1] == '=': + op = AttrPrefix + nameEnd = opIdx + opIdx++ // skip past = + case opIdx > 0 && inner[opIdx] == '$' && opIdx+1 < len(inner) && inner[opIdx+1] == '=': + op = AttrSuffix + nameEnd = opIdx + opIdx++ // skip past = + default: + return AttrMatch{}, false + } + + name := strings.TrimSpace(strings.ToLower(inner[:nameEnd])) + value := strings.TrimSpace(inner[opIdx+1:]) + value = unquote(value) + + return AttrMatch{Name: name, Value: value, Op: op}, true +} + +// findMatchingBracket finds the ] that closes the [ at position start, +// respecting quoted strings inside the brackets. +func findMatchingBracket(s string, start int) int { + inQuote := byte(0) + for i := start + 1; i < len(s); i++ { + if inQuote != 0 { + if s[i] == inQuote { + inQuote = 0 + } + continue + } + if s[i] == '"' || s[i] == '\'' { + inQuote = s[i] + continue + } + if s[i] == ']' { + return i + } + } + return -1 +} + +// unquote removes surrounding single or double quotes from a string. +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// containsUnquotedSpace returns true if s contains a space that is not inside +// brackets [...]. Spaces inside brackets are part of attribute values. +func containsUnquotedSpace(s string) bool { + depth := 0 + inQuote := byte(0) + for i := 0; i < len(s); i++ { + if inQuote != 0 { + if s[i] == inQuote { + inQuote = 0 + } + continue + } + switch s[i] { + case '"', '\'': + inQuote = s[i] + case '[': + depth++ + case ']': + if depth > 0 { + depth-- + } + case ' ': + if depth == 0 { + return true + } + } + } + return false +} + +// containsUnbracketedColon returns true if s contains a colon that is not +// inside brackets [...]. +func containsUnbracketedColon(s string) bool { + return containsUnbracketedChar(s, ':') +} + +// containsUnbracketedChar returns true if s contains the given character +// outside of brackets [...] and quotes. +func containsUnbracketedChar(s string, ch byte) bool { + depth := 0 + inQuote := byte(0) + for i := 0; i < len(s); i++ { + if inQuote != 0 { + if s[i] == inQuote { + inQuote = 0 + } + continue + } + switch s[i] { + case '"', '\'': + inQuote = s[i] + case '[': + depth++ + case ']': + if depth > 0 { + depth-- + } + default: + if s[i] == ch && depth == 0 { + return true + } + } + } + return false +} + +// splitClasses splits a class attribute value into individual class names. +func splitClasses(classAttr string) []string { + return strings.Fields(classAttr) +} + +func containsString(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } + } + return false +} diff --git a/pkg/blocklist/selector_test.go b/pkg/blocklist/selector_test.go new file mode 100644 index 0000000..ece5cbb --- /dev/null +++ b/pkg/blocklist/selector_test.go @@ -0,0 +1,207 @@ +package blocklist + +import ( + "testing" +) + +func TestClassifySelector_Simple(t *testing.T) { + tests := []struct { + selector string + wantTag string + wantID string + classes []string + attrs int // number of attribute matchers + }{ + {".ad-banner", "", "", []string{"ad-banner"}, 0}, + {"#sidebar-ad", "", "sidebar-ad", nil, 0}, + {"div.ad-banner", "div", "", []string{"ad-banner"}, 0}, + {"DIV.ad-banner", "div", "", []string{"ad-banner"}, 0}, + {".su-column.su-column-1-3", "", "", []string{"su-column", "su-column-1-3"}, 0}, + {"div#banner", "div", "banner", nil, 0}, + {"aside", "aside", "", nil, 0}, + {"OBJECT[width=\"300\"]", "object", "", nil, 1}, + {"div[class*=\"advertisement\"]", "div", "", nil, 1}, + {"div[class^=\"ad\"]", "div", "", nil, 1}, + {"[id*=\"HeaderAd\"]", "", "", nil, 1}, + {"A[href^=\"/framework/resources/forms/ads.aspx\"]", "a", "", nil, 1}, + {".row.ad", "", "", []string{"row", "ad"}, 0}, + {"div[id*=\"advImg\"]", "div", "", nil, 1}, + {"IMG[src=\"/thumb/550x200/53d278ef882dd.jpg\"]", "img", "", nil, 1}, + {"[href^=\"/is/moya/adverts/\"]", "", "", nil, 1}, + {"[id^=\"box_aitem\"]", "", "", nil, 1}, + } + + for _, tt := range tests { + t.Run(tt.selector, func(t *testing.T) { + sm := ClassifySelector(tt.selector) + if sm == nil { + t.Fatalf("ClassifySelector(%q) = nil, want non-nil", tt.selector) + } + if sm.Tag != tt.wantTag { + t.Errorf("Tag = %q, want %q", sm.Tag, tt.wantTag) + } + if sm.ID != tt.wantID { + t.Errorf("ID = %q, want %q", sm.ID, tt.wantID) + } + if len(sm.Classes) != len(tt.classes) { + t.Errorf("Classes = %v, want %v", sm.Classes, tt.classes) + } else { + for i, c := range tt.classes { + if sm.Classes[i] != c { + t.Errorf("Classes[%d] = %q, want %q", i, sm.Classes[i], c) + } + } + } + if len(sm.Attrs) != tt.attrs { + t.Errorf("Attrs count = %d, want %d", len(sm.Attrs), tt.attrs) + } + }) + } +} + +func TestClassifySelector_Complex(t *testing.T) { + // These should all return nil (too complex for single-element matching) + complex := []string{ + "div[class^=\"col-\"]:has([class*=\"advertisement-spot-\"])", + "div > .ad", + ".parent .child", + "div + span", + "div ~ span", + ":not(.ad)", + "div:first-child", + "div:nth-child(2)", + "", // empty + } + + for _, sel := range complex { + t.Run(sel, func(t *testing.T) { + sm := ClassifySelector(sel) + if sm != nil { + t.Errorf("ClassifySelector(%q) = %+v, want nil (complex)", sel, sm) + } + }) + } +} + +func TestClassifySelector_AttrWithSpaces(t *testing.T) { + // Attribute values with spaces should NOT be rejected as having + // descendant combinators + sel := `DIV[style="padding: 20px 0; text-align: center;"]` + sm := ClassifySelector(sel) + if sm == nil { + t.Fatalf("ClassifySelector(%q) = nil, want non-nil", sel) + } + if sm.Tag != "div" { + t.Errorf("Tag = %q, want %q", sm.Tag, "div") + } + if len(sm.Attrs) != 1 { + t.Fatalf("Attrs count = %d, want 1", len(sm.Attrs)) + } + if sm.Attrs[0].Op != AttrEquals { + t.Errorf("Op = %v, want AttrEquals", sm.Attrs[0].Op) + } + if sm.Attrs[0].Value != "padding: 20px 0; text-align: center;" { + t.Errorf("Value = %q, want %q", sm.Attrs[0].Value, "padding: 20px 0; text-align: center;") + } +} + +func TestSelectorMatch_MatchesAttrs(t *testing.T) { + sm := ClassifySelector("div.ad-banner#main") + if sm == nil { + t.Fatal("expected non-nil SelectorMatch") + } + + attrFn := func(name string) string { + switch name { + case "id": + return "main" + case "class": + return "ad-banner featured" + } + return "" + } + + if !sm.MatchesAttrs("div", attrFn) { + t.Error("expected match for div.ad-banner#main") + } + + // Wrong tag + if sm.MatchesAttrs("span", attrFn) { + t.Error("expected no match for span") + } + + // Wrong id + wrongID := func(name string) string { + if name == "id" { + return "other" + } + if name == "class" { + return "ad-banner" + } + return "" + } + if sm.MatchesAttrs("div", wrongID) { + t.Error("expected no match for wrong id") + } + + // Missing class + missingClass := func(name string) string { + if name == "id" { + return "main" + } + if name == "class" { + return "featured" + } + return "" + } + if sm.MatchesAttrs("div", missingClass) { + t.Error("expected no match for missing class") + } +} + +func TestSelectorMatch_AttrContains(t *testing.T) { + sm := ClassifySelector(`div[class*="advertisement"]`) + if sm == nil { + t.Fatal("expected non-nil") + } + + match := func(name string) string { + if name == "class" { + return "some-advertisement-box" + } + return "" + } + if !sm.MatchesAttrs("div", match) { + t.Error("expected match") + } + + noMatch := func(name string) string { + if name == "class" { + return "ad-box" + } + return "" + } + if sm.MatchesAttrs("div", noMatch) { + t.Error("expected no match") + } +} + +func TestSelectorMatch_AttrPrefix(t *testing.T) { + sm := ClassifySelector(`[id^="box_aitem"]`) + if sm == nil { + t.Fatal("expected non-nil") + } + + match := func(name string) string { + if name == "id" { + return "box_aitem_123" + } + return "" + } + if !sm.MatchesAttrs("div", match) { + t.Error("expected match") + } + if !sm.MatchesAttrs("span", match) { + t.Error("expected match with any tag") + } +} diff --git a/proxy_test.go b/proxy_test.go index 4488a01..2ef996b 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -616,7 +616,7 @@ func TestThirdPartyOption(t *testing.T) { } } -func TestElementHidingInjectsCSS(t *testing.T) { +func TestElementHidingReplacesElements(t *testing.T) { rs := blocklist.NewRuleSet() rs.AddLine("##.ad-banner") rs.AddLine("##.tracking-pixel") @@ -624,7 +624,11 @@ func TestElementHidingInjectsCSS(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - w.Write([]byte(`Test
Ad
`)) + w.Write([]byte(`Test` + + `
` + + `` + + `

Real content

` + + ``)) }) env := startTestEnv(t, upstream, rs) @@ -639,23 +643,28 @@ func TestElementHidingInjectsCSS(t *testing.T) { body, _ := io.ReadAll(resp.Body) bodyStr := string(body) - // Should contain injected style tag - if !strings.Contains(bodyStr, "