diff --git a/DECISIONS.md b/DECISIONS.md index a7f95db..012a4c9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -36,4 +36,5 @@ - 2026-02-26 m+git@andri.dk — Element picker UI uses Shadow DOM (closed mode) to isolate styles from the host page. All picker CSS lives inside the shadow root, preventing conflicts in both directions. The highlight overlay uses `position:fixed` with `pointer-events:none` and `document.elementFromPoint()` for hover detection. - 2026-02-26 m+git@andri.dk — CSS selector generation priority: `#id` > `.class` (if unique) > `tag.class` > `tag[attr]` > parent path with `nth-child`. Prefers short selectors that match adblock element hiding syntax. Uses `CSS.escape()` for safety with special characters in IDs and class names. - 2026-02-26 m+git@andri.dk — Rule hot-reload via `atomic.Pointer[blocklist.RuleSet]`. When a user creates, deletes, or toggles a rule via the API, the full RuleSet is rebuilt from static blocklists + all enabled database rules, then atomically swapped. Readers never block. Rebuilds are serialized with a mutex to prevent concurrent rebuilds. The API handler triggers reloads via a callback (`onRulesChanged`) fired asynchronously in a goroutine so the HTTP response isn't delayed. +- 2026-02-26 m+git@andri.dk — CORS: reflect the `Origin` request header instead of using a hardcoded portal origin. Injected scripts run on arbitrary proxied pages (e.g. `https://example.com`) and make cross-origin requests to the portal API. All API endpoints require a Bearer token (not cookies), so reflecting the origin doesn't weaken security — an attacker without the token can't use the API regardless of CORS policy. `Vary: Origin` header included for correct caching. - 2026-02-26 m+git@andri.dk — Element hiding is now CSS-only (`display: none !important`). Previously, simple selectors also stripped matched elements from the DOM via HTML tokenizer rewriting. This caused blank pages when generic EasyList selectors (e.g. `##[data-ad-cls]`, `##.ad-banner`) matched direct children of `` that were legitimate page wrappers. CSS-only hiding matches browser adblocker behavior (uBlock Origin uses CSS for element hiding). Src-based stripping of `script`/`iframe`/`object`/`embed` with blocked URLs is kept — these load external resources and stripping them prevents the browser from even attempting the request. Deleted `SelectorMatch`, `ClassifySelector`, `matchesAny`, `matchesAnyWithAttrs` and related dead code. diff --git a/README.md b/README.md index 0c23932..9865347 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,92 @@ # ublproxy -A proxy-server, that is capable of filtering ads from HTTPS/TLS traffic, using adblock rules. +A proxy-server that filters ads from HTTPS/TLS traffic using adblock rules, with interactive element picking for custom rules. -### Known Limitations +## Quick start + +```bash +# Build +go build -o ublproxy . + +# Run with EasyList +ublproxy --blocklist https://easylist.to/easylist/easylist.txt +``` + +The proxy listens on `http://127.0.0.1:8080`. Configure your browser or OS to use it as an HTTP proxy. + +An HTTPS portal runs on `https://127.0.0.1:8443` for account registration and rule management. + +### CA certificate + +The proxy generates a CA certificate to intercept HTTPS traffic. Visit `http://127.0.0.1:8080/` in your browser (while using the proxy) to download and install the CA certificate. Your browser/OS must trust this certificate for HTTPS filtering to work. + +## Custom rules + +ublproxy supports interactive element picking. You can visually select ad elements on any webpage and create blocking rules that persist across sessions. + +### 1. Register an account + +Visit `https://127.0.0.1:8443/` in your browser and register a passkey. No username or password needed — the passkey **is** your identity. Anyone on the local network can register. + +Registration uses WebAuthn, so you need a browser that supports passkeys (all modern browsers do). The portal uses HTTPS with a certificate signed by the proxy's own CA, so you must have installed the CA certificate first. + +### 2. Pick elements to block + +After registering, browse any website through the proxy and press **Alt+Shift+B** to open the element picker. The picker overlay appears in the top-right corner. + +1. **Hover** over an element — a red highlight shows what will be selected +2. **Click** to select it — the generated CSS selector appears in the panel +3. **Block element** saves the rule and immediately hides the element +4. **Escape** to deselect, or close the picker entirely + +Rules are stored in SQLite and take effect on the next page load (or immediately on the current page via injected CSS). + +### 3. Manage rules via the API + +Rules can also be managed programmatically. All endpoints require a Bearer token from the login flow. + +```bash +# List your rules +curl -s https://127.0.0.1:8443/api/rules \ + -H "Authorization: Bearer " + +# Create a rule +curl -s https://127.0.0.1:8443/api/rules \ + -X POST -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"rule": "example.com##.ad-banner", "domain": "example.com"}' + +# Disable a rule +curl -s https://127.0.0.1:8443/api/rules/1 \ + -X PATCH -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"enabled": false}' + +# Delete a rule +curl -s https://127.0.0.1:8443/api/rules/1 \ + -X DELETE -H "Authorization: Bearer " +``` + +Rules follow [adblock filter syntax](https://adblockplus.org/filter-cheatsheet). Element hiding rules use `domain##selector` format (e.g. `example.com##.ad-banner`). + +## CLI flags + +| Flag | Default | Description | +|------|---------|-------------| +| `-addr` | `127.0.0.1` | Address to listen on | +| `-port` | `8080` | HTTP proxy port | +| `-portal-port` | `8443` | HTTPS portal port | +| `-ca-dir` | `~/.ublproxy` | Directory for CA certificate and key | +| `-db` | `~/.ublproxy/ublproxy.db` | Path to SQLite database | +| `-blocklist` | (none) | Path or URL to a blocklist file (repeatable) | + +## Known limitations - **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. +- **Session-to-IP mapping**: Sessions are bound to client IP. Multiple users behind the same NAT IP share a single session slot (last login wins). -### References +## References - [adblock rules cheatsheet](https://adblockplus.org/filter-cheatsheet) diff --git a/api.go b/api.go index 44ffdd3..a0b342e 100644 --- a/api.go +++ b/api.go @@ -52,11 +52,20 @@ func newAPIHandler(s *store.Store, cfg webauthn.Config, sm *sessionMap) *apiHand } func (a *apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // CORS headers for cross-origin requests from injected scripts - w.Header().Set("Access-Control-Allow-Origin", a.webauthnCfg.RPOrigin) + // CORS: reflect the requesting origin. Injected scripts run on arbitrary + // proxied pages (e.g. https://example.com) and make cross-origin requests + // to the portal (e.g. https://127.0.0.1:8443). All state-changing endpoints + // require a Bearer token, so reflecting the origin is safe — the token is + // never auto-sent by the browser. + origin := r.Header.Get("Origin") + if origin == "" { + origin = a.webauthnCfg.RPOrigin + } + w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Vary", "Origin") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) diff --git a/api_test.go b/api_test.go index a90b124..1712da6 100644 --- a/api_test.go +++ b/api_test.go @@ -275,6 +275,27 @@ func TestCORSPreflight(t *testing.T) { if got := rec.Header().Get("Access-Control-Allow-Methods"); got == "" { t.Error("missing Access-Control-Allow-Methods header") } + // Without Origin header, falls back to portal origin + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != testAPIConfig.RPOrigin { + t.Errorf("CORS origin without Origin header = %q, want %q", got, testAPIConfig.RPOrigin) + } +} + +func TestCORSReflectsRequestOrigin(t *testing.T) { + api := testAPI(t) + + // Preflight from a proxied page origin + req := httptest.NewRequest("OPTIONS", "/api/rules", nil) + req.Header.Set("Origin", "https://example.com") + rec := httptest.NewRecorder() + api.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" { + t.Errorf("CORS origin = %q, want %q", got, "https://example.com") + } + if got := rec.Header().Get("Vary"); !strings.Contains(got, "Origin") { + t.Errorf("Vary header = %q, should contain Origin", got) + } } func TestPickerJSRequiresAuth(t *testing.T) {