mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 07:38:18 -10:00
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)
The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.
* perf(ipfs): check object existence before allowed-blob walk
Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.
* refactor(ipfs): improve formatting and readability in get_by_cid function
✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.
✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.
• P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.
* refactor(ipfs): streamline object retrieval by separating type and content reading
* docs(ipfs): update get_by_cid comment to reflect split object retrieval
* Run cargo fmt on store.rs
* test(gl): add tests for iCaptcha transparent retry flow (#167)
* fix(clippy): remove redundant borrow in format! arg
* fix(review): use runtime GITLAWB_ICAPTCHA_INSECURE flag instead of cfg(test), add mock assertions
* fix(review): set mock expectations at creation time, tighten insecure scheme check
* fix(test): serialize icaptcha integration tests with global Mutex to prevent env-var race
* fix(icaptcha): compare origins (scheme+host+port) in resolve_solver_url; tighten proof assertion in test
* fix(test): save/restore inherited iCaptcha env vars instead of unconditional remove; share lock in icaptcha-client tests
* fix(test): constrain negative send_once mocks with Matcher::Missing so they actually protect the contract
---------
Co-authored-by: Gravirei <[email protected]>
This commit is contained in:
1 parent
c98b503f90
commit
ad7c2b2b83
2 files changed
+474
-16
No files matched your search
@@ -185,3 +185,389 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
|
||||
.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::<reqwest::header::HeaderName>().unwrap(),
|
||||
v.parse::<reqwest::header::HeaderValue>().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<OsString>,
|
||||
prev_insecure: Option<OsString>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -82,18 +82,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<String> {
|
||||
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<String> {
|
||||
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
|
||||
@@ -110,12 +134,13 @@ fn host_of(u: &str) -> Option<String> {
|
||||
/// 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<String>| -> bool {
|
||||
host.as_ref()
|
||||
.map(|h| Some(h) == default_host.as_ref() || Some(h) == operator_host.as_ref())
|
||||
let allowed = |origin: &Option<String>| -> bool {
|
||||
origin
|
||||
.as_ref()
|
||||
.map(|o| Some(o) == default_origin.as_ref() || Some(o) == operator_origin.as_ref())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
@@ -126,7 +151,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,
|
||||
@@ -138,7 +163,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)
|
||||
}
|
||||
|
||||
@@ -349,6 +374,37 @@ fn interactive_prompt(challenge: &Challenge) -> Option<String> {
|
||||
#[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<OsString>,
|
||||
}
|
||||
|
||||
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 ──────────
|
||||
|
||||
@@ -432,4 +488,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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user