Skip to content

Commit 69137e3

Browse files
madeyeclaude
andcommitted
Return 407 for proxy requests with missing auth instead of 404
Chrome needs a 407 Proxy-Authenticate challenge to trigger credential sending. Non-proxy requests (no CONNECT, no absolute URI) still get stealth nginx 404, so scanners can't distinguish the proxy from a normal web server. Add Chrome auth challenge test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eeeedf9 commit 69137e3

5 files changed

Lines changed: 82 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Stealth HTTPS forward proxy that auto-obtains TLS certs via ACME/Let's Encrypt a
2727

2828
### Key Design Decisions
2929

30-
- **Stealth over standards**: Auth failures return 404, not 407. The proxy is indistinguishable from a misconfigured nginx to scanners.
30+
- **Stealth for non-proxy traffic**: Non-proxy requests (no absolute URI, no CONNECT) return nginx 404. Proxy requests with missing/wrong auth get 407 so real clients (Chrome) can authenticate.
3131
- **hyper 1.x with upgrades**: `http1::Builder` must use `.with_upgrades()` for CONNECT tunneling to work.
3232
- **Proxy detection**: `req.uri().authority().is_some()` (absolute URI) or `Method::CONNECT`.
3333
- **ACME on port 443 only**: Uses TLS-ALPN-01 challenge type, no port 80 listener needed.

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub async fn handle_request(
4848
let auth_ok = auth::check_proxy_auth(&req, &config.users);
4949

5050
if !auth_ok {
51-
return Ok(stealth::fake_404(&config.stealth.server_name));
51+
return Ok(stealth::proxy_auth_required(&config.stealth.server_name));
5252
}
5353

5454
if req.method() == Method::CONNECT {

src/stealth.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
//! Stealth layer that hides the proxy from scanners and browsers.
22
//!
3-
//! Non-proxy traffic (no absolute URI, no `CONNECT`) and failed auth both
4-
//! receive an identical nginx-style 404 response, making the proxy
5-
//! indistinguishable from a misconfigured web server.
3+
//! Non-proxy traffic (no absolute URI, no `CONNECT`) receives a nginx-style
4+
//! 404 response, making the proxy indistinguishable from a misconfigured web
5+
//! server. Proxy requests with missing/invalid auth get a `407` so that
6+
//! real clients (e.g. Chrome) can send credentials.
67
78
use http_body_util::Full;
89
use hyper::body::{Bytes, Incoming};
@@ -43,3 +44,18 @@ pub fn fake_404(server_name: &str) -> Response<Full<Bytes>> {
4344
.body(Full::new(Bytes::from(body)))
4445
.unwrap()
4546
}
47+
48+
/// Build a 407 response requesting proxy authentication.
49+
///
50+
/// Sent when a proxy request (CONNECT or absolute URI) arrives without
51+
/// valid credentials. The `Proxy-Authenticate` header tells clients like
52+
/// Chrome to prompt for or resend credentials.
53+
pub fn proxy_auth_required(server_name: &str) -> Response<Full<Bytes>> {
54+
Response::builder()
55+
.status(StatusCode::PROXY_AUTHENTICATION_REQUIRED)
56+
.header("Server", server_name)
57+
.header("Proxy-Authenticate", "Basic realm=\"proxy\"")
58+
.header("Content-Length", "0")
59+
.body(Full::new(Bytes::new()))
60+
.unwrap()
61+
}

tests/chrome_tests.rs

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ mod common;
22

33
use common::echo_server::EchoServer;
44
use common::test_server::TestServer;
5+
use https_proxy::config::UserConfig;
6+
7+
fn test_users() -> Vec<UserConfig> {
8+
vec![UserConfig {
9+
username: "testuser".to_string(),
10+
password: "testpass".to_string(),
11+
}]
12+
}
13+
514
fn chrome_path() -> Option<String> {
615
// Fixed paths (macOS)
716
let candidates = [
@@ -31,9 +40,9 @@ fn chrome_path() -> Option<String> {
3140
None
3241
}
3342

34-
/// Chrome fetches an HTTP URL through the HTTPS proxy (HTTP forward path).
43+
/// Chrome fetches an HTTP URL through the HTTPS proxy (no-auth, HTTP forward path).
3544
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
36-
async fn test_chrome_http_forward_through_proxy() {
45+
async fn test_chrome_http_forward_no_auth() {
3746
let chrome = match chrome_path() {
3847
Some(p) => p,
3948
None => {
@@ -74,10 +83,9 @@ async fn test_chrome_http_forward_through_proxy() {
7483
);
7584
}
7685

77-
/// Chrome sends CONNECT for HTTPS URLs through the proxy.
78-
/// Tests that HTTP/2 CONNECT tunneling works with enable_connect_protocol().
86+
/// Chrome sends CONNECT for HTTPS URLs (no-auth, tests H2 CONNECT tunnel).
7987
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
80-
async fn test_chrome_https_connect_through_proxy() {
88+
async fn test_chrome_https_connect_no_auth() {
8189
let chrome = match chrome_path() {
8290
Some(p) => p,
8391
None => {
@@ -87,7 +95,6 @@ async fn test_chrome_https_connect_through_proxy() {
8795
};
8896

8997
let server = TestServer::start_no_auth().await;
90-
9198
let proxy_url = server.proxy_url();
9299

93100
let output = tokio::task::spawn_blocking(move || {
@@ -121,3 +128,46 @@ async fn test_chrome_https_connect_through_proxy() {
121128
&stdout[..stdout.len().min(500)]
122129
);
123130
}
131+
132+
/// When auth is required, Chrome gets a 407 Proxy-Authenticate challenge.
133+
/// Headless Chrome can't complete the auth handshake without extensions,
134+
/// so we verify it receives 407 (not 404) which enables interactive auth.
135+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
136+
async fn test_chrome_gets_407_when_auth_required() {
137+
let chrome = match chrome_path() {
138+
Some(p) => p,
139+
None => {
140+
eprintln!("Chrome/Chromium not found, skipping");
141+
return;
142+
}
143+
};
144+
145+
let server = TestServer::start(test_users()).await;
146+
let proxy_url = server.proxy_url();
147+
148+
let output = tokio::task::spawn_blocking(move || {
149+
std::process::Command::new(&chrome)
150+
.arg("--headless=new")
151+
.arg("--disable-gpu")
152+
.arg("--no-sandbox")
153+
.arg("--disable-software-rasterizer")
154+
.arg("--timeout=10000")
155+
.arg(format!("--proxy-server={proxy_url}"))
156+
.arg("--ignore-certificate-errors")
157+
.arg("--dump-dom")
158+
.arg("https://example.com/")
159+
.output()
160+
.unwrap()
161+
})
162+
.await
163+
.unwrap();
164+
165+
let stdout = String::from_utf8_lossy(&output.stdout);
166+
167+
// Chrome should show ERR_PROXY_AUTH_REQUESTED (got 407), not
168+
// ERR_TUNNEL_CONNECTION_FAILED (which would mean the proxy is broken).
169+
assert!(
170+
!stdout.contains("ERR_TUNNEL_CONNECTION_FAILED"),
171+
"Should get auth challenge, not tunnel failure"
172+
);
173+
}

tests/curl_tests.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async fn test_curl_forward_through_proxy() {
6262
}
6363

6464
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
65-
async fn test_curl_no_auth_gets_404() {
65+
async fn test_curl_no_auth_gets_407() {
6666
if !curl_available() {
6767
eprintln!("curl not found, skipping");
6868
return;
@@ -94,5 +94,8 @@ async fn test_curl_no_auth_gets_404() {
9494
.unwrap();
9595

9696
let status_code = String::from_utf8_lossy(&output.stdout);
97-
assert_eq!(status_code, "404", "missing auth should get stealth 404");
97+
assert_eq!(
98+
status_code, "407",
99+
"missing auth on proxy request should get 407"
100+
);
98101
}

0 commit comments

Comments
 (0)