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, "