diff --git a/ADBLOCK_SYNTAX.md b/ADBLOCK_SYNTAX.md new file mode 100644 index 0000000..74d1e07 --- /dev/null +++ b/ADBLOCK_SYNTAX.md @@ -0,0 +1,173 @@ +# Adblock Filter Syntax Support + +Reference: [uBlock Origin Static Filter Syntax](https://github.com/gorhill/uBlock/wiki/Static-filter-syntax) + +Implementation: `internal/blocklist/` + +## Network Filter Patterns + +| Feature | Example | Status | Notes | +|---------|---------|--------|-------| +| Literal text | `ad.js` | Supported | `pattern.go` | +| Wildcard `*` | `ad*.js` | Supported | `segWildcard`, consecutive `*` collapsed | +| Separator `^` | `\|\|example.com^` | Supported | `segSeparator`, matches non-alnum except `_-.%` or end of string | +| Start anchor `\|` | `\|https://example.com` | Supported | `anchorStart` flag | +| End anchor `\|` | `example.com/ad\|` | Supported | `anchorEnd` flag | +| Domain anchor `\|\|` | `\|\|example.com^` | Supported | `domainAnchor` flag, O(1) map lookup for hostname-only rules | +| Regex patterns | `/banner\d+/` | Supported | Compiled to `regexp.Regexp`, case-insensitive by default | +| HOSTS file format | `0.0.0.0 example.com` | Supported | Treated as `\|\|hostname^` | +| Exception filters `@@` | `@@\|\|example.com^` | Supported | Layered evaluation | + +## Network Filter Options (`$` modifiers) + +### Resource Type Options + +| Option | Status | Notes | +|--------|--------|-------| +| `$script` | Supported | | +| `$image` | Supported | | +| `$stylesheet` / `$css` | Supported | `$css` is alias for `$stylesheet` | +| `$xmlhttprequest` / `$xhr` | Supported | `$xhr` is alias for `$xmlhttprequest` | +| `$subdocument` / `$frame` | Supported | `$frame` is alias for `$subdocument` | +| `$media` | Supported | | +| `$font` | Supported | | +| `$object` | Supported | | +| `$object-subrequest` | Supported | Legacy alias for `$object` | +| `$websocket` | Supported | | +| `$document` / `$doc` | Supported | `$doc` is alias for `$document` | +| `$ping` | Supported | Maps to `ResourcePing` | +| `$other` | Supported | | +| `$popup` | Parsed, not enforced | Not enforceable at proxy level | +| `$popunder` | Not implemented | Not enforceable at proxy level | +| `$all` | Supported | Equivalent to all network-based types + `$popup` + `$document` + `$inline-font` + `$inline-script` | +| Negated types (`$~script`) | Supported | `ExcludeTypes` bitmask | +| Multiple types (`$script,image`) | Supported | | + +### Party / Scope Options + +| Option | Status | Notes | +|--------|--------|-------| +| `$third-party` / `$3p` | Supported | `$3p` is alias | +| `$~third-party` / `$first-party` / `$1p` | Supported | `$1p` and `$first-party` are aliases | +| `$strict1p` | Not implemented | MV2-only, hostname-exact match | +| `$strict3p` | Not implemented | MV2-only, hostname-exact match | +| `$domain=` / `$from=` | Supported | Multiple domains, `\|` separated, `~` negation. `$from=` is alias | +| Entity matching in `$domain=` | Supported | e.g. `$domain=google.*` | +| Regex values in `$domain=` | Not implemented | e.g. `$domain=/regex/` | +| `$to=` | Supported | Include/exclude with `~` negation, entity matching | +| `$denyallow=` | Supported | Exempt request destinations from blocking | + +### Behavioral / Priority Options + +| Option | Status | Notes | +|--------|--------|-------| +| `$match-case` | Supported | Case-sensitive matching | +| `$important` | Supported | Bypasses exception filters | +| `$badfilter` | Supported | Disables matching rules via normalized target, lazy evaluation | +| `$method=` | Supported | Bitmask-based, include/exclude/negation | +| `$header=` | Supported | Block by response header presence/value/regex, negation with `~` | +| `$cname` | Not implemented | Firefox MV2-only | +| `$ipaddress=` | Not implemented | Firefox MV2-only | + +### Modifier / Redirect Options + +| Option | Status | Notes | +|--------|--------|-------| +| `$csp=` | Supported | Injects `Content-Security-Policy` response header. Exceptions with `@@...$csp` (blanket) or `@@...$csp=value` (specific) | +| `$permissions=` | Supported | Injects `Permissions-Policy` response header. `\|` separator converted to `, ` internally | +| `$removeparam=` | Supported | Strips query parameters (literal or `/regex/`). `$removeparam` without value strips all params | +| `$redirect=` | Supported | 19 neutered resources (GIF, PNG, JS, CSS, HTML, JSON, TXT, MP3, MP4, VAST/VMAP XML, empty/none) with aliases. Exception handling (blanket + specific) | +| `$redirect-rule=` | Supported | Creates redirect directive only (no blocking rule) | +| `$empty` | Supported | Deprecated alias for `$redirect=empty` | +| `$mp4` | Supported | Deprecated alias for `$redirect=noopmp4-1s,$media` | +| `$replace=` | Not implemented | Trusted-source only | +| `$uritransform=` | Not implemented | Trusted-source only | +| `$urlskip=` | Not implemented | Trusted-source only | +| `$rewrite=` | Silently ignored | | + +### Cosmetic Filtering Control Options + +| Option | Status | Notes | +|--------|--------|-------| +| `$elemhide` / `$ehide` | Supported | Disables all cosmetic filtering on matching pages | +| `$generichide` / `$ghide` | Supported | Disables generic (non-domain-specific) cosmetic selectors | +| `$specifichide` / `$shide` | Supported | Disables domain-specific cosmetic selectors | +| `$genericblock` | Not supported | Per uBO spec, not supported | + +### Noop / Placeholder + +| Option | Status | Notes | +|--------|--------|-------| +| `_` (noop) | Supported | Placeholder for readability and regex disambiguation | + +## Extended Filtering — Cosmetic Filters + +| Feature | Status | Notes | +|---------|--------|-------| +| Basic element hiding `##selector` | Supported | CSS injection with `display: none !important` | +| Element hiding exceptions `#@#selector` | Supported | | +| Domain-scoped (`example.com##.ad`) | Supported | Include/exclude with `~` negation | +| Generic selectors (`##.ad-class`) | Supported | Pre-filtered by HTML class/ID token extraction | +| Entity matching (`google.*##.ad`) | Supported | Via `domainMatchesOrIsSubdomain` | +| Specific-generic (`*##.selector`) | Not implemented | Unconditional injection | +| Hostname regex (`/regex/##.ad`) | Not implemented | | + +### Procedural Cosmetic Filters + +All procedural operators are **not implemented**. Lines containing `#?#` are explicitly skipped. + +- `:has()`, `:has-text()`, `:matches-attr()`, `:matches-css()`, `:matches-css-before()`, `:matches-css-after()`, `:matches-media()`, `:matches-path()`, `:matches-prop()`, `:min-text-length()`, `:not()` (extended), `:others()`, `:upward()`, `:watch-attr()`, `:xpath()` + +### Action Operators + +All action operators are **not implemented**. + +- `:style()`, `:remove()`, `:remove-attr()`, `:remove-class()` + +## HTML Filters + +| Feature | Status | Notes | +|---------|--------|-------| +| `##^selector` (response-level) | Not implemented | | +| `##^responseheader()` | Not implemented | | +| `##^script:has-text()` | Not implemented | | + +Note: ublproxy has its own resource stripping that removes `` sanitization +- **Parse error visibility**: `OnWarning` callback and `ParseErrors()` counter for malformed rules diff --git a/DECISIONS.md b/DECISIONS.md index aaf05c0..a540342 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -93,3 +93,6 @@ - 2026-03-02 m+git@andri.dk — TLS handshake failure circuit breaker for automatic cert-pin detection. When a host accumulates 3 TLS handshake failures within 10 minutes, the proxy auto-switches it to passthrough (no MITM) for 1 hour. After the TTL expires, a single failure re-trips the breaker immediately (`prevTripped` flag) to avoid repeated breakage. `RecordSuccess` clears all state when a MITM handshake succeeds, proving the host is not pinned. Portal host and IPs are excluded — they can never be auto-passthrough'd. Events are logged at warn level and recorded in the activity feed as `auto-passthrough`. Works in both explicit and transparent proxy modes. No CLI flags; all thresholds hardcoded. In-memory only; resets on restart. - 2026-03-02 m+git@andri.dk — Added Windows binaries (amd64, arm64) to the release workflow. No source code changes needed — all Go code, dependencies, and the pure-Go SQLite driver are cross-platform. Windows archives use `.zip` instead of `.tar.gz`. CI tests now run on `windows-latest` alongside `ubuntu-latest`. Transparent proxy mode requires platform-specific firewall configuration (outside ublproxy's scope) but the proxy itself runs identically. - 2026-03-02 m+git@andri.dk — SEO and Open Graph improvements for landing page. Added `og:image` (1200x630 PNG matching the CRT/terminal aesthetic), `og:site_name`, Twitter Card meta tags, canonical URL, SVG favicon, and JSON-LD `SoftwareApplication` structured data. OG image source is `web/og-image.html` — a standalone HTML file screenshotted via Playwright to `web/og-image.png`. Keeps the image regenerable from source. +- 2026-03-06 m+git@andri.dk — Dropped `!#include` pre-parsing directive from scope. Published filter lists (EasyList, EasyPrivacy, uBlock Filters) are pre-compiled — `!#include` references are resolved before distribution. No real-world need at the proxy level. +- 2026-03-06 m+git@andri.dk — Scriptlet injection (`##+js()`) via `" +} + // injectBeforeClose inserts content before the first found closing tag, // or appends if none is found. Tags are tried in order. func injectBeforeClose(htmlDoc, content []byte, tags ...[]byte) []byte { diff --git a/http.go b/http.go index 5a5c907..3c9dfc4 100644 --- a/http.go +++ b/http.go @@ -54,9 +54,25 @@ func (p *proxyHandler) handleHTTP(w http.ResponseWriter, r *http.Request) { ctx := matchContextFromRequest(r) clientIP := clientIPFromRequest(r) credID := p.credentialForIP(clientIP) + + // Apply $removeparam rules to strip tracking query parameters + targetURL := p.applyRemoveParams(clientIP, r.URL.String(), ctx) + if targetURL != r.URL.String() { + parsed, err := url.Parse(targetURL) + if err == nil { + r.URL = parsed + } + } + if p.shouldBlock(clientIP, r.URL.String(), ctx) { p.logActivity(ActivityBlocked, r.URL.Hostname(), r.URL.String(), "", clientIP, credID) logBlocked(r.URL.Hostname(), r.URL.String(), "", clientIP, credID) + // Serve neutered resource if a $redirect directive matches + if name, ok := p.matchRedirect(clientIP, r.URL.String(), ctx); ok { + if serveRedirectResource(w, name) { + return + } + } w.WriteHeader(http.StatusNoContent) return } @@ -87,6 +103,18 @@ func (p *proxyHandler) handleHTTP(w http.ResponseWriter, r *http.Request) { } defer resp.Body.Close() + // Block based on response headers ($header= rules) + if p.shouldBlockByHeader(clientIP, r.URL.String(), ctx, resp.Header) { + resp.Body.Close() + p.logActivity(ActivityBlocked, r.URL.Hostname(), r.URL.String(), "$header", clientIP, credID) + logBlocked(r.URL.Hostname(), r.URL.String(), "$header", clientIP, credID) + w.WriteHeader(http.StatusNoContent) + return + } + + // Inject $csp and $permissions response headers + p.applyModifierHeaders(resp, clientIP, r.URL.String(), ctx) + // Replace ad elements in HTML responses (skip HEAD — no body to modify). // If the proxy connection is plain HTTP (r.TLS == nil), skip the // bootstrap script injection to avoid leaking the session token. @@ -189,6 +217,7 @@ func (p *proxyHandler) handleHTTPUpgrade(w http.ResponseWriter, r *http.Request) func matchContextFromRequest(req *http.Request) blocklist.MatchContext { ctx := blocklist.MatchContext{ ResourceType: blocklist.InferResourceType(req), + Method: req.Method, } referer := req.Header.Get("Referer") if referer == "" { diff --git a/inject_test.go b/inject_test.go index 4bb59c3..6e712dd 100644 --- a/inject_test.go +++ b/inject_test.go @@ -446,3 +446,161 @@ func TestElementHidingFiltersUnmatchedSelectors(t *testing.T) { t.Error("CSS should NOT contain .nonexistent (not in HTML)") } } + +func TestScriptletInjection(t *testing.T) { + rs := blocklist.NewRuleSet() + rs.AddLine("example.com##+js(nowebrtc)") + rs.AddLine("example.com##+js(set-constant, ads.enabled, true)") + + p := &proxyHandler{sessions: newSessionMap()} + p.baselineRules.Store(rs) + + htmlBody := `Test

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 for scriptlet injection") + } + + body := string(modified) + + // Both scriptlets should be injected as which must be escaped + rs.AddLine(`example.com##+js(set-constant, x, true)`) + + p := &proxyHandler{sessions: newSessionMap()} + p.baselineRules.Store(rs) + + htmlBody := `

Content

` + resp := &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"text/html"}}, + Body: io.NopCloser(strings.NewReader(htmlBody)), + } + + modified, _ := p.applyElementHiding(resp, "example.com", "127.0.0.1", false) + body := string(modified) + + // Must not contain raw inside the scriptlet injection + // (the closing of the wrapper tag is OK, but not inside the JS) + scriptStart := strings.Index(body, "") + if scriptStart >= 0 && scriptEnd > scriptStart { + jsContent := body[scriptStart+len("") + } + } +} + +func TestScriptletInjectionWithCSSRules(t *testing.T) { + rs := blocklist.NewRuleSet() + rs.AddLine("example.com##+js(nowebrtc)") + rs.AddLine("##.ad-banner") + + p := &proxyHandler{sessions: newSessionMap()} + p.baselineRules.Store(rs) + + htmlBody := `
Ad

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") + } + + body := string(modified) + + // Both CSS and scriptlet should be present + if !strings.Contains(body, "