From 64099a818535511e9f694a3be403134ab02f6129 Mon Sep 17 00:00:00 2001
From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com>
Date: Sat, 30 May 2026 15:16:58 -0500
Subject: [PATCH 1/6] mmtls tunneling
---
privaxy/src/server/proxy/mitm.rs | 170 +++++++++++++++++++++++++++++-
privaxy/src/server/proxy/serve.rs | 27 ++++-
2 files changed, 190 insertions(+), 7 deletions(-)
diff --git a/privaxy/src/server/proxy/mitm.rs b/privaxy/src/server/proxy/mitm.rs
index 72141b66..866ada88 100644
--- a/privaxy/src/server/proxy/mitm.rs
+++ b/privaxy/src/server/proxy/mitm.rs
@@ -10,7 +10,11 @@ use hyper::{
};
use hyper_rustls::HttpsConnector;
use std::{net::IpAddr, sync::Arc};
-use tokio::{net::TcpStream, sync::broadcast};
+use tokio::{
+ io::{AsyncRead, AsyncWrite, AsyncReadExt, AsyncWriteExt},
+ net::TcpStream,
+ sync::broadcast,
+};
use tokio_rustls::TlsAcceptor;
#[allow(clippy::too_many_arguments)]
@@ -108,9 +112,35 @@ pub(crate) async fn serve_mitm_session(
});
Ok(Response::new(Body::empty()))
+ } else if local_exclusion_store.contains(authority.host())
+ && req.headers().contains_key(http::header::UPGRADE)
+ {
+ // An excluded host performing a protocol upgrade over plain HTTP — e.g.
+ // WeChat's MMTLS long-link (`http://dns.weixin.qq.com/mmtls/...`), which
+ // speaks a proprietary, non-HTTP protocol once upgraded. The hyper-based
+ // bridge in `serve` can't carry that (the upstream never returns a clean
+ // `101`, so the upgrade "expected but not completed"). Blind-tunnel the
+ // bytes at the TCP level instead, the same way excluded CONNECT hosts
+ // are tunneled.
+ tunnel_http_upgrade(req, authority).await
} else {
// The request is not of method `CONNECT`. Therefore,
// this request is for an HTTP resource.
+ //
+ // An opaque (non-WebSocket) protocol upgrade to a host that is *not*
+ // excluded will be routed through `serve`, whose hyper bridge cannot
+ // carry a non-HTTP protocol — it will hang or fail. We can't safely
+ // tunnel it (the user hasn't opted the host out of filtering), so warn
+ // and let it proceed, pointing the user at the exclusion list.
+ if is_opaque_upgrade(req.headers()) {
+ log::warn!(
+ "Proxying opaque protocol-upgrade traffic (MMTLS?) for {}; \
+ this is unlikely to work through the MITM proxy. Consider adding the host \
+ to your exclusions.",
+ authority
+ );
+ }
+
serve(
adblock_requester,
req,
@@ -128,12 +158,144 @@ pub(crate) async fn serve_mitm_session(
}
}
-async fn tunnel(mut upgraded: &mut Upgraded, authority: &Authority) -> std::io::Result<()> {
- let mut server = TcpStream::connect(authority.to_string()).await?;
+/// An HTTP `Upgrade` request whose target protocol is something other than
+/// WebSocket (or h2c) — e.g. WeChat's MMTLS long-link. The proxy can't do
+/// anything useful with such a protocol, and its hyper-based upgrade bridge
+/// can't carry it; these are only handled correctly by blind-tunneling, which
+/// requires the host to be excluded.
+fn is_opaque_upgrade(headers: &http::HeaderMap) -> bool {
+ headers
+ .get(http::header::UPGRADE)
+ .and_then(|value| value.to_str().ok())
+ .map(|value| {
+ // The Upgrade header may list multiple comma-separated tokens, each
+ // optionally `name/version`. Treat it as opaque only if no token is
+ // a protocol we can actually bridge.
+ value.split(',').all(|token| {
+ let name = token.trim().split('/').next().unwrap_or("").trim();
+ !name.eq_ignore_ascii_case("websocket") && !name.eq_ignore_ascii_case("h2c")
+ })
+ })
+ .unwrap_or(false)
+}
+
+/// Blind-tunnel a plain-HTTP protocol upgrade to an excluded host. The proxied
+/// request carries an absolute-form URI; we replay it to the upstream in
+/// origin-form over a raw socket, return our own `101` to the client, and pipe
+/// the (opaque) post-upgrade bytes both ways. The upstream's own `101` header
+/// block is discarded so the client sees exactly one status line.
+///
+/// thank you, wechat, for making this necessary
+async fn tunnel_http_upgrade(
+ req: Request
,
+ authority: Authority,
+) -> Result, hyper::Error> {
+ // Build the origin-form request head before `req` is moved into the task.
+ let path = req
+ .uri()
+ .path_and_query()
+ .map(|pq| pq.as_str())
+ .unwrap_or("/");
+ let mut head = format!("{} {} HTTP/1.1\r\n", req.method(), path);
+ for (name, value) in req.headers() {
+ head.push_str(name.as_str());
+ head.push_str(": ");
+ // Header values are effectively always ASCII here; lossy conversion just
+ // avoids failing the replay on a pathological non-UTF8 value.
+ head.push_str(&String::from_utf8_lossy(value.as_bytes()));
+ head.push_str("\r\n");
+ }
+ head.push_str("\r\n");
+
+ let upgrade_value = req.headers().get(http::header::UPGRADE).cloned();
- tokio::io::copy_bidirectional(&mut upgraded, &mut server).await?;
+ // The bridge runs detached: `hyper::upgrade::on` only resolves once we have
+ // returned the `101` below, so awaiting it here would deadlock.
+ tokio::spawn(async move {
+ match bridge_http_upgrade(req, head, &authority).await {
+ Ok(()) => log::debug!("HTTP-upgrade tunnel closed for {}", authority),
+ Err(e) => log::warn!("HTTP-upgrade tunnel for {} failed: {}", authority, e),
+ }
+ });
+
+ let mut response = Response::new(Body::empty());
+ *response.status_mut() = http::StatusCode::SWITCHING_PROTOCOLS;
+ response.headers_mut().insert(
+ http::header::CONNECTION,
+ http::HeaderValue::from_static("upgrade"),
+ );
+ if let Some(upgrade) = upgrade_value {
+ response.headers_mut().insert(http::header::UPGRADE, upgrade);
+ }
+ Ok(response)
+}
+
+/// Upstream half of `tunnel_http_upgrade`: wait for the client upgrade, connect
+/// to the origin, replay the request head, strip the origin's `101`, then pipe.
+async fn bridge_http_upgrade(
+ req: Request,
+ head: String,
+ authority: &Authority,
+) -> std::io::Result<()> {
+ let host = authority.host();
+ // Proxied `http://` authorities carry no port; default to 80.
+ let port = authority.port_u16().unwrap_or(80);
+
+ let mut client = hyper::upgrade::on(req)
+ .await
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+ let mut upstream = TcpStream::connect((host, port)).await?;
+ upstream.write_all(head.as_bytes()).await?;
+
+ let leftover = read_past_response_headers(&mut upstream).await?;
+ if !leftover.is_empty() {
+ client.write_all(&leftover).await?;
+ }
+
+ pipe(&mut client, &mut upstream).await
+}
+
+/// Read from `stream` until the end of the HTTP response header block
+/// (`\r\n\r\n`) and return any bytes that followed it (the start of the tunneled
+/// payload). If the upstream closes or never sends a recognizable header block,
+/// whatever was read is returned so it can still be forwarded.
+async fn read_past_response_headers(stream: &mut TcpStream) -> std::io::Result> {
+ const HEADER_CAP: usize = 64 * 1024;
+ let mut buf = Vec::new();
+ let mut chunk = [0u8; 1024];
+
+ loop {
+ let n = stream.read(&mut chunk).await?;
+ if n == 0 {
+ return Ok(buf);
+ }
+ buf.extend_from_slice(&chunk[..n]);
+
+ if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
+ return Ok(buf.split_off(pos + 4));
+ }
+ if buf.len() > HEADER_CAP {
+ // No header terminator in a sane amount of data; treat everything as
+ // payload rather than stalling.
+ return Ok(buf);
+ }
+ }
+}
+
+async fn tunnel(upgraded: &mut Upgraded, authority: &Authority) -> std::io::Result<()> {
+ let mut server = TcpStream::connect(authority.to_string()).await?;
log::debug!("Started tunneling host: {}", authority);
+ pipe(upgraded, &mut server).await
+}
+
+/// Pipe two duplex streams in both directions until either side closes.
+async fn pipe(a: &mut A, b: &mut B) -> std::io::Result<()>
+where
+ A: AsyncRead + AsyncWrite + Unpin + ?Sized,
+ B: AsyncRead + AsyncWrite + Unpin + ?Sized,
+{
+ tokio::io::copy_bidirectional(a, b).await?;
Ok(())
}
diff --git a/privaxy/src/server/proxy/serve.rs b/privaxy/src/server/proxy/serve.rs
index 9f50ff2c..6bbe2a88 100644
--- a/privaxy/src/server/proxy/serve.rs
+++ b/privaxy/src/server/proxy/serve.rs
@@ -554,10 +554,14 @@ async fn perform_two_ends_upgrade(
) -> Response {
let (mut duplex_client, mut duplex_server) = tokio::io::duplex(32);
+ // Captured for log context; `uri` is moved into `new_request` below.
+ let request_uri = uri.to_string();
+
let mut new_request = Request::new(Body::empty());
*new_request.headers_mut() = request.headers().clone();
*new_request.uri_mut() = uri;
+ let client_uri = request_uri.clone();
tokio::spawn(async move {
match hyper::upgrade::on(request).await {
Ok(mut upgraded_client) => {
@@ -565,16 +569,33 @@ async fn perform_two_ends_upgrade(
tokio::io::copy_bidirectional(&mut upgraded_client, &mut duplex_client).await;
}
Err(e) => {
- log::debug!("Unable to upgrade: {}", e)
+ log::warn!("Unable to upgrade client connection for {}: {}", client_uri, e)
}
}
});
let response = match hyper_client.request(new_request).await {
Ok(response) => response,
- Err(_err) => return get_empty_response(http::StatusCode::BAD_REQUEST),
+ Err(err) => {
+ log::warn!("Upstream upgrade request failed for {}: {}", request_uri, err);
+ return get_empty_response(http::StatusCode::BAD_GATEWAY);
+ }
};
+ // Only bridge a genuine protocol switch. If the upstream did not return
+ // `101 Switching Protocols`, forwarding a fabricated 101 leaves the client
+ // believing the upgrade succeeded while no bytes are ever bridged from the
+ // server half — the connection then hangs forever. Forward the upstream's
+ // actual response instead so the client can fail (or follow it) cleanly.
+ if response.status() != StatusCode::SWITCHING_PROTOCOLS {
+ log::warn!(
+ "Upstream did not upgrade {} (status {}); forwarding response as-is",
+ request_uri,
+ response.status()
+ );
+ return response;
+ }
+
let mut new_response = get_empty_response(StatusCode::SWITCHING_PROTOCOLS);
*new_response.headers_mut() = response.headers().clone();
@@ -586,7 +607,7 @@ async fn perform_two_ends_upgrade(
});
}
Err(e) => {
- log::debug!("Unable to upgrade: {}", e)
+ log::warn!("Unable to upgrade upstream connection for {}: {}", request_uri, e)
}
}
From 8f654794a65136371524a4911061af15223bf644 Mon Sep 17 00:00:00 2001
From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com>
Date: Sat, 30 May 2026 15:17:05 -0500
Subject: [PATCH 2/6] timeout socket
---
privaxy/src/server/lib.rs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/privaxy/src/server/lib.rs b/privaxy/src/server/lib.rs
index 43eec62d..9a87fe86 100644
--- a/privaxy/src/server/lib.rs
+++ b/privaxy/src/server/lib.rs
@@ -364,11 +364,20 @@ async fn privaxy_backend(
configuration_save_lock: Arc>,
notify_reload: Arc,
) {
+ // Mirror the reqwest client's connection hardening (see above): without a
+ // connect timeout and OS-level keepalive, an upgrade can hang on a pooled
+ // keep-alive connection the remote has silently dropped, surfacing as
+ // "upgrade expected but not completed".
+ let mut http_connector = hyper::client::HttpConnector::new();
+ http_connector.enforce_http(false);
+ http_connector.set_connect_timeout(Some(Duration::from_secs(10)));
+ http_connector.set_keepalive(Some(Duration::from_secs(60)));
+
let https_connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.https_or_http()
.enable_http1()
- .build();
+ .wrap_connector(http_connector);
let config = read_configuration(&configuration_save_lock).await;
let network_config = &config.network;
let doh_config = network_config.doh.clone();
@@ -380,7 +389,12 @@ async fn privaxy_backend(
// handle compression.
// Hyper's client don't follow redirects, which is what we want, nothing to
// disable here.
- let hyper_client = Client::builder().build(https_connector);
+ // An upgraded connection is consumed by the tunnel anyway, so idle pooling
+ // buys nothing and only risks reusing a stale connection under a long-lived
+ // WebSocket — disable it.
+ let hyper_client = Client::builder()
+ .pool_max_idle_per_host(0)
+ .build(https_connector);
let make_service = make_service_fn(move |conn: &AddrStream| {
let client_ip_address = conn.remote_addr().ip();
From b15d38508d07a188dc7fa99a1a03fc1d6aeaeeb7 Mon Sep 17 00:00:00 2001
From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com>
Date: Sat, 30 May 2026 15:17:14 -0500
Subject: [PATCH 3/6] docs
---
CHANGELOG.md | 4 ++++
privaxy/src/server/proxy/mitm.rs | 6 ++++--
privaxy/src/server/proxy/serve.rs | 18 +++++++++++++++---
3 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb233065..41320fa7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## v0.7.1
+- Fix WebSocket / protocol-upgrade connections hanging
+ - Upgrade requests (`wss://`, and proprietary HTTP-upgrade transports like MMTLS long-link) could spin forever with `upgrade expected but not completed`. The proxy fabricated a `101 Switching Protocols` to the client regardless of what the upstream actually returned, so a failed upgrade left the client waiting on a tunnel that was never bridged. It now forwards the upstream's real response when it isn't a genuine `101`, and the dedicated upgrade HTTP client gained the same connection hardening (`connect_timeout`, `tcp_keepalive`, no idle pooling) as the main client.
+ - Excluded hosts performing a plain-HTTP protocol upgrade are now blind-tunneled at the TCP level instead of being run through the (HTTP-only) upgrade bridge, so MITM-excluded apps using non-HTTP upgrade protocols work. Previously the exclusion list was only consulted on the `CONNECT` path, so excluding such a host had no effect on its plain-HTTP upgrade traffic.
+ - When an opaque (non-WebSocket) upgrade is seen for a host that is *not* excluded, a warning is logged naming the host and suggesting it be added to the exclusions, instead of failing cryptically.
- Validate filter lists when added
- Adding a filter now rejects URLs that do not serve a `text/plain` filter list (e.g. an HTML error/landing page returned with a `200`) with a `422`, instead of silently saving a broken filter. The error is surfaced in the web UI, and filters whose URL stops serving a list are dropped from the engine with a warning on the next refresh.
- Fix proxied requests randomly hanging/timing out
diff --git a/privaxy/src/server/proxy/mitm.rs b/privaxy/src/server/proxy/mitm.rs
index 866ada88..0733e1c3 100644
--- a/privaxy/src/server/proxy/mitm.rs
+++ b/privaxy/src/server/proxy/mitm.rs
@@ -11,7 +11,7 @@ use hyper::{
use hyper_rustls::HttpsConnector;
use std::{net::IpAddr, sync::Arc};
use tokio::{
- io::{AsyncRead, AsyncWrite, AsyncReadExt, AsyncWriteExt},
+ io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::TcpStream,
sync::broadcast,
};
@@ -225,7 +225,9 @@ async fn tunnel_http_upgrade(
http::HeaderValue::from_static("upgrade"),
);
if let Some(upgrade) = upgrade_value {
- response.headers_mut().insert(http::header::UPGRADE, upgrade);
+ response
+ .headers_mut()
+ .insert(http::header::UPGRADE, upgrade);
}
Ok(response)
}
diff --git a/privaxy/src/server/proxy/serve.rs b/privaxy/src/server/proxy/serve.rs
index 6bbe2a88..5ad3064e 100644
--- a/privaxy/src/server/proxy/serve.rs
+++ b/privaxy/src/server/proxy/serve.rs
@@ -569,7 +569,11 @@ async fn perform_two_ends_upgrade(
tokio::io::copy_bidirectional(&mut upgraded_client, &mut duplex_client).await;
}
Err(e) => {
- log::warn!("Unable to upgrade client connection for {}: {}", client_uri, e)
+ log::warn!(
+ "Unable to upgrade client connection for {}: {}",
+ client_uri,
+ e
+ )
}
}
});
@@ -577,7 +581,11 @@ async fn perform_two_ends_upgrade(
let response = match hyper_client.request(new_request).await {
Ok(response) => response,
Err(err) => {
- log::warn!("Upstream upgrade request failed for {}: {}", request_uri, err);
+ log::warn!(
+ "Upstream upgrade request failed for {}: {}",
+ request_uri,
+ err
+ );
return get_empty_response(http::StatusCode::BAD_GATEWAY);
}
};
@@ -607,7 +615,11 @@ async fn perform_two_ends_upgrade(
});
}
Err(e) => {
- log::warn!("Unable to upgrade upstream connection for {}: {}", request_uri, e)
+ log::warn!(
+ "Unable to upgrade upstream connection for {}: {}",
+ request_uri,
+ e
+ )
}
}
From c4ffb2412aea4a82e6dcc007cd30e132d845abf4 Mon Sep 17 00:00:00 2001
From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com>
Date: Sat, 30 May 2026 15:40:00 -0500
Subject: [PATCH 4/6] pac optimization
---
privaxy/src/resources/proxy.pac.tera | 23 ++++++++++++++---------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/privaxy/src/resources/proxy.pac.tera b/privaxy/src/resources/proxy.pac.tera
index 08f62dcc..9454c908 100644
--- a/privaxy/src/resources/proxy.pac.tera
+++ b/privaxy/src/resources/proxy.pac.tera
@@ -10,22 +10,27 @@ function FindProxyForURL(url, host) {
if (myIp === "{{ ip }}") return "DIRECT";
{%- endfor %}
{%- endif %}
+{%- if cidrs | length > 0 or fqdns | length > 0 %}
+ var hostIsIp = /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.indexOf(":") !== -1;
+{%- endif %}
{%- if cidrs | length > 0 %}
- if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
+ if (hostIsIp) {
{%- for ip, netmask in cidrs %}
if (isInNet(host, "{{ ip }}", "{{ netmask }}")) return "DIRECT";
{%- endfor %}
}
{%- endif %}
{%- if fqdns | length > 0 %}
- var directDomains = [
- {%- for fqdn in fqdns %}
- "{{ fqdn }}",
- {%- endfor %}
- ];
- for (var i = 0; i < directDomains.length; i++) {
- var d = directDomains[i];
- if (host === d || dnsDomainIs(host, "." + d)) return "DIRECT";
+ if (!hostIsIp) {
+ var directDomains = [
+ {%- for fqdn in fqdns %}
+ "{{ fqdn }}",
+ {%- endfor %}
+ ];
+ for (var i = 0; i < directDomains.length; i++) {
+ var d = directDomains[i];
+ if (host === d || dnsDomainIs(host, "." + d)) return "DIRECT";
+ }
}
{%- endif %}
return "PROXY {{ proxy_host }}";
From dd47e180dc3598354711107ab2121ea4ded65c69 Mon Sep 17 00:00:00 2001
From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com>
Date: Sat, 30 May 2026 15:40:05 -0500
Subject: [PATCH 5/6] buildmode arg
---
Dockerfile | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 0507d0cb..422bca3b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -39,12 +39,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
&& cargo build --release; rm src/main.rs
COPY . .
+ARG COMPILE_MODE="release"
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
--mount=type=cache,target=/root/.npm \
- cd web_frontend && trunk build --release \
- && cd .. && cargo build --release \
- && cp target/release/privaxy /privaxy-out \
+ cd web_frontend && trunk build --${COMPILE_MODE} \
+ && cd .. && cargo build --${COMPILE_MODE} \
+ && cp target/${COMPILE_MODE}/privaxy /privaxy-out \
&& chmod +x /privaxy-out
# Prebuilt path: expect $PREBUILT_BINARY to exist in the build context.
From efc096a98f1291fb10287e962d59ed9e546da7dd Mon Sep 17 00:00:00 2001
From: joshrmcdaniel <80354972+joshrmcdaniel@users.noreply.github.com>
Date: Sat, 30 May 2026 15:40:12 -0500
Subject: [PATCH 6/6] lint rule
---
filterlists-api/src/lib.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/filterlists-api/src/lib.rs b/filterlists-api/src/lib.rs
index 9c068d7d..5d25379b 100644
--- a/filterlists-api/src/lib.rs
+++ b/filterlists-api/src/lib.rs
@@ -20,7 +20,7 @@ pub async fn get_filters() -> Result, FilterListError> {
pub async fn get_filter_information(filter: FilterArgs) -> Result {
let id = match filter {
FilterArgs::U32(id) => id,
- FilterArgs::Filter(filter) => filter.id.clone(),
+ FilterArgs::Filter(filter) => filter.id,
};
_get::(&format!("{FILTERLISTS_API_URL}/lists/{id}")).await
}