@@ -19,6 +19,74 @@ const MAX_ICAPTCHA_RETRIES: usize = 2;
1919/// response body, so it bounds a slow download and not just a slow handshake.
2020const TOTAL_REQUEST_TIMEOUT : std:: time:: Duration = std:: time:: Duration :: from_secs ( 30 ) ;
2121
22+ /// Whether `url` would send a signed request off this machine in cleartext.
23+ ///
24+ /// True only for `http://` to a non-loopback host. RFC 9421 signs the request
25+ /// but does not encrypt it, so on a plaintext hop the `Signature` header and
26+ /// the body are both readable, and a captured signature is replayable for its
27+ /// freshness window against any host.
28+ ///
29+ /// Loopback is decided from the parsed address rather than a string match, so
30+ /// `127.0.0.2`, `[::1]` and an IPv4-mapped `[::ffff:127.0.0.1]` are all
31+ /// recognised as this machine. A value that does not parse, or that is not
32+ /// http(s), is not this guard's business and returns false: it fails later with
33+ /// its own error, and naming it a TLS problem would misdirect the reader.
34+ fn is_insecure_remote ( url : & str ) -> bool {
35+ let Ok ( parsed) = reqwest:: Url :: parse ( url. trim ( ) ) else {
36+ return false ;
37+ } ;
38+ if parsed. scheme ( ) != "http" {
39+ return false ;
40+ }
41+ let Some ( host) = parsed. host_str ( ) else {
42+ return false ;
43+ } ;
44+ let host = host. trim_end_matches ( '.' ) . to_ascii_lowercase ( ) ;
45+ if host == "localhost" {
46+ return false ;
47+ }
48+ // host_str() keeps the brackets on an IPv6 literal.
49+ let bare = host. trim_start_matches ( '[' ) . trim_end_matches ( ']' ) ;
50+ if let Ok ( ip) = bare. parse :: < std:: net:: IpAddr > ( ) {
51+ if ip. is_loopback ( ) {
52+ return false ;
53+ }
54+ // An IPv4-mapped IPv6 literal hides a v4 loopback from is_loopback().
55+ if let std:: net:: IpAddr :: V6 ( v6) = ip {
56+ if let Some ( v4) = v6. to_ipv4_mapped ( ) {
57+ if v4. is_loopback ( ) {
58+ return false ;
59+ }
60+ }
61+ }
62+ }
63+ true
64+ }
65+
66+ /// Refuse to sign a request destined for a cleartext hop off this machine
67+ /// unless the operator has opted in. `GITLAWB_ALLOW_INSECURE_HTTP` exists for a
68+ /// private LAN where the operator has decided that is acceptable; its presence
69+ /// alone opts in, matching git-remote-gitlawb's guard for the same hop.
70+ fn ensure_signing_transport ( node_base : & str , allow_insecure : bool ) -> Result < ( ) > {
71+ if allow_insecure || !is_insecure_remote ( node_base) {
72+ return Ok ( ( ) ) ;
73+ }
74+ anyhow:: bail!(
75+ "refusing to send a signed request to {node_base} over plaintext http.\n \
76+ The request is signed but not encrypted, so the Signature header and \
77+ the body are readable by anyone on the path, and a captured signature \
78+ can be replayed.\n \
79+ Use https://, or set GITLAWB_ALLOW_INSECURE_HTTP=1 to accept the risk \
80+ (for a trusted private network only)."
81+ )
82+ }
83+
84+ /// The operator opt-in for a plaintext remote, read at request time so a test
85+ /// or shell can set it without rebuilding the client.
86+ fn insecure_http_allowed ( ) -> bool {
87+ std:: env:: var_os ( "GITLAWB_ALLOW_INSECURE_HTTP" ) . is_some ( )
88+ }
89+
2290/// Follow a redirect only when it stays on the origin that issued it AND re-issues the
2391/// identical request-target, and only for as long as the chain bound allows.
2492///
@@ -115,6 +183,7 @@ impl NodeClient {
115183 . keypair
116184 . as_ref ( )
117185 . context ( "get_signed requires an identity keypair" ) ?;
186+ ensure_signing_transport ( & self . node_url , insecure_http_allowed ( ) ) ?;
118187 let signed = sign_request ( kp, "GET" , path, b"" ) ;
119188 let req = self
120189 . inner
@@ -133,6 +202,7 @@ impl NodeClient {
133202 let url = format ! ( "{}{}" , self . node_url, path) ;
134203 let mut req = self . inner . get ( & url) ;
135204 if let Some ( kp) = & self . keypair {
205+ ensure_signing_transport ( & self . node_url , insecure_http_allowed ( ) ) ?;
136206 let signed = sign_request ( kp, "GET" , path, b"" ) ;
137207 req = req
138208 . header ( "Content-Digest" , signed. content_digest )
@@ -214,6 +284,7 @@ impl NodeClient {
214284 . body ( body. to_vec ( ) ) ;
215285
216286 if let Some ( kp) = & self . keypair {
287+ ensure_signing_transport ( & self . node_url , insecure_http_allowed ( ) ) ?;
217288 let signed = sign_request ( kp, method, path, body) ;
218289 req = req
219290 . header ( "Content-Digest" , signed. content_digest )
@@ -1268,4 +1339,130 @@ mod tests {
12681339 let out = sanitize_node_msg ( "ok \u{0627} \u{200D} b" ) ;
12691340 assert_eq ! ( out, "ok \u{0627} \u{200D} b" ) ;
12701341 }
1342+
1343+ // ── #413 plaintext-remote signing guard ──────────────────────────────
1344+
1345+ /// Holds [`ICAPTCHA_ENV_LOCK`] while it points `GITLAWB_ALLOW_INSECURE_HTTP`
1346+ /// at `value` (or removes it), restoring the prior value on drop.
1347+ struct InsecureHttpEnv {
1348+ _lock : MutexGuard < ' static , ( ) > ,
1349+ prev : Option < OsString > ,
1350+ }
1351+
1352+ impl InsecureHttpEnv {
1353+ fn set ( value : Option < & str > ) -> Self {
1354+ let lock = ICAPTCHA_ENV_LOCK . lock ( ) . unwrap ( ) ;
1355+ let prev = std:: env:: var_os ( "GITLAWB_ALLOW_INSECURE_HTTP" ) ;
1356+ match value {
1357+ Some ( v) => std:: env:: set_var ( "GITLAWB_ALLOW_INSECURE_HTTP" , v) ,
1358+ None => std:: env:: remove_var ( "GITLAWB_ALLOW_INSECURE_HTTP" ) ,
1359+ }
1360+ InsecureHttpEnv { _lock : lock, prev }
1361+ }
1362+ }
1363+
1364+ impl Drop for InsecureHttpEnv {
1365+ fn drop ( & mut self ) {
1366+ match self . prev . take ( ) {
1367+ Some ( v) => std:: env:: set_var ( "GITLAWB_ALLOW_INSECURE_HTTP" , v) ,
1368+ None => std:: env:: remove_var ( "GITLAWB_ALLOW_INSECURE_HTTP" ) ,
1369+ }
1370+ }
1371+ }
1372+
1373+ #[ test]
1374+ fn insecure_remote_flags_only_plaintext_off_machine ( ) {
1375+ // Loopback of every spelling is this machine and exempt.
1376+ for url in [
1377+ "http://localhost" ,
1378+ "http://localhost:7545" ,
1379+ "http://127.0.0.1:7545" ,
1380+ "http://127.0.0.2" ,
1381+ "http://[::1]" ,
1382+ "http://[::ffff:127.0.0.1]" ,
1383+ // https to anywhere is encrypted; the guard is not its business.
1384+ "https://node.gitlawb.com" ,
1385+ // Not a URL: unparseable input fails later with its own error.
1386+ "not a url" ,
1387+ ] {
1388+ assert ! ( !is_insecure_remote( url) , "{url} must not be flagged" ) ;
1389+ }
1390+ // Plaintext http off this machine is the flagged shape.
1391+ for url in [
1392+ "http://10.0.0.36:7777" ,
1393+ "http://node.example.com" ,
1394+ "http://[fd00::1]" ,
1395+ "http://127.0.0.1.evil.example" ,
1396+ ] {
1397+ assert ! ( is_insecure_remote( url) , "{url} must be flagged" ) ;
1398+ }
1399+ }
1400+
1401+ #[ test]
1402+ fn signing_transport_refuses_plaintext_remote ( ) {
1403+ let err = ensure_signing_transport ( "http://10.0.0.36:7777" , false ) . unwrap_err ( ) ;
1404+ assert ! (
1405+ err. to_string( ) . contains( "plaintext http" )
1406+ && err. to_string( ) . contains( "GITLAWB_ALLOW_INSECURE_HTTP" ) ,
1407+ "the refusal names the risk and the opt-in: {err}"
1408+ ) ;
1409+ // Opted in, loopback, and https all pass.
1410+ assert ! ( ensure_signing_transport( "http://10.0.0.36:7777" , true ) . is_ok( ) ) ;
1411+ assert ! ( ensure_signing_transport( "http://localhost:7545" , false ) . is_ok( ) ) ;
1412+ assert ! ( ensure_signing_transport( "https://node.gitlawb.com" , false ) . is_ok( ) ) ;
1413+ }
1414+
1415+ /// A signed call against a plaintext remote errors out before any request
1416+ /// leaves; the refusal must beat the send. `.invalid` NXDOMAINs fast, so a
1417+ /// missed guard surfaces as a DNS error rather than a slow timeout.
1418+ #[ tokio:: test]
1419+ async fn get_signed_refuses_plaintext_remote_before_sending ( ) {
1420+ let _env = InsecureHttpEnv :: set ( None ) ;
1421+ let client = NodeClient :: new ( "http://gl-nonexistent-node.invalid" , Some ( test_keypair ( ) ) ) ;
1422+ let err = client. get_signed ( "/api/v1/x" ) . await . unwrap_err ( ) ;
1423+ assert ! (
1424+ err. to_string( ) . contains( "plaintext http" ) ,
1425+ "expected the transport refusal, got: {err}"
1426+ ) ;
1427+ }
1428+
1429+ /// With the opt-in set, the same call passes the guard and fails later on
1430+ /// the unresolvable host, proving the env var unblocks the send rather than
1431+ /// merely being read.
1432+ #[ tokio:: test]
1433+ async fn get_signed_proceeds_when_insecure_http_opted_in ( ) {
1434+ let _env = InsecureHttpEnv :: set ( Some ( "1" ) ) ;
1435+ let client = NodeClient :: new ( "http://gl-nonexistent-node.invalid" , Some ( test_keypair ( ) ) ) ;
1436+ let err = client. get_signed ( "/api/v1/x" ) . await . unwrap_err ( ) ;
1437+ assert ! (
1438+ !err. to_string( ) . contains( "plaintext http" ) ,
1439+ "the opt-in should let the request attempt the hop, got: {err}"
1440+ ) ;
1441+ }
1442+
1443+ /// Unsigned calls are not this guard's business: no keypair means no
1444+ /// signature to leak, and the request proceeds to its own failure.
1445+ #[ tokio:: test]
1446+ async fn unsigned_call_to_plaintext_remote_is_ungated ( ) {
1447+ let _env = InsecureHttpEnv :: set ( None ) ;
1448+ let client = NodeClient :: new ( "http://gl-nonexistent-node.invalid" , None ) ;
1449+ let err = client. post ( "/api/v1/x" , b"{}" ) . await . unwrap_err ( ) ;
1450+ assert ! (
1451+ !err. to_string( ) . contains( "plaintext http" ) ,
1452+ "an unsigned request should not hit the signing guard, got: {err}"
1453+ ) ;
1454+ }
1455+
1456+ /// The opt-in is presence-based, matching the remote helper's read.
1457+ #[ test]
1458+ fn insecure_http_allowed_reads_the_env_var ( ) {
1459+ {
1460+ let _env = InsecureHttpEnv :: set ( Some ( "1" ) ) ;
1461+ assert ! ( insecure_http_allowed( ) ) ;
1462+ }
1463+ {
1464+ let _env = InsecureHttpEnv :: set ( None ) ;
1465+ assert ! ( !insecure_http_allowed( ) ) ;
1466+ }
1467+ }
12711468}
0 commit comments