diff --git a/crates/gitlawb-node/src/api/profiles.rs b/crates/gitlawb-node/src/api/profiles.rs index 6f3a12a9..4e42c6e4 100644 --- a/crates/gitlawb-node/src/api/profiles.rs +++ b/crates/gitlawb-node/src/api/profiles.rs @@ -98,7 +98,7 @@ pub async fn set_profile( &state.http_client, &state.config.pinata_upload_url, &state.config.pinata_jwt, - &format!("profile-{}", &did), + &format!("profile-{}", did), &profile_json, ) .await diff --git a/crates/gl/src/http.rs b/crates/gl/src/http.rs index 8cd7c97e..dc9f45da 100644 --- a/crates/gl/src/http.rs +++ b/crates/gl/src/http.rs @@ -185,3 +185,389 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result { .await .context("iCaptcha solver task panicked")? } + +#[cfg(test)] +mod tests { + use super::*; + use gitlawb_core::identity::Keypair; + use mockito::Server; + use std::ffi::OsString; + use std::sync::{Mutex, MutexGuard}; + + /// Serializes the two integration tests that touch the process-global + /// `GITLAWB_ICAPTCHA_URL` / `GITLAWB_ICAPTCHA_INSECURE` env vars so they + /// never race. + static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn test_keypair() -> Keypair { + Keypair::generate() + } + + fn headers_from_pairs(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap { + let mut h = reqwest::header::HeaderMap::new(); + for (k, v) in pairs { + h.insert( + k.parse::().unwrap(), + v.parse::().unwrap(), + ); + } + h + } + + // ── icaptcha_cfg ──────────────────────────────────────────────────── + + #[test] + fn icaptcha_cfg_returns_some_when_both_headers_present() { + let kp = test_keypair(); + let client = NodeClient::new("http://localhost", Some(kp.clone())); + let headers = headers_from_pairs(&[ + ("x-icaptcha-url", "https://icaptcha.gitlawb.com"), + ("x-icaptcha-level", "3"), + ]); + let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap(); + assert_eq!(cfg.did, kp.did().to_string()); + assert_eq!(cfg.level, 3); + } + + #[test] + fn icaptcha_cfg_defaults_level_when_only_url_present() { + let kp = test_keypair(); + let client = NodeClient::new("http://localhost", Some(kp)); + let headers = headers_from_pairs(&[("x-icaptcha-url", "https://icaptcha.gitlawb.com")]); + let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap(); + assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL); + } + + #[test] + fn icaptcha_cfg_defaults_url_when_only_level_present() { + let kp = test_keypair(); + let client = NodeClient::new("http://localhost", Some(kp)); + let headers = headers_from_pairs(&[("x-icaptcha-level", "5")]); + let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap(); + assert_eq!(cfg.level, 5); + } + + #[test] + fn icaptcha_cfg_returns_none_without_icaptcha_headers() { + let client = NodeClient::new("http://localhost", Some(test_keypair())); + let headers = reqwest::header::HeaderMap::new(); + assert!(client.icaptcha_cfg(&headers).unwrap().is_none()); + } + + #[test] + fn icaptcha_cfg_returns_none_with_unrelated_headers() { + let client = NodeClient::new("http://localhost", Some(test_keypair())); + let headers = headers_from_pairs(&[("content-type", "application/json")]); + assert!(client.icaptcha_cfg(&headers).unwrap().is_none()); + } + + #[test] + fn icaptcha_cfg_errors_when_no_keypair() { + let client = NodeClient::new("http://localhost", None); + let headers = headers_from_pairs(&[("x-icaptcha-level", "3")]); + let err = client.icaptcha_cfg(&headers).unwrap_err(); + assert!(err.to_string().contains("identity keypair")); + } + + #[test] + fn icaptcha_cfg_ignores_unparseable_level() { + let client = NodeClient::new("http://localhost", Some(test_keypair())); + let headers = headers_from_pairs(&[ + ("x-icaptcha-url", "https://icaptcha.gitlawb.com"), + ("x-icaptcha-level", "not-a-number"), + ]); + let cfg = client.icaptcha_cfg(&headers).unwrap().unwrap(); + assert_eq!(cfg.level, icaptcha_client::DEFAULT_LEVEL); + } + + // ── send_once ─────────────────────────────────────────────────────── + + #[tokio::test] + async fn send_once_attaches_proof_header_when_provided() { + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/api/test") + .match_header("x-icaptcha-proof", "test.proof.token") + .with_status(200) + .with_body("ok") + .create_async() + .await; + let client = NodeClient::new(server.url(), None); + let resp = client + .send_once("POST", "/api/test", b"{}", Some("test.proof.token")) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + m.assert(); + } + + #[tokio::test] + async fn send_once_omits_proof_header_when_not_provided() { + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/api/test") + .match_header("x-icaptcha-proof", mockito::Matcher::Missing) + .with_status(200) + .with_body("ok") + .create_async() + .await; + let client = NodeClient::new(server.url(), None); + let resp = client + .send_once("POST", "/api/test", b"{}", None) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + m.assert(); + } + + #[tokio::test] + async fn send_once_signs_request_when_keypair_present() { + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/api/test") + .match_header("Signature", mockito::Matcher::Any) + .match_header("Signature-Input", mockito::Matcher::Any) + .match_header("Content-Digest", mockito::Matcher::Any) + .with_status(200) + .with_body("ok") + .create_async() + .await; + let client = NodeClient::new(server.url(), Some(test_keypair())); + let resp = client + .send_once("POST", "/api/test", b"{}", None) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + m.assert(); + } + + #[tokio::test] + async fn send_once_does_not_sign_when_no_keypair() { + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/api/test") + .match_header("Signature", mockito::Matcher::Missing) + .match_header("Signature-Input", mockito::Matcher::Missing) + .match_header("Content-Digest", mockito::Matcher::Missing) + .with_status(200) + .with_body("ok") + .create_async() + .await; + let client = NodeClient::new(server.url(), None); + let resp = client + .send_once("POST", "/api/test", b"{}", None) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + m.assert(); + } + + // ── send_signed ───────────────────────────────────────────────────── + + #[tokio::test] + async fn send_signed_returns_non_icaptcha_403_without_retry() { + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/api/register") + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"error":"forbidden"}"#) + .create_async() + .await; + let client = NodeClient::new(server.url(), Some(test_keypair())); + let resp = client + .send_signed("POST", "/api/register", b"{}") + .await + .unwrap(); + assert_eq!(resp.status(), 403); + m.assert(); + } + + #[tokio::test] + async fn send_signed_returns_first_response_on_success() { + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/api/register") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"created"}"#) + .create_async() + .await; + let client = NodeClient::new(server.url(), Some(test_keypair())); + let resp = client + .send_signed("POST", "/api/register", b"{}") + .await + .unwrap(); + assert_eq!(resp.status(), 201); + m.assert(); + } + + #[tokio::test] + async fn send_signed_handles_405_not_icaptcha() { + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/api/register") + .with_status(405) + .with_body(r#"{"error":"method not allowed"}"#) + .create_async() + .await; + let client = NodeClient::new(server.url(), Some(test_keypair())); + let resp = client + .send_signed("POST", "/api/register", b"{}") + .await + .unwrap(); + assert_eq!(resp.status(), 405); + m.assert(); + } + + // ── send_signed iCaptcha retry (full integration) ──────────────────── + + /// Set GITLAWB_ICAPTCHA_URL and GITLAWB_ICAPTCHA_INSECURE so the iCaptcha + /// client trusts a local mockito HTTP server, restoring any prior values on + /// drop so a test run launched with those variables keeps working. + /// Holds [`ICAPTCHA_ENV_LOCK`] for its lifetime so concurrent tests don't + /// race on the process-global env vars. + struct IcaptchaEnv { + _lock: MutexGuard<'static, ()>, + prev_url: Option, + prev_insecure: Option, + } + + impl IcaptchaEnv { + fn new(url: &str) -> Self { + let lock = ICAPTCHA_ENV_LOCK.lock().unwrap(); + let prev_url = std::env::var_os("GITLAWB_ICAPTCHA_URL"); + let prev_insecure = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE"); + std::env::set_var("GITLAWB_ICAPTCHA_URL", url); + std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1"); + IcaptchaEnv { + _lock: lock, + prev_url, + prev_insecure, + } + } + } + + impl Drop for IcaptchaEnv { + fn drop(&mut self) { + match self.prev_url.take() { + Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v), + None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"), + } + match self.prev_insecure.take() { + Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v), + None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"), + } + } + } + + /// Set up a mock iCaptcha server that responds to challenge + answer. + /// `hits` sets the expected call count for both endpoints so the test can + /// verify the solve loop was entered the correct number of times. + struct MockIcaptcha { + challenge: mockito::Mock, + answer: mockito::Mock, + _guard: IcaptchaEnv, + url: String, + } + + impl MockIcaptcha { + async fn new(server: &mut mockito::ServerGuard, hits: usize) -> Self { + let url = server.url(); + let guard = IcaptchaEnv::new(&url); + let challenge = server + .mock("POST", "/v1/challenge") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"challengeId":"c1","type":"arithmetic","difficulty":1,"prompt":"What is 1 + 1?","token":"tk1"}"#, + ) + .expect(hits) + .create_async() + .await; + let answer = server + .mock("POST", "/v1/answer") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"passed","proof":"mock.proof"}"#) + .expect(hits) + .create_async() + .await; + Self { + challenge, + answer, + _guard: guard, + url, + } + } + } + + #[tokio::test] + async fn send_signed_solves_icaptcha_and_retries_to_success() { + let mut node = Server::new_async().await; + let mut icaptcha = Server::new_async().await; + let ic = MockIcaptcha::new(&mut icaptcha, 1).await; + + let n1 = node + .mock("POST", "/api/register") + .with_status(403) + .with_header("content-type", "application/json") + .with_header("x-icaptcha-url", &ic.url) + .with_header("x-icaptcha-level", "3") + .with_body(r#"{"error":"icaptcha_proof_required"}"#) + .expect(1) + .create_async() + .await; + let n2 = node + .mock("POST", "/api/register") + .match_header("x-icaptcha-proof", "mock.proof") + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"status":"created"}"#) + .expect(1) + .create_async() + .await; + + let client = NodeClient::new(node.url(), Some(test_keypair())); + let resp = client + .send_signed("POST", "/api/register", b"{}") + .await + .unwrap(); + assert_eq!(resp.status(), 201); + n1.assert(); + n2.assert(); + ic.challenge.assert(); + ic.answer.assert(); + } + + #[tokio::test] + async fn send_signed_returns_403_after_icaptcha_retries_exhausted() { + let mut node = Server::new_async().await; + let mut icaptcha = Server::new_async().await; + // MAX_ICAPTCHA_RETRIES = 2, so with every call returning 403 with + // iCaptcha headers the solve loop runs twice (2 challenge + 2 answer). + let ic = MockIcaptcha::new(&mut icaptcha, 2).await; + + // The original + 2 retries = 3 node calls before the loop gives up. + let n = node + .mock("POST", "/api/register") + .with_status(403) + .with_header("content-type", "application/json") + .with_header("x-icaptcha-url", &ic.url) + .with_header("x-icaptcha-level", "3") + .with_body(r#"{"error":"icaptcha_proof_required"}"#) + .expect(3) + .create_async() + .await; + + let client = NodeClient::new(node.url(), Some(test_keypair())); + let resp = client + .send_signed("POST", "/api/register", b"{}") + .await + .unwrap(); + assert_eq!(resp.status(), 403); + n.assert(); + ic.challenge.assert(); + ic.answer.assert(); + } +} diff --git a/crates/icaptcha-client/src/lib.rs b/crates/icaptcha-client/src/lib.rs index c41e368e..788c0e3d 100644 --- a/crates/icaptcha-client/src/lib.rs +++ b/crates/icaptcha-client/src/lib.rs @@ -81,18 +81,42 @@ fn sanitize_excerpt(s: &str) -> String { out } -/// Whether `u` parses as an `https` URL. +/// Whether `u` parses as a trusted URL. +/// +/// Production trusts only `https`. When `GITLAWB_ICAPTCHA_INSECURE` is set (a +/// runtime escape hatch for integration tests) the function also trusts +/// `http://127.0.0.1` and `http://localhost` so the full iCaptcha retry path +/// can be exercised against a local mockito server. fn is_https(u: &str) -> bool { - reqwest::Url::parse(u) - .map(|p| p.scheme() == "https") - .unwrap_or(false) + let Ok(parsed) = reqwest::Url::parse(u) else { + return false; + }; + if parsed.scheme() == "https" { + return true; + } + if std::env::var_os("GITLAWB_ICAPTCHA_INSECURE").is_some() + && parsed.scheme() == "http" + && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost")) + { + return true; + } + false } -/// Lowercased host of a URL, if it parses and has one. -fn host_of(u: &str) -> Option { - reqwest::Url::parse(u) - .ok() - .and_then(|p| p.host_str().map(|h| h.to_ascii_lowercase())) +/// Lowercased origin string (`scheme://host[:port]`) of a URL. +/// +/// Includes the effective port so that `http://localhost:3000` and +/// `http://localhost:9000` are treated as distinct origins — a hostile node +/// cannot redirect the iCaptcha solve to a different loopback port. +fn origin_key(u: &str) -> Option { + let parsed = reqwest::Url::parse(u).ok()?; + let scheme = parsed.scheme(); + let host = parsed.host_str()?.to_ascii_lowercase(); + let port = parsed + .port_or_known_default() + .map(|p| format!(":{p}")) + .unwrap_or_default(); + Some(format!("{scheme}://{host}{port}")) } /// Decide which iCaptcha origin to actually talk to, and whether it is trusted @@ -109,12 +133,13 @@ fn host_of(u: &str) -> Option { /// exfiltrated to an origin the operator did not choose. fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (String, bool) { let operator = operator.filter(|u| is_https(u)); - let operator_host = operator.and_then(host_of); - let default_host = host_of(DEFAULT_URL); + let operator_origin = operator.as_ref().and_then(|u| origin_key(u)); + let default_origin = origin_key(DEFAULT_URL); - let allowed = |host: &Option| -> bool { - host.as_ref() - .map(|h| Some(h) == default_host.as_ref() || Some(h) == operator_host.as_ref()) + let allowed = |origin: &Option| -> bool { + origin + .as_ref() + .map(|o| Some(o) == default_origin.as_ref() || Some(o) == operator_origin.as_ref()) .unwrap_or(false) }; @@ -125,7 +150,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri }; let chosen = match advertised { - Some(a) if is_https(a) && allowed(&host_of(a)) => a.to_string(), + Some(a) if is_https(a) && allowed(&origin_key(a)) => a.to_string(), Some(a) => { tracing::warn!( advertised = %a, @@ -137,7 +162,7 @@ fn resolve_solver_url(advertised: Option<&str>, operator: Option<&str>) -> (Stri }; // Key goes only to the operator's own configured origin. - let key_trusted = operator_host.is_some() && host_of(&chosen) == operator_host; + let key_trusted = operator_origin.is_some() && origin_key(&chosen) == operator_origin; (chosen, key_trusted) } @@ -320,6 +345,37 @@ fn interactive_prompt(challenge: &Challenge) -> Option { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + use std::sync::{Mutex, MutexGuard}; + + /// Serializes tests that touch the process-global + /// `GITLAWB_ICAPTCHA_INSECURE` env var so they never race. + static ICAPTCHA_ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Set `GITLAWB_ICAPTCHA_INSECURE` for the test lifetime, restoring any + /// prior value on drop. + struct InsecureEnv { + _lock: MutexGuard<'static, ()>, + prev: Option, + } + + impl InsecureEnv { + fn new() -> Self { + let lock = ICAPTCHA_ENV_LOCK.lock().unwrap(); + let prev = std::env::var_os("GITLAWB_ICAPTCHA_INSECURE"); + std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", "1"); + InsecureEnv { _lock: lock, prev } + } + } + + impl Drop for InsecureEnv { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_INSECURE", v), + None => std::env::remove_var("GITLAWB_ICAPTCHA_INSECURE"), + } + } + } // ── P1: hostile error bodies must not reach the terminal raw ────────── @@ -403,4 +459,20 @@ mod tests { assert_eq!(url, DEFAULT_URL); assert!(!key_trusted); } + + #[test] + fn rejects_insecure_advertised_url_on_different_port() { + // With GITLAWB_ICAPTCHA_INSECURE=1, two loopback URLs on different + // ports must be treated as distinct origins so a hostile node cannot + // redirect the bearer key to a different listener on localhost. + let _env = InsecureEnv::new(); + + let op = "http://localhost:3000"; + let (url, key_trusted) = resolve_solver_url(Some("http://localhost:9000"), Some(op)); + assert_eq!( + url, op, + "must fall back to operator origin, not use different port" + ); + assert!(key_trusted, "key stays trusted with operator origin"); + } }