From 9b54a1dd7f5d2495740b65127cbaec6f88e0af51 Mon Sep 17 00:00:00 2001 From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:58:34 -0500 Subject: [PATCH 1/4] userscripting --- CHANGELOG.md | 173 ++ privaxy/src/resources/userscript_shim.js | 888 ++++++++++ privaxy/src/server/configuration/filter.rs | 2 +- privaxy/src/server/configuration/mod.rs | 152 ++ privaxy/src/server/configuration/updater.rs | 36 +- .../src/server/configuration/userscript.rs | 1520 +++++++++++++++++ privaxy/src/server/lib.rs | 157 +- privaxy/src/server/proxy/gm/endpoint.rs | 447 +++++ privaxy/src/server/proxy/gm/fetch.rs | 486 ++++++ privaxy/src/server/proxy/gm/mod.rs | 24 + privaxy/src/server/proxy/gm/storage.rs | 363 ++++ privaxy/src/server/proxy/gm/token.rs | 123 ++ privaxy/src/server/proxy/html_rewriter.rs | 260 ++- privaxy/src/server/proxy/mitm.rs | 4 + privaxy/src/server/proxy/mod.rs | 2 + privaxy/src/server/proxy/serve.rs | 53 +- privaxy/src/server/proxy/userscripts.rs | 247 +++ privaxy/src/server/web_gui/mod.rs | 24 + privaxy/src/server/web_gui/userscripts.rs | 594 +++++++ web_frontend/src/main.rs | 2 + web_frontend/src/settings.rs | 9 + web_frontend/src/userscript_edit.rs | 280 +++ web_frontend/src/userscripts.rs | 757 ++++++++ 23 files changed, 6573 insertions(+), 30 deletions(-) create mode 100644 privaxy/src/resources/userscript_shim.js create mode 100644 privaxy/src/server/configuration/userscript.rs create mode 100644 privaxy/src/server/proxy/gm/endpoint.rs create mode 100644 privaxy/src/server/proxy/gm/fetch.rs create mode 100644 privaxy/src/server/proxy/gm/mod.rs create mode 100644 privaxy/src/server/proxy/gm/storage.rs create mode 100644 privaxy/src/server/proxy/gm/token.rs create mode 100644 privaxy/src/server/proxy/userscripts.rs create mode 100644 privaxy/src/server/web_gui/userscripts.rs create mode 100644 web_frontend/src/userscript_edit.rs create mode 100644 web_frontend/src/userscripts.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b6906..6a508e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,179 @@ ## Unreleased +- Userscript engine: Greasemonkey/Tampermonkey-style scripts injected into + matching pages, managed at runtime from the web UI. + - New **Settings → Userscripts** page: install a script by pasting it or by + URL (e.g. from Greasyfork), toggle scripts individually, edit a script's + source in place, and uninstall it. A master switch disables the whole + engine without clearing the per-script selection. Each entry shows its + version, `@run-at`, match patterns, grant count and `@noframes` state, and + a script whose stored body no longer parses is called out as not being + injected instead of failing silently. + - New `[userscripts]` configuration section holding the master `enabled` flag + and one `[[userscripts.scripts]]` entry per script. As with filter lists, + only metadata is stored in the configuration file; bodies live under + `userscripts/` in the configuration directory (override with + `PRIVAXY_USERSCRIPT_PATH`), keyed by a hash of the source URL so + re-installing the same script reuses its file. Configurations written + before this release parse unchanged and default to an enabled engine with + no scripts. + - Scripts are matched on the same canonical URL the adblock engine uses, + supporting `@match` (Chrome match-pattern syntax including `` and + `*.host` wildcards), `@include`/`@exclude` (globs, or regular expressions + written `/…/`), `@exclude-match`, `@run-at` + (`document-start`/`-body`/`-end`/`-idle`) and `@noframes`. Exclusions take + precedence over inclusions. A script declaring neither `@match` nor + `@include` — or with a malformed pattern or no metadata block — is rejected + with a `422` at install time rather than being stored and never firing. + - The in-page runtime provides `GM_info`, `unsafeWindow`, `GM_addStyle` + (nonce-stamped so it survives the page's CSP), `GM_log`, `GM_openInTab`, + `GM_setClipboard`, `GM_notification`, `GM_registerMenuCommand` and the + promise-based `GM.*` namespace. + - `@require` libraries and `@resource` payloads are fetched server-side and + cached on disk under `userscripts/assets/`, keyed by URL hash. Requires are + evaluated inside the script's own wrapper ahead of its body, so their + top-level declarations are visible to the script without leaking into the + page, and `GM_getResourceText`/`GM_getResourceURL` read the fetched + resources. Cached assets are never re-fetched (the convention is to pin a + versioned URL); delete the `assets` directory to refresh. An asset that + cannot be fetched degrades its script rather than dropping it, and the + failure is reported on the Userscripts page. + - `GM_setValue`/`GM_getValue`/`GM_deleteValue`/`GM_listValues` are persisted, + scoped per script, in `userscripts/gm_storage.json` (writes are coalesced + on a short debounce and written atomically; the configuration file is + deliberately not used, since these are written far too often to + re-serialize it each time). Because `GM_getValue` is synchronous in the GM + API, values are preloaded into each script's descriptor at injection time + and read from that snapshot in-page, so an ordinary `GM_getValue` costs no + request. Uninstalling a script drops its values. + - `GM_xmlhttpRequest` is relayed server-side, so it has no CORS restrictions + — the one capability a real content script cannot have. Three independent + controls gate it: the origin-bound token, the requesting script's own + `@connect` declarations (as Tampermonkey requires, so compatibility is + unaffected), and a filter rejecting loopback, RFC1918, carrier-grade NAT, + link-local (including the cloud metadata address) and IPv4-mapped + equivalents. Redirects are followed manually so `@connect` and address + filtering re-run on every hop rather than letting an allow-listed host + bounce the request to `127.0.0.1`. Methods are restricted, `Host` and + hop-by-hop request headers cannot be set, and responses are size-capped. + - `@resource` payloads may be binary. They are stored as bytes with the + content type they were served as; text small enough to matter is inlined + into the script's descriptor so `GM_getResourceText` stays synchronous, + while anything binary or oversized is reachable through + `GM_getResourceURL`, served from the reserved path with its original bytes, + content type and `X-Content-Type-Options: nosniff`. `GM_getResourceText` on + a binary resource returns `null` and logs a note pointing at + `GM_getResourceURL` rather than returning mojibake. + - `GM_addValueChangeListener`/`GM_removeValueChangeListener` are implemented. + Changes fire locally, reach other same-origin tabs over `BroadcastChannel` + (free, no request), and reach other origins and other devices behind the + same proxy by polling a read endpoint — which only runs while at least one + listener is registered, so a page with no listeners issues no extra + requests. The read endpoint requires the requesting URL to satisfy the + script's own `@match`/`@include`, so it can never reveal more than the + page's own descriptor already contained. + - `GM_registerMenuCommand` now has a real surface: a small floating menu, + injected only once a script actually registers a command, hosted in a closed + shadow root so neither the page's CSS nor Privaxy's can reach across. + Commands are still reachable from the console via + `__privaxyUserscriptMenu()`. + - Userscripts can be disabled for a single tab from that menu, backed by + `sessionStorage` — per-tab by construction, so the proxy needs no notion of + a tab. Other tabs are unaffected; `__privaxyUserscriptsEnableTab()` restores + them, since disabling removes the menu that turned them off. + - Userscripts installed from a URL are re-fetched on the same 24h timer as the + filter lists and recompiled in place, so upstream changes are picked up + without a restart. The refresh re-reads the configuration from disk rather + than reusing the updater's own copy: userscript changes deliberately bypass + that channel, so its copy is stale with respect to them and recompiling from + it would drop every script installed since startup. + - `@updateURL` and `@downloadURL` are honored, both defaulting to the URL the + script was installed from. When they differ, only the (small) `@updateURL` + document is fetched to compare `@version`, and the body is downloaded solely + when that version is newer — versions are ordered as dotted numbers, so + `1.2.10` correctly supersedes `1.2.9`, falling back to plain inequality for + schemes that cannot be ordered. Scripts that split metadata and body, a + common Greasyfork layout, previously re-downloaded the whole body on every + cycle. + - A **Check for updates** button on the Userscripts page refreshes on demand + instead of waiting out the timer, reporting per script whether it was + updated, already current, or failed and why. Unlike the periodic refresh it + holds the save lock, so a changed `@name` or `@version` is persisted. + - New `userscripts.allow_private_network_requests` setting (default off, with + a toggle on the Userscripts page) permits the relay to reach private + addresses. It is off by default because the relay runs server-side: the + proxy usually sits *inside* a LAN and can reach routers, admin panels and + metadata endpoints no page could contact. Changes apply immediately, with + no reload. + - Changes apply to the next page load with no reload or restart: compiled + scripts live in a shared store that each API mutation replaces in place, + and the store is also refreshed on `SIGHUP` so a hand-edited + `[userscripts]` section takes effect. Each script is emitted in its own + `nonce`d script element, so a syntax error in one script cannot abandon the + others or the ad-blocking payload, and the CSP nonce is kept in a closure + rather than published on `window`. + - Note that a userscript installed here runs on **every client behind the + proxy**, in the page's main world (a proxy has no isolated world to offer), + which is a wider blast radius than a browser extension installed in one + profile. The Userscripts page says so where scripts are added. + - **Not supported.** A userscript engine built into a proxy cannot reach full + Tampermonkey parity, and some of what is missing is structural rather than + unfinished. Known gaps, so a script that misbehaves can be diagnosed instead + of guessed at: + - *No isolated world, and there cannot be one.* Scripts run in the page's + main world, so `unsafeWindow === window`, page scripts can read and clobber + anything a userscript leaves reachable, and anti-adblock can detect the + injection. Each script is still wrapped in its own function, so its `var`, + `let`, `const`, `function` and `class` declarations do not leak to the page + — only an undeclared assignment or an explicit `window.x = …` does. + - *Pages the proxy never sees get nothing.* A site with an active service + worker serving navigations from cache, `file://`, `chrome://`, browser-cache + hits and any traffic not routed through Privaxy are all invisible to it, so + no script runs there. A browser extension sees all of them. + - *`@grant` is parsed and displayed but not enforced.* Every script receives + every implemented API regardless of what it declared, including + `@grant none`. This is deliberately forgiving — a script that forgot to + declare a grant still works — but it is a deviation. + - *Not implemented:* `GM_cookie` (the proxy has no cookie jar for the + browser's cookies; it only sees `Cookie` headers in flight), + `GM_getTab`/`GM_saveTab`/`GM_getTabs` (no tab identity exists on the proxy + side), `GM_download` (it would mean the proxy writing files to its own disk + on a page's behalf), `GM_addElement`, the batch `GM_setValues`/`GM_getValues` + forms, and `window.onurlchange`. + - *`GM_notification` writes to the console* rather than raising a real + notification, and `GM_setClipboard` needs a user gesture like any page-context + clipboard write. + - *Metadata ignored:* `@sandbox`, `@unwrap`, `@top-level-await`, `@icon`, + `@supportURL`, `@antifeature`. Unknown directives are skipped, not rejected. + - *`@resource` payloads are byte-exact but not inlined as text when binary or + over 256 KB* — `GM_getResourceText` returns `null` for those and the data is + reachable only through `GM_getResourceURL`. + - *`GM_xmlhttpRequest` is not a transparent `XMLHttpRequest`.* Responses are + decoded as lossy UTF-8, so binary bodies are unusable; `abort()` only + suppresses the callbacks, since the server-side request is already in + flight; there is no `onprogress`/`onreadystatechange`; redirects are capped + at 5 hops, timeouts at 60s (default 30s) and responses at 8 MB; and the + request only reaches hosts the script declared with `@connect`. + - *Value-change notification is not instant across origins.* Same-origin tabs + are updated immediately over `BroadcastChannel`; a change made on a + different origin or another device is picked up by a 15s poll, and only + while a listener is registered. + - *Per-tab disable is per-tab **per origin*** — it is `sessionStorage`, so + disabling on one site does not disable on another in the same tab. + - *Storage limits:* 1000 keys per script and 64 KB per value; a script body + or fetched `@require`/`@resource` is capped at 2 MB. Writes are flushed on a + 500 ms debounce, so values set immediately before a crash can be lost. + - *No script ordering or import/export in the UI.* Injection order is + configuration order, changeable only by editing the file. +- A configuration file that fails to parse no longer takes the server down. + `read_configuration` unwrapped the parse error, so a hand-edited file with (for + example) a duplicate TOML key panicked a worker on `SIGHUP` and killed both the + proxy and web-UI loops while the process kept running — both ports stopped + listening with no way back except a restart. The last configuration that parsed + is now kept and reused, so a reload over a broken file logs the error and + carries on serving with the previous settings, then picks up the corrected file + on the next reload. The CA reload in the same path no longer unwraps either. - The PAC route now also answers at `/wpad.dat`, so DNS-based WPAD auto-discovery (`http://wpad./wpad.dat`) can point straight at Privaxy without needing a rewrite in a fronting reverse proxy. diff --git a/privaxy/src/resources/userscript_shim.js b/privaxy/src/resources/userscript_shim.js new file mode 100644 index 0000000..cc93e4d --- /dev/null +++ b/privaxy/src/resources/userscript_shim.js @@ -0,0 +1,888 @@ +// Privaxy userscript runtime. +// +// Injected into the document head ahead of the userscripts themselves. +// `PRIVAXY_NONCE` is supplied by the enclosing IIFE and deliberately stays in +// this closure: exposing the CSP nonce on `window` would let page scripts +// bypass the page's own Content-Security-Policy. +// +// Avoid writing the literal sequence `<` + `script` anywhere in this file: it +// is served inside an inline script element, and HTML5's double-escaped +// script-data states can change where the browser thinks that element ends. +// +// Userscripts run in the page's main world — a proxy has no isolated world to +// offer — so `unsafeWindow` is simply `window`. + +// The element this runtime is executing in, captured before anything can move +// it. Its text is blanked once startup finishes so later page scripts cannot +// read PRIVAXY_ENDPOINT_TOKEN out of the DOM. +var privaxyRuntimeElement = document.currentScript; + +// GM storage. Values arrive preloaded in each script's descriptor, because +// GM_getValue is synchronous in the GM API and cannot wait on a request. Reads +// therefore hit this in-memory snapshot; writes update it and are persisted +// asynchronously through the reserved same-origin endpoint. +var privaxyValueStores = Object.create(null); +var privaxyMenuCommands = []; + +// Listeners registered via GM_addValueChangeListener, keyed by script id. +var privaxyValueListeners = Object.create(null); +var privaxyListenerCount = 0; +var privaxyPollTimer = null; + +// Same-origin cross-tab notification, which costs nothing and covers the common +// case of two tabs on one site. Cross-origin and cross-device changes arrive via +// the poll below instead. +var privaxyValueChannel = null; +try { + privaxyValueChannel = new BroadcastChannel('privaxy-userscript-values'); +} catch (error) { + // Not available in this context; local and polled notification still work. +} + +// Per-tab disable. sessionStorage is scoped to one tab by construction, so this +// needs no notion of a tab on the proxy side. Scoped per origin, which is the +// same granularity userscripts themselves run at. +var PRIVAXY_TAB_DISABLED_KEY = '__privaxy_userscripts_disabled__'; + +function privaxyTabDisabled() { + try { + return window.sessionStorage.getItem(PRIVAXY_TAB_DISABLED_KEY) === '1'; + } catch (error) { + // Storage can be blocked outright; treat that as "not disabled". + return false; + } +} + +function privaxySetTabDisabled(disabled) { + try { + if (disabled) { + window.sessionStorage.setItem(PRIVAXY_TAB_DISABLED_KEY, '1'); + } else { + window.sessionStorage.removeItem(PRIVAXY_TAB_DISABLED_KEY); + } + } catch (error) { + console.error('[privaxy userscript] unable to record the per-tab setting', error); + } +} + +function privaxyValueStore(scriptId, preloaded) { + if (!privaxyValueStores[scriptId]) { + var store = Object.create(null); + if (preloaded) { + Object.keys(preloaded).forEach(function (key) { + store[key] = preloaded[key]; + }); + } + privaxyValueStores[scriptId] = store; + } + + return privaxyValueStores[scriptId]; +} + +// Pending writes are batched per tick: a script that calls GM_setValue in a +// scroll handler produces one request per frame rather than one per call. +var privaxyPendingWrites = Object.create(null); +var privaxyFlushScheduled = false; + +/// Deliver a change to this page's listeners for `scriptId`. +function privaxyNotifyListeners(scriptId, key, oldValue, newValue, remote) { + var listeners = privaxyValueListeners[scriptId]; + if (!listeners) { + return; + } + + Object.keys(listeners).forEach(function (id) { + try { + listeners[id](key, oldValue, newValue, remote); + } catch (error) { + console.error('[privaxy userscript] a value change listener threw', error); + } + }); +} + +if (privaxyValueChannel) { + privaxyValueChannel.onmessage = function (event) { + var change = event && event.data; + if (!change || !change.script) { + return; + } + + // Keep this tab's snapshot in step with the writing tab before firing, + // so a listener reading GM_getValue sees the new value. + var store = privaxyValueStores[change.script]; + if (store) { + if (change.newValue === null || change.newValue === undefined) { + delete store[change.key]; + } else { + store[change.key] = change.newValue; + } + } + + privaxyNotifyListeners(change.script, change.key, change.oldValue, change.newValue, true); + }; +} + +function privaxySchedulePersist(scriptId, key, value) { + if (!PRIVAXY_ENDPOINT_TOKEN) { + // No token means no derivable page origin; values stay in memory for + // the life of the page. + return; + } + + if (!privaxyPendingWrites[scriptId]) { + privaxyPendingWrites[scriptId] = Object.create(null); + } + // `null` is the deletion signal understood by the endpoint. + privaxyPendingWrites[scriptId][key] = value === undefined ? null : value; + + if (privaxyFlushScheduled) { + return; + } + privaxyFlushScheduled = true; + + Promise.resolve().then(function () { + privaxyFlushScheduled = false; + var pending = privaxyPendingWrites; + privaxyPendingWrites = Object.create(null); + + Object.keys(pending).forEach(function (id) { + fetch('/__privaxy__/gm/values', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // Same-origin by construction: the proxy answers this path on + // whatever origin the page is on. + credentials: 'omit', + body: JSON.stringify({ + token: PRIVAXY_ENDPOINT_TOKEN, + script: id, + values: pending[id] + }) + }) + .then(function (response) { + if (!response.ok) { + console.error( + '[privaxy userscript] failed to persist values (HTTP ' + + response.status + ')' + ); + } + }) + .catch(function (error) { + console.error('[privaxy userscript] failed to persist values', error); + }); + }); + }); +} + +function privaxyBroadcastChange(scriptId, key, oldValue, newValue) { + if (!privaxyValueChannel) { + return; + } + + try { + privaxyValueChannel.postMessage({ + script: scriptId, + key: key, + oldValue: oldValue === undefined ? null : oldValue, + newValue: newValue === undefined ? null : newValue + }); + } catch (error) { + // A value that cannot be structured-cloned still reached this tab's + // listeners and the server; only cross-tab delivery is lost. + console.warn('[privaxy userscript] value change not broadcast to other tabs', error); + } +} + +// Interval between polls once at least one listener exists. Long enough to be +// negligible, short enough that a cross-device change lands while the page is +// still open. +var PRIVAXY_POLL_INTERVAL_MS = 15000; + +/// Poll the reserved endpoint for values changed elsewhere (another origin, or +/// another device behind the same proxy). BroadcastChannel already covers +/// same-origin tabs, so this is the fallback rather than the primary path. +function privaxyStartPolling() { + if (privaxyPollTimer !== null || !PRIVAXY_ENDPOINT_TOKEN) { + return; + } + + privaxyPollTimer = setInterval(function () { + Object.keys(privaxyValueListeners).forEach(function (scriptId) { + var listeners = privaxyValueListeners[scriptId]; + if (!listeners || Object.keys(listeners).length === 0) { + return; + } + + fetch('/__privaxy__/gm/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'omit', + body: JSON.stringify({ token: PRIVAXY_ENDPOINT_TOKEN, script: scriptId }) + }) + .then(function (response) { + return response.ok ? response.json() : null; + }) + .then(function (payload) { + if (!payload || !payload.values) { + return; + } + privaxyReconcile(scriptId, payload.values); + }) + .catch(function () { + // A failed poll is not worth reporting on every tick. + }); + }); + }, PRIVAXY_POLL_INTERVAL_MS); +} + +/// Apply a server snapshot over this page's store, firing listeners for keys +/// that actually differ. +function privaxyReconcile(scriptId, remoteValues) { + var store = privaxyValueStores[scriptId]; + if (!store) { + return; + } + + var seen = Object.create(null); + + Object.keys(remoteValues).forEach(function (key) { + seen[key] = true; + var previous = store[key]; + var next = remoteValues[key]; + + // Compared by serialization: values are JSON round-tripped through the + // server anyway, so this is the same notion of equality the store has. + if (JSON.stringify(previous) !== JSON.stringify(next)) { + store[key] = next; + privaxyNotifyListeners(scriptId, key, previous, next, true); + } + }); + + Object.keys(store).forEach(function (key) { + if (!seen[key]) { + var previous = store[key]; + delete store[key]; + privaxyNotifyListeners(scriptId, key, previous, undefined, true); + } + }); +} + +function privaxyLogPrefix(info) { + return '[privaxy userscript: ' + info.name + ']'; +} + +function privaxyBuildApi(info) { + var values = privaxyValueStore(info.id, info.values); + + function setValue(key, value) { + var name = String(key); + var previous = values[name]; + values[name] = value; + privaxySchedulePersist(info.id, name, value); + privaxyNotifyListeners(info.id, name, previous, value, false); + privaxyBroadcastChange(info.id, name, previous, value); + } + + function getValue(key, fallback) { + var name = String(key); + + return Object.prototype.hasOwnProperty.call(values, name) + ? values[name] + : fallback; + } + + function deleteValue(key) { + var name = String(key); + var previous = values[name]; + delete values[name]; + privaxySchedulePersist(info.id, name, null); + privaxyNotifyListeners(info.id, name, previous, undefined, false); + privaxyBroadcastChange(info.id, name, previous, null); + } + + function addValueChangeListener(key, callback) { + var name = String(key); + if (!privaxyValueListeners[info.id]) { + privaxyValueListeners[info.id] = Object.create(null); + } + + var id = 'listener-' + privaxyListenerCount++; + privaxyValueListeners[info.id][id] = function (changedKey, oldValue, newValue, remote) { + if (changedKey === name) { + callback(changedKey, oldValue, newValue, remote); + } + }; + + // Polling only starts once something is actually listening, so a page + // with no listeners makes no extra requests at all. + privaxyStartPolling(); + + return id; + } + + function removeValueChangeListener(id) { + var listeners = privaxyValueListeners[info.id]; + if (listeners) { + delete listeners[id]; + } + } + + function listValues() { + return Object.keys(values); + } + + function addStyle(css) { + var style = document.createElement('style'); + + // The proxy augments the page's CSP with a nonce rather than stripping + // it, so an injected "# ); diff --git a/privaxy/src/server/proxy/serve.rs b/privaxy/src/server/proxy/serve.rs index 573f0b9..5b69aaf 100644 --- a/privaxy/src/server/proxy/serve.rs +++ b/privaxy/src/server/proxy/serve.rs @@ -515,6 +515,20 @@ pub(crate) async fn serve( .get_cosmetic_response(match_url.clone(), Vec::new(), Vec::new()) .await; + // Whether the end-of-body lookup will produce a non-empty cosmetic + // `