Skip to content

Commit 8def1ca

Browse files
committed
fix(gl): refuse signed requests to a plaintext remote
1 parent bfc44f9 commit 8def1ca

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

‎crates/gl/src/http.rs‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
2020
const 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

Comments
 (0)