Skip to content

Commit 218d6f1

Browse files
authored
Merge pull request #214 from Gravirei/fix/issue-211-icaptcha-mock-consumed-guard-gaps
test(icaptcha): harden mock-consumed guard with proxy observer and blackhole tripwire
2 parents 817698a + dc6b19d commit 218d6f1

3 files changed

Lines changed: 381 additions & 37 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//! Tripwire: a committed negative case that locks the blackhole mechanism.
2+
//!
3+
//! The guard against non-loopback egress works because `obtain_proof` builds a
4+
//! stock `reqwest::blocking::Client` that inherits the process's proxy env vars.
5+
//! If the builder later gains `.no_proxy()` or a custom connector, the guard
6+
//! disarms silently and every existing test stays green (no live call is made
7+
//! during the normal mock-consumed run because the mock is on loopback and
8+
//! `NO_PROXY` covers it — the blackhole isn't even exercised).
9+
//!
10+
//! This test asserts that a destination the blackhole's `NO_PROXY` does NOT
11+
//! cover fails *while the blackhole is armed*. The destination is a local HTTP
12+
//! server on `[::1]` (IPv6 loopback), which `NO_PROXY` (`127.0.0.1, localhost`)
13+
//! does not list. With the blackhole active the proxy intercepts the
14+
//! connection and blocks it; without the blackhole the request would reach the
15+
//! server directly and succeed, turning this test RED.
16+
//!
17+
//! A positive control (disarmed blackhole) runs first, proving the fixture is
18+
//! valid and `[::1]` is reachable.
19+
//!
20+
//! Design constraints (see issue #211):
21+
//! - An unresolvable host would keep the request failing even when the guard
22+
//! is disarmed (DNS failure masquerading as the blackhole), so we must use
23+
//! a reachable address.
24+
//! - A real external host would make a live network call the moment the guard
25+
//! disarms, and on any runner that cannot reach that host the connect error
26+
//! again masquerades as the blackhole. A local loopback address avoids both
27+
//! problems — it is always reachable when unblocked and never reaches the
28+
//! real network.
29+
//! - The address must NOT be covered by `NO_PROXY`, yet must exist on every
30+
//! platform. An arbitrary `127/8` alias is not portable: macOS only binds
31+
//! `127.0.0.1` by default. `[::1]` (IPv6 loopback) is present by default
32+
//! on Linux, macOS, and Windows, and `NO_PROXY` only lists `127.0.0.1` and
33+
//! `localhost`, so the blackhole still intercepts it.
34+
//! - This depends on hyper-util's proxy matcher having no implicit loopback
35+
//! bypass — a host is only proxied-around when literally listed in
36+
//! `NO_PROXY` (verified in the pinned hyper-util; recheck on upgrade).
37+
38+
use std::io::{Read, Write};
39+
use std::net::TcpListener;
40+
use std::thread;
41+
42+
use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg};
43+
44+
mod support;
45+
46+
/// A minimal HTTP server that responds to iCaptcha challenge and answer
47+
/// requests on the IPv6 loopback address NOT covered by NO_PROXY.
48+
///
49+
/// When the blackhole is working, the proxy should intercept these requests
50+
/// and `obtain_proof` should fail. When the blackhole is disarmed, the
51+
/// requests reach this server and the flow succeeds.
52+
fn serve_icaptcha(listener: TcpListener) {
53+
thread::spawn(move || {
54+
for stream in listener.incoming() {
55+
let Ok(mut stream) = stream else { continue };
56+
// Read until the first \n (end of request line) so TCP
57+
// segmentation cannot split the path out of a single read.
58+
let mut buf = [0u8; 4096];
59+
let mut n = 0usize;
60+
while n < buf.len() {
61+
match stream.read(&mut buf[n..]) {
62+
Ok(0) | Err(_) => break,
63+
Ok(read) => {
64+
n += read;
65+
if buf[..n].contains(&b'\n') {
66+
break;
67+
}
68+
}
69+
}
70+
}
71+
if n == 0 {
72+
continue;
73+
}
74+
let request = String::from_utf8_lossy(&buf[..n]);
75+
let body = if request.contains("/v1/answer") {
76+
r#"{"status":"passed","proof":"PROOF-TRIP"}"#
77+
} else {
78+
r#"{"challengeId":"c1","type":"anagram","difficulty":1,"prompt":"listen","token":"tok-1"}"#
79+
};
80+
let response = format!(
81+
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
82+
body.len(),
83+
);
84+
let _ = stream.write_all(response.as_bytes());
85+
}
86+
});
87+
}
88+
89+
fn make_cfg(url: &str) -> IcaptchaCfg {
90+
IcaptchaCfg {
91+
url: url.to_string(),
92+
did: "did:key:zTEST".to_string(),
93+
level: 1,
94+
api_key: None,
95+
}
96+
}
97+
98+
#[test]
99+
fn obtain_proof_blackhole_tripwire() {
100+
// Bind to [::1], IPv6 loopback, which NO_PROXY (127.0.0.1, localhost)
101+
// does not cover. Unlike an arbitrary 127/8 alias, IPv6 loopback is
102+
// present by default on Linux, macOS, and Windows, so the fixture is
103+
// portable across platforms. Fail loudly if it cannot be bound: a
104+
// silent skip would report this tripwire as green while exercising
105+
// none of the negative control, recreating the very gap it exists to
106+
// close.
107+
let listener = TcpListener::bind(("::1", 0)).expect(
108+
"cannot bind [::1] for the blackhole tripwire. IPv6 loopback (::1) \
109+
is present by default on Linux, macOS, and Windows. This test fails \
110+
rather than silently skipping so a platform without it cannot count \
111+
the negative control as passed.",
112+
);
113+
let port = listener.local_addr().unwrap().port();
114+
let url = format!("http://[::1]:{port}");
115+
serve_icaptcha(listener);
116+
117+
// ── Positive control: disarmed blackhole → obtain_proof succeeds ──────
118+
let _disarmed_guard = support::disarm_proxy_env();
119+
120+
let solve: &dyn Fn(&Challenge) -> Option<String> = &|_c| Some("silent".to_string());
121+
let cfg = make_cfg(&url);
122+
let proof = obtain_proof(&cfg, Some(solve))
123+
.expect("positive control: obtain_proof should succeed when the proxy is disarmed");
124+
assert_eq!(
125+
proof, "PROOF-TRIP",
126+
"positive control: unexpected proof value"
127+
);
128+
drop(_disarmed_guard);
129+
130+
// ── Negative control: armed blackhole → obtain_proof fails with connect error ──
131+
let _armed_guard = support::arm_blackhole("http://127.0.0.1:1");
132+
133+
let result = obtain_proof(&cfg, Some(solve));
134+
135+
let err = result.expect_err(
136+
"blackhole tripwire: obtain_proof succeeded against [::1], \
137+
meaning the proxy blackhole did not intercept the request; \
138+
the no-live-call guard is disarmed",
139+
);
140+
assert!(
141+
err.chain().any(|c| c
142+
.downcast_ref::<reqwest::Error>()
143+
.is_some_and(|e| e.is_connect())),
144+
"expected a connect/proxy error, got: {err:#}",
145+
);
146+
}
Lines changed: 142 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! INV-19 guard: the icaptcha flow must actually call the service (consume the
1+
//! INV-19 guard: the icaptcha flow must actually call the service (consuming the
22
//! mock), not short-circuit or make a live call. If a future change let
33
//! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which
44
//! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go
@@ -8,50 +8,144 @@
88
//! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or
99
//! any other origin (a different host the mock never observes). To make the
1010
//! "no live call" half of the guard load-bearing, the test blackholes every
11-
//! non-loopback destination: `NO_PROXY` lets the loopback mock through while a
12-
//! closed proxy port routes any other host nowhere, so the client contacting
13-
//! anything but the injected mock fails the request instead of silently
14-
//! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED.
11+
//! non-loopback destination via a proxy observer: `NO_PROXY` lets the loopback
12+
//! mock through while every proxy variable routes any other host through a TCP
13+
//! listener that counts connections. Zero observed connections proves no leak
14+
//! reached the network on the exercised path.
15+
//!
16+
//! The observer design counts every accepted connection regardless of whether
17+
//! the caller propagates the transport error, so it catches leaks that a
18+
//! closed-port blackhole (which only surfaces propagating errors) would miss.
19+
//! To close the timing race inherent in a detached accept loop, the count is
20+
//! read only after a bounded drain: the test opens a flush connection and
21+
//! blocks until the accept thread has accepted *that specific connection*
22+
//! (tagged by its source port), not merely until any next counter tick.
23+
//! Because `accept()` hands out connections FIFO, every leak connection that
24+
//! completed its TCP handshake before the flush is counted before the flush —
25+
//! the assertion therefore covers all egress in flight when `obtain_proof`
26+
//! returned. A leak that starts its handshake after the drain is outside the
27+
//! window; that is the irreducible limit of observing a detached accept loop,
28+
//! and it is the remaining, well-defined gap this guard accepts.
29+
//!
30+
//! SCOPE: The observer only witnesses leaks issued on the path this test drives
31+
//! (a single passing round, 200 on both endpoints, no PoW, no Continue/Failed,
32+
//! no non-2xx response). A leak living on an error or retry branch makes no
33+
//! connection during the run and the zero-count assertion stays green. The
34+
//! guarantee covers only the exercised happy path and proxy-honoring transports;
35+
//! a future `.no_proxy()` or raw `TcpStream` bypasses the observer entirely.
1536
//!
1637
//! `ALL_PROXY` alone is NOT enough: reqwest honors the scheme-specific
1738
//! `HTTPS_PROXY` / `https_proxy` (and the http equivalents) OVER `ALL_PROXY`, so
1839
//! a runner with a working `HTTPS_PROXY` in its environment would route an https
1940
//! leak to `DEFAULT_URL` (which is https) through that proxy while the loopback
20-
//! mock is still consumed and this test stayed green. So we blackhole the
21-
//! scheme-specific variables too. No restore is needed: this file has a single
22-
//! `#[test]`, so it runs in its own process and the override never races a
23-
//! sibling test.
41+
//! mock is still consumed and this test stayed green. So we blackhole the
42+
//! scheme-specific variables too.
43+
44+
use std::net::{TcpListener, TcpStream};
45+
use std::sync::{Arc, Condvar, Mutex};
2446

2547
use icaptcha_client::{obtain_proof, Challenge, IcaptchaCfg};
2648

49+
mod support;
50+
51+
/// A TCP listener that counts every accepted connection.
52+
///
53+
/// Acts as the proxy target: any leaked connection to a non-loopback
54+
/// destination will be routed through this listener and counted. Every
55+
/// accepted connection is recorded by its source port so the test's own
56+
/// flush connection ([`ProxyObserver::drain`]) can be recognised and
57+
/// excluded from the leak count.
58+
struct ProxyObserver {
59+
_listener: Arc<TcpListener>,
60+
port: u16,
61+
/// Source ports of every accepted connection, in accept order.
62+
accepted: Arc<Mutex<Vec<u16>>>,
63+
/// Source ports of the test's own flush connections (never counted).
64+
flush_ports: Arc<Mutex<Vec<u16>>>,
65+
/// Signalled after each accept so `drain` can wait on the flush.
66+
ack: Arc<Condvar>,
67+
}
68+
69+
impl ProxyObserver {
70+
fn bind() -> Self {
71+
let listener = Arc::new(TcpListener::bind("127.0.0.1:0").expect("bind proxy observer"));
72+
let port = listener.local_addr().unwrap().port();
73+
let accepted = Arc::new(Mutex::new(Vec::new()));
74+
let flush_ports = Arc::new(Mutex::new(Vec::new()));
75+
let ack = Arc::new(Condvar::new());
76+
let a = Arc::clone(&accepted);
77+
let c = Arc::clone(&ack);
78+
let l = Arc::clone(&listener);
79+
std::thread::spawn(move || {
80+
for stream in l.incoming() {
81+
let Ok(stream) = stream else { continue };
82+
a.lock()
83+
.unwrap()
84+
.push(stream.peer_addr().map(|a| a.port()).unwrap_or(0));
85+
c.notify_all();
86+
}
87+
});
88+
ProxyObserver {
89+
_listener: listener,
90+
port,
91+
accepted,
92+
flush_ports,
93+
ack,
94+
}
95+
}
96+
97+
fn port(&self) -> u16 {
98+
self.port
99+
}
100+
101+
/// Number of accepted connections that are not the test's own flush
102+
/// connections — i.e. the leak count.
103+
fn count(&self) -> usize {
104+
let flush = self.flush_ports.lock().unwrap();
105+
let accepted = self.accepted.lock().unwrap();
106+
accepted.iter().filter(|p| !flush.contains(p)).count()
107+
}
108+
109+
/// Bounded acknowledgement barrier for the accept loop.
110+
///
111+
/// Opens a flush connection and blocks until the accept thread has
112+
/// accepted *that connection* (identified by its source port), bounded by
113+
/// a timeout. Because `accept()` hands out connections FIFO, once the
114+
/// flush is accepted every connection that completed its handshake before
115+
/// it has been accepted and recorded too — so counting on afterwards
116+
/// cannot miss a leak that was already in flight. The flush connection
117+
/// itself is never counted.
118+
///
119+
/// A counter-tick barrier would be unsound here: sampling "current + 1"
120+
/// and waiting for any increment lets a leak accepted after the sample
121+
/// satisfy the wait, and the flush is then miscounted as that leak.
122+
fn drain(&self) {
123+
let flush =
124+
TcpStream::connect(("127.0.0.1", self.port)).expect("observer flush connection");
125+
let flush_port = flush.local_addr().unwrap().port();
126+
self.flush_ports.lock().unwrap().push(flush_port);
127+
let mut accepted = self.accepted.lock().unwrap();
128+
while !accepted.contains(&flush_port) {
129+
let (guard, timed_out) = self
130+
.ack
131+
.wait_timeout(accepted, std::time::Duration::from_secs(5))
132+
.unwrap();
133+
accepted = guard;
134+
assert!(
135+
!timed_out.timed_out(),
136+
"proxy observer drain timed out; accept loop stalled"
137+
);
138+
}
139+
}
140+
}
141+
27142
#[test]
28143
fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
29-
// Blackhole every non-loopback origin: the loopback mock bypasses the proxy
30-
// (NO_PROXY); any other host is forced through a closed port and fails.
31-
// reqwest reads these at client-build time, and obtain_proof builds its
32-
// client fresh, so this bounds the transport for the whole flow.
33-
//
34-
// Set the scheme-specific vars too (both cases), not just ALL_PROXY: reqwest
35-
// gives HTTPS_PROXY/https_proxy precedence over ALL_PROXY for https URLs, so
36-
// an ALL_PROXY-only blackhole leaves an https leak to DEFAULT_URL open on a
37-
// runner whose environment already has a working HTTPS_PROXY.
38-
let blackhole = "http://127.0.0.1:1";
39-
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
40-
std::env::set_var("no_proxy", "127.0.0.1,localhost");
41-
for var in [
42-
"ALL_PROXY",
43-
"all_proxy",
44-
"HTTPS_PROXY",
45-
"https_proxy",
46-
"HTTP_PROXY",
47-
"http_proxy",
48-
] {
49-
std::env::set_var(var, blackhole);
50-
}
144+
let observer = ProxyObserver::bind();
145+
let _guard = support::arm_blackhole(&format!("http://127.0.0.1:{}", observer.port()));
51146

52147
let mut server = mockito::Server::new();
53148

54-
// The two endpoints the flow hits, each expected exactly once.
55149
let challenge = server
56150
.mock("POST", "/v1/challenge")
57151
.with_status(200)
@@ -77,18 +171,29 @@ fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
77171
api_key: None,
78172
};
79173

80-
// "anagram" is not a built-in solvable type, so the flow consults this
81-
// solver callback. The answer body itself does not matter: the mocked
82-
// /v1/answer returns "passed" regardless of what is submitted.
83174
let solve = |_c: &Challenge| Some("silent".to_string());
84175
let solver: &dyn Fn(&Challenge) -> Option<String> = &solve;
85176

177+
let count_before = observer.count();
178+
86179
let proof = obtain_proof(&cfg, Some(solver))
87180
.expect("obtain_proof should complete against the mocked service");
88181
assert_eq!(proof, "PROOF-XYZ");
89182

90-
// INV-19: prove the client actually called the mocked endpoints. Had it made
91-
// a live call or skipped the network, these would fail (mock never hit).
92183
challenge.assert();
93184
answer.assert();
185+
186+
// Drain the accept loop before asserting: the flush connection is not
187+
// counted, and because accept() is FIFO every leak connection that
188+
// completed its handshake before the flush is recorded before the flush
189+
// is — so the count after drain reflects all egress in flight during the
190+
// call.
191+
observer.drain();
192+
let leak_count = observer.count() - count_before;
193+
194+
assert_eq!(
195+
leak_count, 0,
196+
"proxy observer caught leaked connections; \
197+
the blackhole failed to contain egress"
198+
);
94199
}

0 commit comments

Comments
 (0)