From f74f44a6ece2c40d5f8a14b45ac1e51b1bf8b576 Mon Sep 17 00:00:00 2001 From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Wed, 27 May 2026 06:58:00 -0500 Subject: [PATCH 01/12] add mipsbe (#26) * add mips be * release ci * tag * ci * v4 * ci --- .github/workflows/ci.yml | 64 ++++++++++++++++++++++++++++++++++------ Cross.toml | 10 +++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa966e3..b81c2ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,13 @@ on: push: branches: - main + - develop + tags: + - 'v*' pull_request: branches: - - main + - main + - develop concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -23,7 +27,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: @@ -57,7 +61,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v1 + uses: actions/checkout@v6 - name: Install rust uses: dtolnay/rust-toolchain@stable @@ -81,9 +85,11 @@ jobs: run: | rel="${GITHUB_REF#refs/*/}" if grep -qE '^v?\d+\.\d+\.\d+' <<< "$rel" ; then - tag="$rel" - elif [ "$rel" = "main" ]; then + tag="${rel#v}" + elif [ "$rel" = "develop" ]; then tag="dev" + elif [ "$rel" = "main" ]; then + tag="latest" else tag="$(echo "$GITHUB_SHA" | cut -c1-7)" fi @@ -112,7 +118,7 @@ jobs: cache_scope: docker-arm64 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download prebuilt binary uses: actions/download-artifact@v4 @@ -221,7 +227,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install rust uses: dtolnay/rust-toolchain@stable @@ -307,7 +313,7 @@ jobs: target: aarch64-unknown-linux-musl steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Cache build artifacts uses: actions/cache@v3 @@ -386,9 +392,15 @@ jobs: - build: linux os: ubuntu-22.04 target: mipsel-unknown-linux-musl + - build: linux + os: ubuntu-22.04 + target: mips-unknown-linux-musl + - build: linux + os: ubuntu-22.04 + target: mips-unknown-linux-gnu steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Cache build artifacts uses: actions/cache@v3 @@ -444,3 +456,37 @@ jobs: name: privaxy-${{ matrix.target }} path: | target/${{ matrix.target }}/release/privaxy + + release: + name: Create GitHub Release + needs: [ci, ci_mips, image_merge] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./artifacts + pattern: privaxy-* + merge-multiple: false + - name: Set package version from tag + run: | + echo "PKG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + - name: Flatten artifacts for release + run: | + mkdir ./release-assets + for dir in ./artifacts/privaxy-*/; do + target=$(basename "$dir" | sed 's/^privaxy-//') + cp "$dir/privaxy" "./release-assets/privaxy-${PKG_VERSION}-${target}" + done + find ./artifacts -name '*.deb' -exec cp {} ./release-assets/ \; + find ./artifacts -name '*.rpm' -exec cp {} ./release-assets/ \;e '*.rpm' -exec cp {} ./release-assets/ \; + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: ./release-assets/* + generate_release_notes: true + fail_on_unmatched_files: true \ No newline at end of file diff --git a/Cross.toml b/Cross.toml index 034b5e7..e01eb01 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,5 +1,15 @@ [target.mipsel-unknown-linux-gnu] build-std = true +[target.mips-unknown-linux-gnu] +build-std = true + [target.mipsel-unknown-linux-musl] build-std = true +[target.mipsel-unknown-linux-musl.env] +RUSTFLAGS = "-C target-feature=+crt-static" + +[target.mips-unknown-linux-musl] +build-std = true +[target.mips-unknown-linux-musl.env] +RUSTFLAGS = "-C target-feature=+crt-static" From 57adb746933b72321318384ff3ca822dc207d3c4 Mon Sep 17 00:00:00 2001 From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Fri, 29 May 2026 10:33:12 -0500 Subject: [PATCH 02/12] fix: correct adblock request typing, trim report-only CSP, and package MIPS debs/rpms (#27) * request type from Sec-Fetch-Dest; capped block log line; report-only CSP no longer augmented * request_type threaded into the engine check * mips * format * fix host blocking * keep filters on reload --- .github/workflows/ci.yml | 9 +- privaxy/Cargo.toml | 4 + privaxy/src/server/blocker.rs | 9 +- privaxy/src/server/configuration/filter.rs | 2 +- privaxy/src/server/proxy/serve.rs | 100 +++++++++++++++++++-- 5 files changed, 111 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b81c2ff..96af753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -434,19 +434,20 @@ jobs: run: cross build --release --target ${{ matrix.target }} --bin privaxy --target-dir target - name: Build packages - if: matrix.target == 'mipsel-unknown-linux-gnu' + if: ${{ endsWith(matrix.target, '-linux-gnu') }} run: | - cargo install cargo-deb && cargo deb --no-build --no-strip --variant mipsel -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release + variant=$(echo "${{ matrix.target }}" | cut -d- -f1) + cargo install cargo-deb && cargo deb --no-build --no-strip --variant "$variant" -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release cargo install cargo-generate-rpm && cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release - uses: actions/upload-artifact@v4 - if: matrix.target == 'mipsel-unknown-linux-gnu' + if: ${{ endsWith(matrix.target, '-linux-gnu') }} with: name: privaxy-deb-${{ matrix.target }} path: target/${{ matrix.target }}/release/privaxy_*.deb - uses: actions/upload-artifact@v4 - if: matrix.target == 'mipsel-unknown-linux-gnu' + if: ${{ endsWith(matrix.target, '-linux-gnu') }} with: name: privaxy-rpm-${{ matrix.target }} path: target/${{ matrix.target }}/release/*.rpm diff --git a/privaxy/Cargo.toml b/privaxy/Cargo.toml index 1676b85..e45f850 100644 --- a/privaxy/Cargo.toml +++ b/privaxy/Cargo.toml @@ -22,6 +22,10 @@ assets = [ name = "privaxy" depends = "libc6, libgcc-s1, libatomic1" +[package.metadata.deb.variants.mips] +name = "privaxy" +depends = "libc6, libgcc-s1, libatomic1" + [package.metadata.generate-rpm] license = "AGPL-3.0-or-later" summary = "Next generation tracker and advertisement blocker" diff --git a/privaxy/src/server/blocker.rs b/privaxy/src/server/blocker.rs index 5dff895..f8a4c56 100644 --- a/privaxy/src/server/blocker.rs +++ b/privaxy/src/server/blocker.rs @@ -37,6 +37,11 @@ pub struct CosmeticRequest { pub struct NetworkUrl { url: String, referer: String, + // adblock-rust request type string (e.g. "script", "xmlhttprequest", + // "image", "sub_frame", "document"). Required for the engine to honour + // type-scoped filter and exception rules ($script, $xhr, $image, …); + // passing a constant here silently defeats those rules. + request_type: String, } #[derive(Debug)] @@ -204,7 +209,7 @@ impl Blocker { let req = Request::new( network_url.url.as_str(), network_url.referer.as_str(), - "other", + network_url.request_type.as_str(), ) .unwrap(); let blocker_result = self.engine.check_network_request(&req); @@ -284,6 +289,7 @@ impl AdblockRequester { &self, network_url: String, referer: String, + request_type: String, ) -> (bool, adblock::blocker::BlockerResult) { let (sender, receiver) = oneshot::channel(); @@ -293,6 +299,7 @@ impl AdblockRequester { kind: RequestKind::Url(NetworkUrl { url: network_url, referer, + request_type, }), }) .unwrap(); diff --git a/privaxy/src/server/configuration/filter.rs b/privaxy/src/server/configuration/filter.rs index 3519922..26f310e 100644 --- a/privaxy/src/server/configuration/filter.rs +++ b/privaxy/src/server/configuration/filter.rs @@ -355,7 +355,7 @@ pub(crate) async fn get_filters_content( } } - filters.append(&mut configuration.custom_filters); + filters.extend(configuration.custom_filters.iter().cloned()); filters.sort_unstable(); // Filter out duplicate lines, if present filters.dedup(); diff --git a/privaxy/src/server/proxy/serve.rs b/privaxy/src/server/proxy/serve.rs index 1f62022..53d4fa8 100644 --- a/privaxy/src/server/proxy/serve.rs +++ b/privaxy/src/server/proxy/serve.rs @@ -13,9 +13,14 @@ use hyper_rustls::HttpsConnector; use std::net::IpAddr; use tokio::sync::broadcast; -const CSP_HEADERS: [&str; 4] = [ +// Only *enforcing* CSP headers are augmented. Report-only headers +// (`content-security-policy-report-only`) are deliberately left untouched: +// they never block our injected script/style, so augmenting them buys nothing, +// and doing so would inject our nonce into the site's own violation telemetry +// and suppress reports the site author relies on (e.g. while testing a strict +// policy before enforcing it). +const CSP_HEADERS: [&str; 3] = [ "content-security-policy", - "content-security-policy-report-only", "x-content-security-policy", "x-webkit-csp", ]; @@ -178,6 +183,66 @@ fn augment_csp_value(value: &str, nonce: &str) -> String { .join("; ") } +/// Map an outgoing request's headers to the adblock-rust request-type string +/// the engine expects (the same vocabulary as uBO's `$type` options). Without +/// an accurate type, type-scoped filter and exception rules — e.g. the +/// `$script`/`$xmlhttprequest` exceptions in uBO's unbreak lists that keep +/// sites like DuckDuckGo working — match incorrectly and cause false blocks. +/// +/// Modern browsers send `Sec-Fetch-Dest`, which maps cleanly onto these types. +/// When it's absent we fall back to sniffing `Accept`, and finally to `other`. +fn request_type_from_headers(headers: &HeaderMap) -> &'static str { + if let Some(dest) = headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) { + return match dest { + "document" => "document", + "frame" | "iframe" => "sub_frame", + "script" | "serviceworker" | "sharedworker" | "worker" | "audioworklet" + | "paintworklet" => "script", + "style" => "stylesheet", + "image" => "image", + "font" => "font", + "audio" | "video" | "track" => "media", + "object" | "embed" => "object", + "report" => "ping", + // `empty` is what fetch()/XHR report; treat it as xhr. + "empty" | "" => "xmlhttprequest", + _ => "other", + }; + } + + match headers + .get(http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + { + Some(accept) if accept.contains("text/html") => "document", + Some(accept) if accept.contains("text/css") => "stylesheet", + Some(accept) if accept.contains("image/") => "image", + Some(accept) if accept.contains("javascript") || accept.contains("ecmascript") => "script", + _ => "other", + } +} + +/// adblock-rust matches against the literal URL string, so an explicit default +/// port (`:443` for https, `:80` for http) wedges itself between the host and the +/// path and breaks hostname-anchored rules (`||host/path`) — the path no longer +/// follows the host directly. Browsers and uBO match on the canonical URL with +/// the default port stripped, so we normalise the same way before handing URLs to +/// the blocker/cosmetic engine. Non-default ports are preserved. +fn url_for_matching(uri: &Uri) -> String { + let scheme = uri.scheme_str().unwrap_or("https"); + let host = uri.host().unwrap_or(""); + let path = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); + let default_port = match scheme { + "https" => Some(443), + "http" => Some(80), + _ => None, + }; + match uri.port_u16() { + Some(port) if Some(port) != default_port => format!("{scheme}://{host}:{port}{path}"), + _ => format!("{scheme}://{host}{path}"), + } +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn serve( adblock_requester: AdblockRequester, @@ -222,15 +287,23 @@ pub(crate) async fn serve( statistics.increment_top_clients(client_ip_address); + let request_type = request_type_from_headers(req.headers()).to_string(); + + // Canonical URL (default port stripped) for all adblock-engine matching — + // see `url_for_matching`. The raw `uri` (which may carry `:443`) is still + // used for the actual outbound request below. + let match_url = url_for_matching(&uri); + let (is_request_blocked, blocker_result) = adblock_requester .is_network_url_blocked( - uri.to_string(), + match_url.clone(), match req.headers().get(http::header::REFERER) { Some(referer) => referer.to_str().unwrap().to_string(), // When no referer, we default to `uri` as we otherwise may get many false // positives due to the blocker thinking it's third party requests. - None => uri.to_string(), + None => match_url.clone(), }, + request_type.clone(), ) .await; @@ -250,7 +323,20 @@ pub(crate) async fn serve( uri.path() )); - log::debug!("Blocked request: {}", uri); + // adblock-rust fuses many same-option patterns into one filter and + // reports the union as the matched filter, which can be enormous; cap + // it so debug logs stay readable. + let matched_filter = blocker_result.filter.as_deref().unwrap_or(""); + let matched_filter = match matched_filter.char_indices().nth(200) { + Some((idx, _)) => format!("{}… (truncated)", &matched_filter[..idx]), + None => matched_filter.to_string(), + }; + log::debug!( + "Blocked request: {} [type={}] matched filter: {}", + uri, + request_type, + matched_filter + ); return Ok(get_blocked_by_privaxy_response(blocker_result)); } @@ -341,12 +427,12 @@ pub(crate) async fn serve( // end-of-body cosmetic lookup still runs for hide/style selectors, // which depend on collected IDs/classes. let injected_script = adblock_requester - .get_cosmetic_response(uri.to_string(), Vec::new(), Vec::new()) + .get_cosmetic_response(match_url.clone(), Vec::new(), Vec::new()) .await .injected_script; let rewriter = Rewriter::new( - uri.to_string(), + match_url.clone(), adblock_requester, receiver_rewriter, sender, From 046829de3634fa2e65d6dda3bd0e2c0e4cf8723a Mon Sep 17 00:00:00 2001 From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Fri, 29 May 2026 11:19:57 -0500 Subject: [PATCH 03/12] fix: cookie not invalidated (#28) * fix auth * fix blocking button * bump to 0.7.1 --- Cargo.lock | 2 +- privaxy/Cargo.toml | 2 +- privaxy/src/server/web_gui/auth/routes.rs | 26 +++++++++++++++++-- .../src/server/web_gui/blocking_enabled.rs | 2 +- web_frontend/src/blocking_enabled.rs | 6 +---- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae6e34a..14db423 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2037,7 +2037,7 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "privaxy" -version = "0.7.0" +version = "0.7.1" dependencies = [ "adblock", "argon2", diff --git a/privaxy/Cargo.toml b/privaxy/Cargo.toml index e45f850..d183e25 100644 --- a/privaxy/Cargo.toml +++ b/privaxy/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "privaxy" description = "Next generation tracker and advertisement blocker" -version = "0.7.0" +version = "0.7.1" edition = "2021" authors = [ "Pierre Barre ", diff --git a/privaxy/src/server/web_gui/auth/routes.rs b/privaxy/src/server/web_gui/auth/routes.rs index 53dbb3d..7a67247 100644 --- a/privaxy/src/server/web_gui/auth/routes.rs +++ b/privaxy/src/server/web_gui/auth/routes.rs @@ -156,11 +156,33 @@ async fn get_status( (false, None) }; - Ok(Box::new(warp::reply::json(&AuthStatusResponse { + let body = serde_json::to_string(&AuthStatusResponse { authenticated, setup_required, username, - }))) + }) + .unwrap(); + let mut response = Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json"); + + // If the caller presented a session cookie that did not authenticate them + // (expired, signed with a rotated key, or otherwise stale), clear it from + // the browser. + if !authenticated && has_invalid_session_cookie(&configuration, &cookie) { + response = response.header("Set-Cookie", build_logout_cookie(configuration.network.tls)); + } + + Ok(Box::new(response.body(body).unwrap())) +} + +/// Returns true when a `privaxy_session` cookie is present but fails +/// verification against the current signing key. +fn has_invalid_session_cookie(configuration: &Configuration, cookie: &Option) -> bool { + match cookie.as_deref().and_then(extract_session_cookie) { + Some(token) => verify(&token, &configuration.auth.session_signing_key).is_err(), + None => false, + } } async fn post_setup( diff --git a/privaxy/src/server/web_gui/blocking_enabled.rs b/privaxy/src/server/web_gui/blocking_enabled.rs index 1533593..a211bbe 100644 --- a/privaxy/src/server/web_gui/blocking_enabled.rs +++ b/privaxy/src/server/web_gui/blocking_enabled.rs @@ -18,7 +18,7 @@ pub async fn put_blocking_enabled( blocking_enabled: BlockingEnabled, blocking_disabled_store: BlockingDisabledStore, ) -> Result { - blocking_disabled_store.set(!blocking_enabled.0); + blocking_disabled_store.set(blocking_enabled.0); Ok(StatusCode::NO_CONTENT) } diff --git a/web_frontend/src/blocking_enabled.rs b/web_frontend/src/blocking_enabled.rs index e6dc81b..6818a53 100644 --- a/web_frontend/src/blocking_enabled.rs +++ b/web_frontend/src/blocking_enabled.rs @@ -50,12 +50,8 @@ impl Component for BlockingEnabled { match request.send().await { Ok(response) => { if response.ok() { - message_callback.emit(Message::BlockingEnabled); - - return; + message_callback.emit(Message::BlockingEnabled) } - - message_callback.emit(Message::BlockingDisabled) } Err(_) => message_callback.emit(Message::BlockingDisabled), } From f0ba5512641556ee13213124bbe4f72106f888ff Mon Sep 17 00:00:00 2001 From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Sat, 30 May 2026 07:49:44 -0500 Subject: [PATCH 04/12] doh (#30) * doh * fmt --- privaxy/src/server/configuration/mod.rs | 1 + privaxy/src/server/configuration/network.rs | 44 +++++ privaxy/src/server/lib.rs | 3 + privaxy/src/server/proxy/mitm.rs | 8 +- privaxy/src/server/proxy/mod.rs | 1 + privaxy/src/server/proxy/serve.rs | 31 +++- .../src/server/web_gui/settings/network.rs | 9 +- web_frontend/src/general.rs | 153 ++++++++++++++++++ 8 files changed, 247 insertions(+), 3 deletions(-) diff --git a/privaxy/src/server/configuration/mod.rs b/privaxy/src/server/configuration/mod.rs index aaeb3b9..1327417 100644 --- a/privaxy/src/server/configuration/mod.rs +++ b/privaxy/src/server/configuration/mod.rs @@ -296,6 +296,7 @@ impl Configuration { pac_direct_ips: Vec::new(), pac_direct_cidrs: std::collections::BTreeMap::new(), pac_direct_fqdns: Vec::new(), + doh: DohConfig::default(), }, exclusions: BTreeSet::from_iter( recommended_exclusions() diff --git a/privaxy/src/server/configuration/network.rs b/privaxy/src/server/configuration/network.rs index 28f9884..41334e2 100644 --- a/privaxy/src/server/configuration/network.rs +++ b/privaxy/src/server/configuration/network.rs @@ -66,6 +66,50 @@ pub struct NetworkConfig { /// FQDNs (and their subdomains) that bypass the proxy. #[serde(default)] pub pac_direct_fqdns: Vec, + /// DNS-over-HTTPS interception policy. See [`DohConfig`]. + #[serde(default)] + pub doh: DohConfig, +} + +/// What the proxy does with DNS-over-HTTPS (DoH) requests it intercepts. +/// +/// Privaxy filters at the HTTP layer rather than at DNS resolution time, so DoH +/// does not bypass blocking the way it bypasses a DNS-level filter — the actual +/// request to a blocked host is still matched downstream regardless of how it +/// was resolved. These modes instead let the operator steer how clients resolve +/// names in the first place. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DohMode { + /// Leave DoH requests untouched. + Off, + /// Refuse DoH requests so the client's DoH attempt fails. Clients in + /// fallback mode (e.g. default Firefox) then revert to the system resolver, + /// whose lookups flow through Privaxy normally. This is the default: it is + /// the HTTP-layer equivalent of the Firefox `use-application-dns.net` canary + /// (which a non-DNS proxy cannot serve directly). + #[default] + Block, + /// Transparently forward the opaque DoH query to [`DohConfig::upstream`] + /// instead of the resolver the client chose. + Redirect, +} + +/// DNS-over-HTTPS interception configuration. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct DohConfig { + /// How intercepted DoH requests are handled. + #[serde(default)] + pub mode: DohMode, + /// Upstream DoH resolver URL used when `mode = "redirect"` + /// (e.g. `https://dns.quad9.net/dns-query`). A redirect with no usable + /// upstream fails safe by blocking instead of forwarding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + /// Additional DoH endpoint hostnames to recognise, on top of the built-in + /// list of well-known resolvers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_hosts: Vec, } #[derive(Error, Debug)] diff --git a/privaxy/src/server/lib.rs b/privaxy/src/server/lib.rs index 2130235..1e0ceed 100644 --- a/privaxy/src/server/lib.rs +++ b/privaxy/src/server/lib.rs @@ -338,6 +338,7 @@ async fn privaxy_backend( .build(); let config = read_configuration(&configuration_save_lock).await; let network_config = &config.network; + let doh_config = network_config.doh.clone(); // The hyper client is only used to perform upgrades. We don't need to // handle compression. @@ -355,6 +356,7 @@ async fn privaxy_backend( let broadcast_tx = broadcast_tx.clone(); let statistics = statistics.clone(); let local_exclusion_store = local_exclusion_store.clone(); + let doh_config = doh_config.clone(); async move { Ok::<_, Infallible>(service_fn(move |req| { @@ -368,6 +370,7 @@ async fn privaxy_backend( statistics.clone(), client_ip_address, local_exclusion_store.clone(), + doh_config.clone(), ) })) } diff --git a/privaxy/src/server/proxy/mitm.rs b/privaxy/src/server/proxy/mitm.rs index 5a22e79..da9e12c 100644 --- a/privaxy/src/server/proxy/mitm.rs +++ b/privaxy/src/server/proxy/mitm.rs @@ -1,5 +1,8 @@ use super::{exclusions::LocalExclusionStore, serve::serve}; -use crate::{blocker::AdblockRequester, cert::CertCache, statistics::Statistics, Event}; +use crate::{ + blocker::AdblockRequester, cert::CertCache, configuration::DohConfig, statistics::Statistics, + Event, +}; use http::uri::{Authority, Scheme}; use hyper::{ client::HttpConnector, http, server::conn::Http, service::service_fn, upgrade::Upgraded, Body, @@ -21,6 +24,7 @@ pub(crate) async fn serve_mitm_session( statistics: Statistics, client_ip_address: IpAddr, local_exclusion_store: LocalExclusionStore, + doh_config: DohConfig, ) -> Result, hyper::Error> { let authority = match req.uri().authority().cloned() { Some(authority) => authority, @@ -79,6 +83,7 @@ pub(crate) async fn serve_mitm_session( broadcast_tx.clone(), statistics.clone(), client_ip_address, + doh_config.clone(), ) }), ) @@ -114,6 +119,7 @@ pub(crate) async fn serve_mitm_session( broadcast_tx, statistics, client_ip_address, + doh_config, ) .await } diff --git a/privaxy/src/server/proxy/mod.rs b/privaxy/src/server/proxy/mod.rs index c4cc095..99e2ec3 100644 --- a/privaxy/src/server/proxy/mod.rs +++ b/privaxy/src/server/proxy/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod doh; pub(crate) mod mitm; pub(crate) mod serve; pub(crate) use mitm::serve_mitm_session; diff --git a/privaxy/src/server/proxy/serve.rs b/privaxy/src/server/proxy/serve.rs index 53d4fa8..9ef08f7 100644 --- a/privaxy/src/server/proxy/serve.rs +++ b/privaxy/src/server/proxy/serve.rs @@ -1,5 +1,7 @@ +use super::doh::{self, DohAction}; use super::html_rewriter::Rewriter; use crate::blocker::AdblockRequester; +use crate::configuration::DohConfig; use crate::statistics::Statistics; use crate::web_gui::events::Event; use adblock::blocker::BlockerResult; @@ -254,6 +256,7 @@ pub(crate) async fn serve( broadcast_sender: broadcast::Sender, statistics: Statistics, client_ip_address: IpAddr, + doh_config: DohConfig, ) -> Result, hyper::Error> { let scheme_string = scheme.to_string(); @@ -294,6 +297,21 @@ pub(crate) async fn serve( // used for the actual outbound request below. let match_url = url_for_matching(&uri); + // DNS-over-HTTPS policy is applied before adblock matching: a DoH endpoint + // rarely matches a network rule, but we still want to refuse or redirect it. + let doh_action = doh::classify(&doh_config, req.headers(), &uri); + if let DohAction::Block = &doh_action { + log::debug!("Refusing DoH request: {}", uri); + let _result = broadcast_sender.send(Event { + now: chrono::Utc::now(), + method: req.method().to_string(), + url: req.uri().to_string(), + is_request_blocked: true, + }); + statistics.increment_blocked_requests(); + return Ok(get_empty_response(StatusCode::BAD_GATEWAY)); + } + let (is_request_blocked, blocker_result) = adblock_requester .is_network_url_blocked( match_url.clone(), @@ -370,8 +388,19 @@ pub(crate) async fn serve( } } } + // In redirect mode the query is forwarded to the configured upstream + // resolver instead of the endpoint the client chose; otherwise the original + // URL is used unchanged. + let outbound_url = match &doh_action { + DohAction::Redirect(upstream) => { + log::debug!("Redirecting DoH request to {}: {}", upstream, uri); + doh::redirect_url(upstream, req.method(), &uri) + } + _ => req.uri().to_string(), + }; + let mut response = match client - .request(req.method().clone(), req.uri().to_string()) + .request(req.method().clone(), outbound_url) .headers(request_headers) .body(req.into_body()) .send() diff --git a/privaxy/src/server/web_gui/settings/network.rs b/privaxy/src/server/web_gui/settings/network.rs index e7627ec..87f42a9 100644 --- a/privaxy/src/server/web_gui/settings/network.rs +++ b/privaxy/src/server/web_gui/settings/network.rs @@ -1,6 +1,6 @@ use super::get_error_response; use crate::configuration; -use crate::configuration::NetworkConfig; +use crate::configuration::{DohConfig, NetworkConfig}; use crate::web_gui::with_configuration_save_lock; use crate::web_gui::with_configuration_updater_sender; use crate::web_gui::with_notify_reload; @@ -27,6 +27,10 @@ pub struct NetworkConfigRequest { pub web_port: u16, /// Enable TLS for the web server. pub tls: bool, + /// DNS-over-HTTPS interception policy. Omitted by older clients, in which + /// case the current configuration is preserved. + #[serde(default)] + pub doh: Option, } impl Into for NetworkConfigRequest { @@ -44,6 +48,7 @@ impl Into for NetworkConfigRequest { pac_direct_ips: Vec::new(), pac_direct_cidrs: std::collections::BTreeMap::new(), pac_direct_fqdns: Vec::new(), + doh: DohConfig::default(), } } } @@ -76,6 +81,7 @@ async fn put_network_settings( }; drop(lock); + let requested_doh = network_settings.doh.clone(); let mut net_cfg: NetworkConfig = network_settings.into(); let current_cfg = configuration.network.clone(); net_cfg.tls_cert_path = current_cfg.tls_cert_path; @@ -86,6 +92,7 @@ async fn put_network_settings( net_cfg.pac_direct_ips = current_cfg.pac_direct_ips; net_cfg.pac_direct_cidrs = current_cfg.pac_direct_cidrs; net_cfg.pac_direct_fqdns = current_cfg.pac_direct_fqdns; + net_cfg.doh = requested_doh.unwrap_or(current_cfg.doh); if let Err(err) = &net_cfg.validate().await { log::error!("Invalid network settings: {}", err); return Ok(Box::new(get_error_response(err))); diff --git a/web_frontend/src/general.rs b/web_frontend/src/general.rs index a6c66a3..e9958e0 100644 --- a/web_frontend/src/general.rs +++ b/web_frontend/src/general.rs @@ -36,6 +36,9 @@ pub enum Message { ValidationSucceeded, CaSaveSuccess, UpdateTls(bool), + UpdateDohMode(String), + UpdateDohUpstream(String), + UpdateDohHosts(String), SaveSuccess, SaveFailed(ApiError), AcknowledgeError, @@ -57,6 +60,50 @@ pub struct NetworkConfig { pub web_port: u16, /// Enable TLS for the web server. pub tls: bool, + /// DNS-over-HTTPS interception policy. + #[serde(default)] + pub doh: DohConfig, +} + +/// How DNS-over-HTTPS requests passing through the proxy are handled. Mirrors +/// the backend `configuration::DohMode`. +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DohMode { + Off, + #[default] + Block, + Redirect, +} + +impl DohMode { + fn as_value(&self) -> &'static str { + match self { + DohMode::Off => "off", + DohMode::Block => "block", + DohMode::Redirect => "redirect", + } + } + + fn from_value(value: &str) -> Self { + match value { + "off" => DohMode::Off, + "redirect" => DohMode::Redirect, + _ => DohMode::Block, + } + } +} + +/// DNS-over-HTTPS interception configuration. Mirrors the backend +/// `configuration::DohConfig`. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct DohConfig { + #[serde(default)] + pub mode: DohMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_hosts: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -116,16 +163,30 @@ struct NetworkSettings { raw_proxy_port: String, raw_bind_addr: String, raw_web_port: String, + raw_doh_upstream: String, + raw_doh_hosts: String, proxy_port_error: Option, bind_addr_error: Option, web_port_error: Option, } impl NetworkSettings { + /// Redirect mode is meaningless without an upstream resolver to forward to. + fn doh_upstream_error(&self) -> Option { + let doh = &self.current_config.doh; + if doh.mode == DohMode::Redirect && doh.upstream.as_deref().unwrap_or("").trim().is_empty() + { + Some("An upstream resolver URL is required in redirect mode".to_string()) + } else { + None + } + } + fn validate(&self) -> bool { self.proxy_port_error.is_none() && self.bind_addr_error.is_none() && self.web_port_error.is_none() + && self.doh_upstream_error().is_none() } fn config_has_changed(&self) -> bool { self.current_config.clone() != self.remote_config @@ -323,6 +384,8 @@ impl Component for GeneralSettings { raw_proxy_port: network_config.proxy_port.to_string(), raw_bind_addr: network_config.bind_addr.clone(), raw_web_port: network_config.web_port.to_string(), + raw_doh_upstream: network_config.doh.upstream.clone().unwrap_or_default(), + raw_doh_hosts: network_config.doh.extra_hosts.join(", "), proxy_port_error: None, bind_addr_error: None, web_port_error: None, @@ -371,6 +434,32 @@ impl Component for GeneralSettings { network_settings.current_config.tls = value; } } + Message::UpdateDohMode(value) => { + if let Some(ref mut network_settings) = self.network_settings { + network_settings.current_config.doh.mode = DohMode::from_value(&value); + } + } + Message::UpdateDohUpstream(value) => { + if let Some(ref mut network_settings) = self.network_settings { + network_settings.raw_doh_upstream = value.clone(); + let trimmed = value.trim(); + network_settings.current_config.doh.upstream = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } + } + Message::UpdateDohHosts(value) => { + if let Some(ref mut network_settings) = self.network_settings { + network_settings.raw_doh_hosts = value.clone(); + network_settings.current_config.doh.extra_hosts = value + .split(',') + .map(|host| host.trim().to_string()) + .filter(|host| !host.is_empty()) + .collect(); + } + } Message::UpdateCaCert(value) => { self.ca_config.ca_cert_pem = value; self.ca_config.dirty = true; @@ -543,6 +632,44 @@ impl Component for GeneralSettings { Message::UpdateTls(input.checked()) }), "If the web server uses HTTPS") } + { render_select_setting( + "DoH handling", + network_settings.current_config.doh.mode.as_value(), + &[ + ("off", "Off — leave DoH untouched"), + ("block", "Block — refuse DoH (clients fall back to system DNS)"), + ("redirect", "Redirect — forward to an upstream resolver"), + ], + ctx.link().callback(|e: Event| { + let input: web_sys::HtmlSelectElement = e.target_unchecked_into(); + Message::UpdateDohMode(input.value()) + }), + "How DNS-over-HTTPS requests passing through the proxy are handled." + ) } + if network_settings.current_config.doh.mode == DohMode::Redirect { + { render_setting( + "Upstream resolver", + network_settings.raw_doh_upstream.clone(), + ctx.link().callback(|e: InputEvent| { + let input: web_sys::HtmlInputElement = e.target_unchecked_into(); + Message::UpdateDohUpstream(input.value()) + }), + network_settings.doh_upstream_error().as_ref(), + "DoH endpoint queries are forwarded to, e.g. https://dns.quad9.net/dns-query" + ) } + } + if network_settings.current_config.doh.mode != DohMode::Off { + { render_setting( + "Extra DoH hosts", + network_settings.raw_doh_hosts.clone(), + ctx.link().callback(|e: InputEvent| { + let input: web_sys::HtmlInputElement = e.target_unchecked_into(); + Message::UpdateDohHosts(input.value()) + }), + None, + "Comma-separated resolver hostnames to also treat as DoH, on top of the built-in list." + ) } + } } } @@ -669,6 +796,32 @@ fn render_certificate_setting( } } +fn render_select_setting( + setting_name: &str, + selected: &str, + options: &[(&str, &str)], + onchange: Callback, + description: &str, +) -> Html { + html! { +
+
+
{ setting_name }
+
+ +
+
+
+

{description}

+
+
+ } +} + use std::cell::RefCell; use std::rc::Rc; From 60e7e84d025b924527bd495252ab42694b04d319 Mon Sep 17 00:00:00 2001 From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Sat, 30 May 2026 07:51:43 -0500 Subject: [PATCH 05/12] Feature/doh (#31) * doh * fmt * Forgot doh lol --- privaxy/src/server/proxy/doh.rs | 113 ++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 privaxy/src/server/proxy/doh.rs diff --git a/privaxy/src/server/proxy/doh.rs b/privaxy/src/server/proxy/doh.rs new file mode 100644 index 0000000..34637d9 --- /dev/null +++ b/privaxy/src/server/proxy/doh.rs @@ -0,0 +1,113 @@ +//! Detection and policy for DNS-over-HTTPS (DoH) requests flowing through the +//! MITM proxy. +//! +//! Privaxy filters at the HTTP layer, not at DNS-resolution time, so DoH does +//! not bypass blocking the way it bypasses a DNS-level filter. This module +//! instead lets the operator either refuse DoH outright — pushing fallback-mode +//! clients (e.g. default Firefox) back onto the system resolver, whose lookups +//! already traverse the proxy — or transparently redirect DoH queries to a +//! chosen upstream resolver. + +use crate::configuration::{DohConfig, DohMode}; +use http::{HeaderMap, Method, Uri}; + +/// Well-known DoH endpoint hostnames. Used as a secondary signal (alongside the +/// RFC 8484 `application/dns-message` content type) to catch JSON DoH and +/// clients that omit the canonical media type. +const KNOWN_DOH_HOSTS: [&str; 12] = [ + "cloudflare-dns.com", + "mozilla.cloudflare-dns.com", + "dns.google", + "dns.google.com", + "dns.quad9.net", + "doh.opendns.com", + "dns.nextdns.io", + "firefox.dns.nextdns.io", + "doh.cleanbrowsing.org", + "dns.adguard-dns.com", + "doh.dns.sb", + "dns.controld.com", +]; + +const DOH_MEDIA_TYPES: [&str; 2] = ["application/dns-message", "application/dns-json"]; + +/// What to do with a request the proxy has (or has not) identified as DoH. +#[derive(Debug, Clone)] +pub(crate) enum DohAction { + /// Not DoH, or the feature is disabled: handle the request normally. + Passthrough, + /// Refuse the request so the client's DoH attempt fails. + Block, + /// Forward the query to this upstream DoH resolver URL instead. + Redirect(String), +} + +fn header_advertises_doh(headers: &HeaderMap, name: http::header::HeaderName) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(|value| { + let value = value.to_ascii_lowercase(); + DOH_MEDIA_TYPES.iter().any(|media| value.contains(media)) + }) + .unwrap_or(false) +} + +fn host_is_known_doh(host: &str, extra_hosts: &[String]) -> bool { + let host = host.to_ascii_lowercase(); + let is_match = |candidate: &str| host == candidate || host.ends_with(&format!(".{candidate}")); + KNOWN_DOH_HOSTS.iter().any(|h| is_match(h)) || extra_hosts.iter().any(|h| is_match(h)) +} + +/// A request is treated as DoH when it carries the RFC 8484 media type (the +/// authoritative signal, exclusive to DoH for both `Content-Type` on POST and +/// `Accept` on GET) or when it targets a known DoH endpoint with a query-shaped +/// path (covering JSON DoH that may arrive with a generic `Accept` header). +fn is_doh_request(headers: &HeaderMap, uri: &Uri, extra_hosts: &[String]) -> bool { + if header_advertises_doh(headers, http::header::CONTENT_TYPE) + || header_advertises_doh(headers, http::header::ACCEPT) + { + return true; + } + + let host = uri.host().unwrap_or(""); + if !host_is_known_doh(host, extra_hosts) { + return false; + } + + uri.path().contains("dns-query") + || uri + .query() + .map(|query| query.contains("dns=") || query.contains("name=")) + .unwrap_or(false) +} + +/// Decide how to handle a request given the configured DoH policy. +pub(crate) fn classify(config: &DohConfig, headers: &HeaderMap, uri: &Uri) -> DohAction { + if matches!(config.mode, DohMode::Off) || !is_doh_request(headers, uri, &config.extra_hosts) { + return DohAction::Passthrough; + } + + match config.mode { + DohMode::Off => DohAction::Passthrough, + DohMode::Block => DohAction::Block, + DohMode::Redirect => match &config.upstream { + Some(upstream) if !upstream.is_empty() => DohAction::Redirect(upstream.clone()), + // Redirect configured without a usable upstream: fail safe by + // blocking rather than silently forwarding to the original resolver. + _ => DohAction::Block, + }, + } +} + +/// Build the outbound URL for a redirected DoH request. GET-style DoH carries +/// the query in the URL (`?dns=` / `?name=`), so it must be preserved; POST DoH +/// carries it in the body and needs only the upstream endpoint. +pub(crate) fn redirect_url(upstream: &str, method: &Method, uri: &Uri) -> String { + if method == Method::GET { + if let Some(query) = uri.query() { + return format!("{upstream}?{query}"); + } + } + upstream.to_string() +} From c38634f9fa89bed3a09015989c6194b03be88c44 Mon Sep 17 00:00:00 2001 From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Sat, 30 May 2026 08:57:04 -0500 Subject: [PATCH 06/12] http2 adaptive window, add connect timeout (#32) --- privaxy/src/server/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/privaxy/src/server/lib.rs b/privaxy/src/server/lib.rs index 1e0ceed..e35ea5d 100644 --- a/privaxy/src/server/lib.rs +++ b/privaxy/src/server/lib.rs @@ -87,6 +87,25 @@ pub async fn start_privaxy() -> PrivaxyServer { .gzip(true) .brotli(true) .deflate(true) + // Without these, a proxied request can hang indefinitely on a pooled + // keep-alive connection the remote has silently dropped: reqwest reuses + // the dead connection and waits on a peer that will never answer. + // Retiring idle connections quickly (well under typical server keep-alive + // windows) plus OS-level keepalive probes bounds that. `connect_timeout` + // additionally fails fast on unreachable hosts. + .connect_timeout(Duration::from_secs(10)) + .pool_idle_timeout(Duration::from_secs(30)) + .tcp_keepalive(Duration::from_secs(60)) + // h2-heavy origins multiplex every subresource over a + // single connection whose flow-control window defaults to a small, + // shared 64 KB. When we drain one stream's body slowly (the browser + // reads slowly, or the HTML rewriter backpressures), that window fills + // and stalls *every other stream* on the connection — head-of-line + // stutter across the whole site. Adaptive flow control grows the + // stream/connection windows based on the bandwidth-delay product, + // relieving the stall while keeping multiplexing. (This overrides any + // manual http2_initial_*_window_size, which is why none are set.) + .http2_adaptive_window(true) .build() .unwrap(); @@ -376,7 +395,7 @@ async fn privaxy_backend( } }); - let ip = env_or_config_ip(&network_config).await; + let ip = env_or_config_ip(network_config).await; let proxy_server_addr = SocketAddr::from((ip, network_config.proxy_port)); let server = Server::bind(&proxy_server_addr) From e978da1b23a41cf929f4d105cf3c95230916fdba Mon Sep 17 00:00:00 2001 From: Josh Mcdaniel <80354972+joshrmcdaniel@users.noreply.github.com> Date: Sat, 30 May 2026 10:52:22 -0500 Subject: [PATCH 07/12] filter validation (#33) --- privaxy/src/server/configuration/filter.rs | 61 +++++++++- privaxy/src/server/configuration/mod.rs | 7 ++ privaxy/src/server/statistics.rs | 1 - privaxy/src/server/web_gui/filters.rs | 14 ++- .../server/web_gui/settings/ca_certificate.rs | 4 +- .../src/server/web_gui/settings/network.rs | 12 +- web_frontend/src/filterlists.rs | 54 +++++++-- web_frontend/src/filters.rs | 106 ++++++++++++------ 8 files changed, 205 insertions(+), 54 deletions(-) diff --git a/privaxy/src/server/configuration/filter.rs b/privaxy/src/server/configuration/filter.rs index 26f310e..da51d15 100644 --- a/privaxy/src/server/configuration/filter.rs +++ b/privaxy/src/server/configuration/filter.rs @@ -187,7 +187,7 @@ impl DefaultFilters { ("https://secure.fanboy.co.nz/fanboy-annoyance.txt", "Fanboy's Annoyance", FilterGroup::Social, false), ("https://secure.fanboy.co.nz/fanboy-cookiemonster.txt", "EasyList Cookie", FilterGroup::Social, false), ("https://easylist.to/easylist/fanboy-social.txt", "Fanboy's Social", FilterGroup::Social, false), - ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt", "uBlock filters - Annoyances", FilterGroup::Social, false), + ("https://raw.githubusercontent.com/uBlockOrigin/uAssets/refs/heads/master/filters/annoyances-others.txt", "uBlock filters - Annoyances", FilterGroup::Social, false), ] .into_iter() .filter_map(|(url, title, group, enabled_by_default)| Self::parse_filter(url, title, group, enabled_by_default)) @@ -256,6 +256,8 @@ impl Filter { let filters_directory = get_filter_directory(); fs::create_dir_all(&filters_directory).await?; + // `get_filter` rejects responses that are not served as a filter list (see its + // Content-Type check), so an invalid URL never reaches disk. let filter = get_filter(self, http_client).await?; let filter_path = filters_directory.join(&self.file_name); @@ -302,13 +304,65 @@ impl From for Filter { } } +/// Returns `Ok(())` only when the response is served as a `text/plain` filter list. +/// +/// A URL returning a `200` is not sufficient on its own: it may serve an HTML error page, a +/// redirect landing page or any other content. Filter lists are served as `text/plain`, so a +/// different Content-Type (most commonly `text/html`) means the URL does not point to a list. +fn validate_filter_content_type( + filter: &Filter, + response: &reqwest::Response, +) -> super::ConfigurationResult<()> { + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + + // `starts_with` so that a charset suffix (e.g. `text/plain; charset=utf-8`) still matches. + if content_type.starts_with("text/plain") { + return Ok(()); + } + + Err(super::ConfigurationError::FilterValidationError(format!( + "The URL for '{}' does not point to a filter list (expected a \"text/plain\" response, got \"{}\")", + filter.title, + if content_type.is_empty() { + "no Content-Type" + } else { + &content_type + } + ))) +} + +/// Verifies at least one rule is present. +fn validate_filter_rules(filter: &Filter, contents: &str) -> super::ConfigurationResult<()> { + let (network_filters, cosmetic_filters) = adblock::lists::parse_filters( + contents.lines(), + false, + adblock::lists::ParseOptions::default(), + ); + + if network_filters.is_empty() && cosmetic_filters.is_empty() { + return Err(super::ConfigurationError::FilterValidationError(format!( + "The URL for '{}' returned no parseable filter rules", + filter.title + ))); + } + + Ok(()) +} + pub(crate) async fn get_filter( filter: &mut Filter, http_client: &reqwest::Client, ) -> super::ConfigurationResult { let response = http_client.get(filter.url.as_str()).send().await?; if response.status().is_success() { + validate_filter_content_type(filter, &response)?; let content = response.text().await?; + validate_filter_rules(filter, &content)?; Ok(content) } else { log::error!( @@ -349,8 +403,11 @@ pub(crate) async fn get_filters_content( for result in results { match result { Ok(filter_content) => filters.push(filter_content), + // A fetch failure here includes a filter whose URL has stopped serving a + // `text/plain` list (see `validate_filter_content_type`); we warn and drop it from + // the engine rather than aborting the whole rebuild. Err(err) => { - log::error!("Unable to retrieve filter: {:?}, skipping.", err) + log::warn!("Dropping filter that could not be loaded: {err:?}") } } } diff --git a/privaxy/src/server/configuration/mod.rs b/privaxy/src/server/configuration/mod.rs index 1327417..3402003 100644 --- a/privaxy/src/server/configuration/mod.rs +++ b/privaxy/src/server/configuration/mod.rs @@ -46,6 +46,8 @@ pub enum ConfigurationError { UnableToDecodePem(#[from] openssl::error::ErrorStack), #[error("filter error: {0}")] FilterError(String), + #[error("filter validation error: {0}")] + FilterValidationError(String), } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -228,6 +230,11 @@ impl Configuration { self.filters.push(filter.clone()); Ok(()) } + Err(err @ ConfigurationError::FilterValidationError(_)) => { + log::warn!("Rejected invalid filter: {err}"); + filter.enabled = false; + Err(err) + } Err(err) => { log::error!("Failed to add filter: {err}"); filter.enabled = false; diff --git a/privaxy/src/server/statistics.rs b/privaxy/src/server/statistics.rs index cade9e0..1517abb 100644 --- a/privaxy/src/server/statistics.rs +++ b/privaxy/src/server/statistics.rs @@ -110,7 +110,6 @@ impl Statistics { let mut top_clients_iter = top_clients.iter(); let mut top_clients = (0..=ENTRIES_PER_STATISTICS_TABLE) - .into_iter() .filter_map(|_| { let (ipv4, count) = top_clients_iter.next()?; diff --git a/privaxy/src/server/web_gui/filters.rs b/privaxy/src/server/web_gui/filters.rs index 565ae2b..89d095e 100644 --- a/privaxy/src/server/web_gui/filters.rs +++ b/privaxy/src/server/web_gui/filters.rs @@ -1,5 +1,7 @@ use super::get_error_response; -use crate::configuration::{calc_filter_filename, Configuration, Filter, FilterGroup}; +use crate::configuration::{ + calc_filter_filename, Configuration, ConfigurationError, Filter, FilterGroup, +}; use crate::web_gui::ApiError; use serde::Deserialize; use serde_with::{serde_as, DisplayFromStr}; @@ -119,7 +121,7 @@ async fn add_filter( url: filter_url, title: filter_request.title.clone(), group: filter_request.group, - file_name: calc_filter_filename(&filter_request.url.to_string()), + file_name: calc_filter_filename(filter_request.url.as_ref()), }; match configuration @@ -127,6 +129,14 @@ async fn add_filter( .await { Ok(_) => {} + Err(ConfigurationError::FilterValidationError(message)) => { + log::warn!("Rejected invalid filter: {message}"); + return Ok(Response::builder() + .status(http::StatusCode::UNPROCESSABLE_ENTITY) + .header(http::header::CONTENT_TYPE, "application/json") + .body(serde_json::to_string(&ApiError { error: message }).unwrap()) + .unwrap()); + } Err(err) => { log::error!("Failed to add filter: {err}"); return Ok(get_error_response(err)); diff --git a/privaxy/src/server/web_gui/settings/ca_certificate.rs b/privaxy/src/server/web_gui/settings/ca_certificate.rs index 74d7183..0313e21 100644 --- a/privaxy/src/server/web_gui/settings/ca_certificate.rs +++ b/privaxy/src/server/web_gui/settings/ca_certificate.rs @@ -50,7 +50,7 @@ async fn validate_ca_certificates(body: Ca) -> Result, Infa )), Err(err) => { log::error!("Invalid CA certificates: {err}"); - return Ok(Box::new( + Ok(Box::new( Response::builder() .status(http::StatusCode::BAD_REQUEST) .body( @@ -59,7 +59,7 @@ async fn validate_ca_certificates(body: Ca) -> Result, Infa }) .unwrap(), ), - )); + )) } } } diff --git a/privaxy/src/server/web_gui/settings/network.rs b/privaxy/src/server/web_gui/settings/network.rs index 87f42a9..8e0d3c8 100644 --- a/privaxy/src/server/web_gui/settings/network.rs +++ b/privaxy/src/server/web_gui/settings/network.rs @@ -33,13 +33,13 @@ pub struct NetworkConfigRequest { pub doh: Option, } -impl Into for NetworkConfigRequest { - fn into(self) -> NetworkConfig { +impl From for NetworkConfig { + fn from(val: NetworkConfigRequest) -> Self { NetworkConfig { - bind_addr: self.bind_addr, - proxy_port: self.proxy_port, - web_port: self.web_port, - tls: self.tls, + bind_addr: val.bind_addr, + proxy_port: val.proxy_port, + web_port: val.web_port, + tls: val.tls, tls_cert_path: None, tls_key_path: None, listen_url: None, diff --git a/web_frontend/src/filterlists.rs b/web_frontend/src/filterlists.rs index bacf5ad..87982f4 100644 --- a/web_frontend/src/filterlists.rs +++ b/web_frontend/src/filterlists.rs @@ -2,7 +2,7 @@ use crate::button; use crate::button::{ButtonColor, ButtonState, PrivaxyButton}; use crate::filters::{AddFilterRequest, Filter, FilterConfiguration, FilterGroup}; use crate::save_button::BASE_BUTTON_CSS; -use crate::{save_button, submit_banner}; +use crate::{failure_banner, save_button, submit_banner, ApiError}; use filterlists_api; use reqwasm::http::Request; use url::Url; @@ -19,6 +19,8 @@ pub enum SearchFilterMessage { RemoveFilter(filterlists_api::Filter), LoadFilters, FiltersLoaded(Vec), + AddFilterFailed(String, String), + AcknowledgeError, Error(String), NextPage, PreviousPage, @@ -39,6 +41,7 @@ pub struct SearchFilterList { current_page: usize, results_per_page: usize, active_filters: FilterConfiguration, + error_message: Option, } const FILTER_TAG_GROUPS: [&'static str; 4] = ["ads", "privacy", "malware", "social"]; @@ -65,6 +68,7 @@ impl Component for SearchFilterList { current_page: 1, results_per_page: 10, active_filters: _ctx.props().filter_configuration.clone(), + error_message: None, } } @@ -95,32 +99,49 @@ impl Component for SearchFilterList { .next() .unwrap_or(FilterGroup::Regional); + self.error_message = None; self.active_filters.push(Filter::new( filter.name.clone(), FilterGroup::Malware, "".to_string(), )); let filter_name = filter.name.clone(); + let rollback_name = filter.name.clone(); let filter_id = filter.id; + let link = self.link.clone(); spawn_local(async move { let parsed_url = match resolve_primary_view_url(filter_id).await { Some(url) => url, - None => return, + None => { + link.send_message(SearchFilterMessage::AddFilterFailed( + rollback_name, + "Could not resolve a download URL for this filter list".to_string(), + )); + return; + } }; let request_body = AddFilterRequest::new(filter_name, group, parsed_url); let request = Request::post("/api/filters") .header("Content-Type", "application/json") .body(serde_json::to_string(&request_body).unwrap()); match request.send().await { + Ok(response) if response.ok() => { + log::info!("Filter added successfully"); + } Ok(response) => { - if response.ok() { - log::info!("Filter added successfully"); - } else { - log::error!("Failed to add filter: {:?}", response.status()); - } + let err = response.json::().await.unwrap_or(ApiError { + error: format!("HTTP {}", response.status()), + }); + link.send_message(SearchFilterMessage::AddFilterFailed( + rollback_name, + err.error, + )); } Err(err) => { - log::error!("Request error: {:?}", err); + link.send_message(SearchFilterMessage::AddFilterFailed( + rollback_name, + format!("{err:?}"), + )); } } }) @@ -267,6 +288,12 @@ impl Component for SearchFilterList { log::info!("Tags loaded successfully"); self.tags = tags.clone(); } + SearchFilterMessage::AddFilterFailed(name, error) => { + log::error!("Failed to add filter {name}: {error}"); + self.active_filters.retain(|f| f.title != name); + self.error_message = Some(error); + } + SearchFilterMessage::AcknowledgeError => self.error_message = None, SearchFilterMessage::Error(error) => { log::error!("Error loading filters: {}", error.to_string()); self.loading = false; @@ -357,6 +384,16 @@ impl Component for SearchFilterList { let total_count = self.filters.len(); let match_count = filtered_filters_len(&self.filters, &self.filter_query); + let failure_banner = match &self.error_message { + Some(message) => failure_banner!( + true, + self.link + .callback(|_| SearchFilterMessage::AcknowledgeError), + message.clone() + ), + None => html! {}, + }; + let modal_overlay = html! {
+ { failure_banner } self.is_open = true, AddFilterMessage::Close => self.is_open = false, AddFilterMessage::Save(url, title, category) => { - if let Ok(parsed_url) = Url::parse(&url) { - let request_body = AddFilterRequest { - enabled: true, - title: if title.is_empty() { - self.url.clone() - } else { - title - }, - group: category, - url: parsed_url, - }; - - let request = Request::post("/api/filters") - .header("Content-Type", "application/json") - .body(serde_json::to_string(&request_body).unwrap()); - - spawn_local(async move { - match request.send().await { - Ok(response) => { - if response.ok() { - log::info!("Filter added successfully"); - } else { - log::error!("Failed to add filter: {:?}", response.status()); - } - } - Err(err) => { - log::error!("Request error: {:?}", err); - } + let parsed_url = match Url::parse(&url) { + Ok(parsed_url) => parsed_url, + Err(err) => { + self.link.send_message(AddFilterMessage::Failed(ApiError { + error: format!("Invalid URL: {err}"), + })); + return true; + } + }; + + let request_body = AddFilterRequest { + enabled: true, + title: if title.is_empty() { + self.url.clone() + } else { + title + }, + group: category, + url: parsed_url, + }; + + let request = Request::post("/api/filters") + .header("Content-Type", "application/json") + .body(serde_json::to_string(&request_body).unwrap()); + + let link = self.link.clone(); + spawn_local(async move { + match request.send().await { + Ok(response) if response.ok() => { + link.send_message(AddFilterMessage::Saved); } - }); - } else { - log::error!("Invalid URL: {}", url); - } + Ok(response) => { + let err = response.json::().await.unwrap_or(ApiError { + error: format!("HTTP {}", response.status()), + }); + link.send_message(AddFilterMessage::Failed(err)); + } + Err(err) => { + link.send_message(AddFilterMessage::Failed(ApiError { + error: format!("{err:?}"), + })); + } + } + }); + } + AddFilterMessage::Saved => { + log::info!("Filter added successfully"); self.is_open = false; + self.show_error = false; + self.err_msg = String::new(); self.changes_saved = true; } + AddFilterMessage::Failed(err) => { + log::error!("Failed to add filter: {}", err.error); + self.show_error = true; + self.err_msg = err.error; + } + AddFilterMessage::AcknowledgeError => self.show_error = false, AddFilterMessage::CategoryChanged(category) => self.category = category, AddFilterMessage::UrlChanged(url) => self.url = url, AddFilterMessage::TitleChanged(title) => { @@ -212,6 +241,16 @@ impl Component for AddFilterComponent { let category = self.category.clone(); let title = self.title.clone(); + let failure_banner = if self.show_error { + failure_banner!( + true, + _ctx.link().callback(|_| AddFilterMessage::AcknowledgeError), + self.err_msg.clone() + ) + } else { + html! {} + }; + //