diff --git a/DECISIONS.md b/DECISIONS.md index 0300929..24c6105 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,7 +1,7 @@ # Decisions - 2026-02-25 m+git@andri.dk — Validate upstream TLS certificates (removed `InsecureSkipVerify`). The proxy is the user's trust boundary; it must verify upstream server identity. -- 2026-02-25 m+git@andri.dk — Don't manipulate `Accept-Encoding`. The proxy forwards the client's encoding preferences to upstream and handles CSS injection based on the response `Content-Encoding`. Gzip HTML is decompressed for injection; brotli/zstd HTML passes through without injection. Non-HTML resources are never touched. +- 2026-02-25 m+git@andri.dk — Don't manipulate `Accept-Encoding`. The proxy forwards the client's encoding preferences to upstream and handles CSS injection based on the response `Content-Encoding`. Gzip and brotli HTML are decompressed for injection; other encodings pass through without injection. Non-HTML resources are never touched. - 2026-02-25 m+git@andri.dk — Skip CSS injection for HEAD requests. HEAD responses have no body per HTTP spec. - 2026-02-25 m+git@andri.dk — Hostname-only rules with `$options` (e.g. `||host^$third-party`) bypass the fast-path hostname map and go through compiled rules to preserve option evaluation. - 2026-02-25 m+git@andri.dk — `$match-case` rules receive both the original-case and lowercased URL so they match correctly through the optimized `ShouldBlockRequest` path. @@ -9,6 +9,6 @@ - 2026-02-25 m+git@andri.dk — Literal-skip pattern matching: use `strings.Index` to jump to candidate positions for leading literals and post-wildcard literals, avoiding byte-by-byte scanning. - 2026-02-25 m+git@andri.dk — Cache `CSSForDomain` results in `sync.Map` since the ruleset is immutable after loading. - 2026-02-25 m+git@andri.dk — Serve decompressed HTML to client after CSS injection (no re-compression). Proxy-to-client hop is typically localhost. -- 2026-02-25 m+git@andri.dk — Go stdlib only. No third-party dependencies unless strongly warranted. +- 2026-02-25 m+git@andri.dk — Added `github.com/andybalholm/brotli` (pure Go, zero transitive runtime deps) for brotli decompression in CSS injection. First third-party dependency — warranted because brotli is the dominant encoding for HTML on modern CDNs and the stdlib has no brotli support. - 2026-02-25 m+git@andri.dk — WebSocket upgrade supported for both ws:// and wss://. Upgrade headers are re-added after hop-by-hop stripping, then bidirectional copy bridges client and upstream after 101. - 2026-02-25 m+git@andri.dk — Cert cache has no eviction. Certs are generated with 24h validity. Acceptable for personal use. diff --git a/README.md b/README.md index aa73fe0..0c23932 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A proxy-server, that is capable of filtering ads from HTTPS/TLS traffic, using a ### Known Limitations -- **Element hiding on brotli/zstd HTML**: CSS injection for element hiding only works when the upstream serves gzip-encoded or uncompressed HTML. Brotli/zstd HTML passes through without element hiding CSS. +- **Element hiding on zstd HTML**: CSS injection for element hiding works with gzip, brotli, and uncompressed HTML. Zstd-encoded HTML passes through without element hiding CSS. - **No re-compression**: After decompressing gzip for CSS injection, HTML is served uncompressed to the client. This is fine when the proxy runs on localhost. - **Cert cache**: Generated TLS certificates are cached indefinitely with no eviction. Certificates have 24-hour validity but expired entries are never cleaned up. Fine for personal use. diff --git a/elemhide_inject.go b/elemhide_inject.go index ca57978..ead981d 100644 --- a/elemhide_inject.go +++ b/elemhide_inject.go @@ -6,12 +6,14 @@ import ( "io" "net/http" "strings" + + "github.com/andybalholm/brotli" ) // 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. -// Handles gzip-compressed responses transparently. +// Handles gzip and brotli compressed responses transparently. func (p *proxyHandler) injectElementHidingCSS(resp *http.Response, host string) ([]byte, bool) { if p.rules == nil { return nil, false @@ -27,23 +29,24 @@ func (p *proxyHandler) injectElementHidingCSS(resp *http.Response, host string) return nil, false } - // Only decompress gzip — bail on other encodings (e.g. brotli) to avoid - // corrupting compressed bytes we can't decode var body []byte var err error encoding := resp.Header.Get("Content-Encoding") - if encoding != "" && !strings.Contains(encoding, "gzip") { - return nil, false - } - if strings.Contains(encoding, "gzip") { + switch { + case strings.Contains(encoding, "gzip"): gr, gzErr := gzip.NewReader(resp.Body) if gzErr != nil { return nil, false } body, err = io.ReadAll(gr) gr.Close() - } else { + case strings.Contains(encoding, "br"): + body, err = io.ReadAll(brotli.NewReader(resp.Body)) + case encoding == "": body, err = io.ReadAll(resp.Body) + default: + // Unknown encoding (e.g. zstd) — pass through unmodified + return nil, false } if err != nil { return nil, false diff --git a/go.mod b/go.mod index 8c65a36..d2dbc13 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module ublproxy go 1.25.0 + +require github.com/andybalholm/brotli v1.2.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d78948e --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +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= diff --git a/proxy_test.go b/proxy_test.go index b692059..4488a01 100644 --- a/proxy_test.go +++ b/proxy_test.go @@ -20,6 +20,8 @@ import ( "sync/atomic" "testing" + "github.com/andybalholm/brotli" + "ublproxy/pkg/blocklist" ) @@ -746,30 +748,27 @@ func TestNonHTMLPreservesCompression(t *testing.T) { } } -func TestElementHidingNonGzipPassesThrough(t *testing.T) { +func TestElementHidingBrotli(t *testing.T) { rs := blocklist.NewRuleSet() rs.AddLine("##.ad-banner") - // Simulate a server that responds with brotli-encoded HTML. The proxy - // cannot decompress brotli, so it must pass the response through - // unmodified rather than corrupting it. htmlBody := `Hello` - fakeCompressed := []byte{0x1b, 0x2f, 0x00, 0xf0} // not real brotli, just binary garbage - fakeCompressed = append(fakeCompressed, []byte(htmlBody)...) upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + bw := brotli.NewWriter(&buf) + bw.Write([]byte(htmlBody)) + bw.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Encoding", "br") w.WriteHeader(http.StatusOK) - w.Write(fakeCompressed) + w.Write(buf.Bytes()) }) env := startTestEnv(t, upstream, rs) client := env.httpClient(t) - // Disable automatic decompression so we can inspect raw bytes - client.Transport.(*http.Transport).DisableCompression = true - resp, err := client.Get(env.httpURL + "/page.html") if err != nil { t.Fatalf("GET: %v", err) @@ -777,15 +776,13 @@ func TestElementHidingNonGzipPassesThrough(t *testing.T) { defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) - // The response must be passed through exactly as the upstream sent it. - // Before the fix, the proxy would try to inject CSS into the compressed - // bytes, corrupting the response. - if len(body) != len(fakeCompressed) { - t.Errorf("body length = %d, want %d (response was modified)", len(body), len(fakeCompressed)) + if !strings.Contains(bodyStr, "