Hello
+ Wide +
+ diff --git a/DECISIONS.md b/DECISIONS.md index 3f31dcb..ff135e1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -86,3 +86,4 @@ - 2026-02-28 m+git@andri.dk — Lazy session restore in `authenticate()`. The in-memory `sessionMap` (IP → credential) is lost on server restart. Now, when a valid Bearer token is validated against SQLite, `authenticate()` also populates the `sessionMap` for that client IP. The proxy knows the user as soon as their browser makes any authenticated API call (e.g. `GET /api/whoami` on portal page load). No schema change needed. - 2026-02-28 m+git@andri.dk — `X-Ublproxy-Stats` response header on modified HTML responses. Reports `hidden=N; stripped=N` — the number of CSS element-hiding selectors injected and HTML elements (script/iframe/object/embed) stripped. Present only when the proxy modified the response. Zero performance overhead: counts are byproducts of work already being done. Useful for debugging filtering issues across browsers. - 2026-02-28 m+git@andri.dk — Warn-level logging when HTML filtering is skipped due to unsupported `Content-Encoding`, decompression init failure, or decompression read failure. Previously these were silent `return nil, false` paths — filtering was silently bypassed with no diagnostic output. +- 2026-02-28 m+git@andri.dk — Pre-filter element hiding selectors against HTML content before CSS injection. With full EasyList subscriptions, `ElementHidingForDomain` returns ~64K global selectors for every domain. Chrome truncates CSS rules that exceed its internal selector limit, silently breaking element hiding. Fix: extract all class names and IDs from the decompressed HTML body (fast tokenizer pass, no DOM construction), then only inject selectors whose primary class/ID token appears in the document. Simple selectors (`.class`, `#id`, `.a.b` compounds, `tag.class`) are matched against the token set; complex selectors (combinators, attribute selectors, pseudo-classes) are always included since they can't be pre-evaluated without a full CSS engine. Reduces injected CSS from ~64K selectors to typically <200 per page. diff --git a/css_filter.go b/css_filter.go new file mode 100644 index 0000000..579b5c5 --- /dev/null +++ b/css_filter.go @@ -0,0 +1,126 @@ +package main + +import ( + "bytes" + "strings" + + "golang.org/x/net/html" +) + +// htmlTokens holds the classes and IDs found in an HTML document. +// Used to pre-filter CSS selectors before injection — only selectors +// that could match something in the document are injected. +type htmlTokens struct { + classes map[string]bool + ids map[string]bool +} + +// extractHTMLTokens does a fast pass over the HTML to collect all class +// names and id attribute values. It uses the tokenizer (not a full DOM +// parse) so it's O(n) in document size with minimal allocation. +func extractHTMLTokens(src []byte) htmlTokens { + tokens := htmlTokens{ + classes: make(map[string]bool), + ids: make(map[string]bool), + } + + tokenizer := html.NewTokenizer(bytes.NewReader(src)) + for { + tt := tokenizer.Next() + if tt == html.ErrorToken { + break + } + if tt != html.StartTagToken && tt != html.SelfClosingTagToken { + continue + } + + for { + key, val, more := tokenizer.TagAttr() + k := string(key) + if k == "class" { + for _, c := range strings.Fields(string(val)) { + tokens.classes[c] = true + } + } else if k == "id" { + v := strings.TrimSpace(string(val)) + if v != "" { + tokens.ids[v] = true + } + } + if !more { + break + } + } + } + + return tokens +} + +// selectorMatchesTokens returns true if the CSS selector could match +// something in the document based on the extracted tokens. +// +// For simple selectors (.class, #id, .a.b compound), it checks whether +// the referenced classes/IDs exist in the HTML. For complex selectors +// (combinators, attribute selectors, pseudo-classes, tag-only), it +// returns true unconditionally since we can't pre-evaluate them without +// a full DOM and CSS selector engine. +func selectorMatchesTokens(selector string, tokens htmlTokens) bool { + // Complex selectors that we can't pre-filter — always include. + // Combinators: space (descendant), > (child), + (adjacent), ~ (general sibling) + if strings.ContainsAny(selector, " >+~") { + return true + } + // Attribute selectors + if strings.ContainsAny(selector, "[]") { + return true + } + // Pseudo-classes/elements + if strings.Contains(selector, ":") { + return true + } + + // Strip leading tag name (e.g. "div.foo" -> ".foo", "aside#bar" -> "#bar") + // Tags don't have . or # so find the first class/id marker. + rest := selector + if dotIdx := strings.IndexByte(rest, '.'); dotIdx >= 0 { + rest = rest[dotIdx:] + } else if hashIdx := strings.IndexByte(rest, '#'); hashIdx >= 0 { + rest = rest[hashIdx:] + } else { + // Tag-only selector (e.g. "aside") — always include + return true + } + + // ID selector: #foo or tag#foo + if rest[0] == '#' { + id := rest[1:] + return tokens.ids[id] + } + + // Class selector(s): .foo or .foo.bar.baz + // Split on '.' — first element is empty (before the leading dot). + parts := strings.Split(rest, ".") + for _, part := range parts { + if part == "" { + continue + } + if !tokens.classes[part] { + return false + } + } + return true +} + +// filterSelectors returns only the selectors that could match something +// in the given HTML document. This avoids injecting tens of thousands of +// CSS selectors that don't match any element on the page. +func filterSelectors(selectors []string, htmlBody []byte) []string { + tokens := extractHTMLTokens(htmlBody) + var matched []string + for _, sel := range selectors { + if selectorMatchesTokens(sel, tokens) { + matched = append(matched, sel) + } + } + return matched +} diff --git a/css_filter_test.go b/css_filter_test.go new file mode 100644 index 0000000..1cfe066 --- /dev/null +++ b/css_filter_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "testing" +) + +func TestExtractHTMLTokens(t *testing.T) { + html := []byte(` +
Hello
+ Wide +
+ No classes or ids
`)) + if len(tokens.classes) != 0 { + t.Errorf("expected no classes, got %d", len(tokens.classes)) + } + if len(tokens.ids) != 0 { + t.Errorf("expected no ids, got %d", len(tokens.ids)) + } +} + +func TestSelectorMatchesTokens(t *testing.T) { + tokens := htmlTokens{ + classes: map[string]bool{ + "ad-banner": true, + "container": true, + "augl": true, + "fluid": true, + "mt-5": true, + "mb-5": true, + "augl-wide": true, + "sponsored": true, + }, + ids: map[string]bool{ + "slot-668": true, + "page": true, + }, + } + + tests := []struct { + selector string + want bool + desc string + }{ + // Simple class selectors + {".ad-banner", true, "class present"}, + {".nonexistent", false, "class absent"}, + {".augl", true, "class present"}, + + // Simple ID selectors + {"#slot-668", true, "id present"}, + {"#missing-id", false, "id absent"}, + {"#page", true, "id present"}, + + // Compound class selectors (.a.b) + {".mt-5.mb-5", true, "both classes present"}, + {".mt-5.nonexistent", false, "one class missing"}, + {".augl.fluid", true, "both classes present"}, + + // Tag + class + {"div.ad-banner", true, "class present (tag ignored for matching)"}, + {"aside.augl", true, "class present"}, + + // Tag + ID + {"div#page", true, "id present"}, + {"div#missing", false, "id absent"}, + + // Attribute selectors — always included (can't pre-match) + {`div[class^="col-"]`, true, "attribute selector always matches"}, + {`[data-ad]`, true, "attribute selector always matches"}, + {`a[href^="/ads/"]`, true, "attribute selector always matches"}, + + // Complex selectors with combinators — always included + {"div .ad-child", true, "descendant combinator always matches"}, + {"div > .ad-child", true, "child combinator always matches"}, + {"div + .sibling", true, "adjacent sibling always matches"}, + {"div ~ .sibling", true, "general sibling always matches"}, + + // Pseudo-classes — always included + {".foo:has(.bar)", true, "pseudo-class always matches"}, + {":not(.foo)", true, "pseudo-class always matches"}, + + // Tag-only selectors — always included (too broad to pre-filter) + {"aside", true, "tag-only always matches"}, + {"div", true, "tag-only always matches"}, + } + + for _, tt := range tests { + got := selectorMatchesTokens(tt.selector, tokens) + if got != tt.want { + t.Errorf("selectorMatchesTokens(%q) = %v, want %v (%s)", tt.selector, got, tt.want, tt.desc) + } + } +} + +func TestFilterSelectorsAgainstHTML(t *testing.T) { + html := []byte(` + +Content
+ `) + + selectors := []string{ + ".augl", // matches class="augl" + ".nonexistent", // no match + "#slot-668", // matches id="slot-668" + "#missing", // no match + ".mt-5.mb-5", // matches both classes + ".mt-5.missing", // one class missing + ".ad-banner", // no match + `div[data-ad]`, // attribute selector — always included + "div .child", // combinator — always included + } + + filtered := filterSelectors(selectors, html) + + want := map[string]bool{ + ".augl": true, + "#slot-668": true, + ".mt-5.mb-5": true, + `div[data-ad]`: true, + "div .child": true, + } + + if len(filtered) != len(want) { + t.Errorf("filtered %d selectors, want %d: %v", len(filtered), len(want), filtered) + } + for _, sel := range filtered { + if !want[sel] { + t.Errorf("unexpected selector in filtered output: %q", sel) + } + } +} diff --git a/elemhide_inject.go b/elemhide_inject.go index 9a0c1da..5252959 100644 --- a/elemhide_inject.go +++ b/elemhide_inject.go @@ -171,8 +171,12 @@ func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP st modified, strippedCount := stripBlockedResources(body, sc) stats.Stripped = strippedCount - // Merge baseline + user element hiding CSS, applying user #@# exceptions - css, selectors := mergeElementHidingCSS(baselineEH, userEH, userRS, host) + // Merge baseline + user element hiding selectors, then filter to only + // those that match classes/IDs actually present in the HTML. This avoids + // injecting tens of thousands of global selectors that don't apply. + allSelectors := mergeElementHidingSelectors(baselineEH, userEH, userRS, host) + selectors := filterSelectors(allSelectors, modified) + css := buildElementHidingCSS(selectors) if css != "" { safeCSS := styleCloseRe.ReplaceAllString(css, `<\/style`) styleTag := []byte("") @@ -195,10 +199,10 @@ func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP st return modified, stats } -// mergeElementHidingCSS combines element hiding selectors from baseline and -// user RuleSets for a specific domain. User #@# exception rules suppress -// matching baseline ## selectors. Returns empty string if no CSS to inject. -func mergeElementHidingCSS(baseline, user *blocklist.ElementHiding, userRS *blocklist.RuleSet, domain string) (string, []string) { +// mergeElementHidingSelectors collects element hiding selectors from baseline +// and user RuleSets for a specific domain. User #@# exception rules suppress +// matching baseline ## selectors. Returns nil if no selectors apply. +func mergeElementHidingSelectors(baseline, user *blocklist.ElementHiding, userRS *blocklist.RuleSet, domain string) []string { var selectors []string // Add baseline selectors, filtering out any excepted by user #@# rules @@ -216,12 +220,16 @@ func mergeElementHidingCSS(baseline, user *blocklist.ElementHiding, userRS *bloc selectors = append(selectors, user.Selectors...) } + return selectors +} + +// buildElementHidingCSS produces a display:none stylesheet from a list of +// CSS selectors. Returns empty string if the list is empty. +func buildElementHidingCSS(selectors []string) string { if len(selectors) == 0 { - return "", nil + return "" } - - css := strings.Join(selectors, ",\n") + " {\n display: none !important;\n}\n" - return css, selectors + return strings.Join(selectors, ",\n") + " {\n display: none !important;\n}\n" } // injectBeforeClose inserts content before the first found closing tag, diff --git a/inject_test.go b/inject_test.go index fec4380..4bb59c3 100644 --- a/inject_test.go +++ b/inject_test.go @@ -284,7 +284,12 @@ func TestElementHidingStatsCountsSelectors(t *testing.T) { p := &proxyHandler{sessions: newSessionMap()} p.baselineRules.Store(rs) - htmlBody := `Content
` + // HTML contains all three classes so all selectors match + htmlBody := `` + + `` + + `Content
` + + `` + resp := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(htmlBody)), + } + + modified, stats := p.applyElementHiding(resp, "example.com", "127.0.0.1", false) + if !stats.Modified { + t.Fatal("expected modification") + } + // Only 3 selectors match the HTML (ad-banner, augl, #slot-668) + if stats.Hidden != 3 { + t.Errorf("Hidden = %d, want 3", stats.Hidden) + } + + body := string(modified) + // Matching selectors should be in the CSS + if !strings.Contains(body, ".ad-banner") { + t.Error("CSS should contain .ad-banner") + } + if !strings.Contains(body, ".augl") { + t.Error("CSS should contain .augl") + } + if !strings.Contains(body, "#slot-668") { + t.Error("CSS should contain #slot-668") + } + // Non-matching selectors should NOT be in the CSS + if strings.Contains(body, ".tracking-pixel") { + t.Error("CSS should NOT contain .tracking-pixel (not in HTML)") + } + if strings.Contains(body, ".sponsored") { + t.Error("CSS should NOT contain .sponsored (not in HTML)") + } + if strings.Contains(body, ".nonexistent") { + t.Error("CSS should NOT contain .nonexistent (not in HTML)") + } +} diff --git a/proxy_test.go b/proxy_test.go index a9d0974..6cd0023 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -2247,6 +2247,7 @@ func TestStatsHeaderOnModifiedHTML(t *testing.T) { `` + `` + `` + + `Content
` + `