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 ` 0 || baseline.RuleCount() > 0)) ||
(userRS != nil && (userRS.HostCount() > 0 || userRS.RuleCount() > 0))
+ // Collect scriptlet rules for this domain from both rulesets
+ scriptletTag := buildScriptletTag(
+ baseline.ScriptletsForDomain(host),
+ userRS.ScriptletsForDomain(host),
+ )
+
// Generate bootstrap script tag (empty string if no session).
// Skip on insecure (plain HTTP) connections to avoid leaking the
// session token over unencrypted traffic.
@@ -124,7 +130,7 @@ func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP st
}
// Nothing to do if there are no rules AND no script to inject
- if baselineEH == nil && userEH == nil && !hasURLRules && scriptTag == "" {
+ if baselineEH == nil && userEH == nil && !hasURLRules && scriptTag == "" && scriptletTag == "" {
return nil, elementHidingStats{}
}
@@ -171,10 +177,15 @@ func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP st
modified, strippedCount := stripBlockedResources(body, sc)
stats.Stripped = strippedCount
+ // Check cosmetic filter exceptions ($elemhide, $generichide, $specifichide)
+ pageURL := "https://" + host + "/"
+ cosmeticExc := baseline.CosmeticFilterExceptions(pageURL) |
+ userRS.CosmeticFilterExceptions(pageURL)
+
// 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)
+ allSelectors := mergeElementHidingSelectors(baselineEH, userEH, userRS, host, cosmeticExc)
selectors := filterSelectors(allSelectors, modified)
css := buildElementHidingCSS(selectors)
if css != "" {
@@ -187,6 +198,11 @@ func (p *proxyHandler) applyElementHiding(resp *http.Response, host, clientIP st
logElementHidden(host, rule, clientIP, credID)
}
+ // Inject scriptlets before for earliest execution
+ if scriptletTag != "" {
+ modified = injectBeforeClose(modified, []byte(scriptletTag), []byte(""), []byte("