Skip to content

Commit 9418e31

Browse files
authored
Merge pull request #213 from Gravirei/fix/issue-212-icaptcha-client-api-key-gate-test
test(icaptcha-client): add test that new() honors key_trusted gate (#212)
2 parents c70b1df + 1ba9ee5 commit 9418e31

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

  • crates/icaptcha-client/src

‎crates/icaptcha-client/src/lib.rs‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,4 +550,174 @@ mod tests {
550550
);
551551
assert!(key_trusted, "key stays trusted with operator origin");
552552
}
553+
554+
#[test]
555+
fn new_withholds_key_from_untrusted_origin_but_attaches_for_operator() {
556+
let guard = ICAPTCHA_ENV_LOCK.lock().unwrap();
557+
let prev_key = std::env::var_os("GITLAWB_ICAPTCHA_API_KEY");
558+
let prev_op = std::env::var_os("GITLAWB_ICAPTCHA_URL");
559+
std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", "secret-bearer");
560+
561+
// Arm 1: No operator configured, node advertises an attacker origin.
562+
// The key has no trusted destination; the advert is rejected.
563+
std::env::remove_var("GITLAWB_ICAPTCHA_URL");
564+
let untrusted =
565+
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
566+
567+
// Arm 1b: No operator configured, no URL advertised. The fallback goes
568+
// to the default public origin, and with no operator to trust, the key
569+
// must not ride.
570+
let no_operator_no_advert = IcaptchaCfg::new("did:key:zTEST", None, None);
571+
572+
// Arm 2: Operator origin configured and advertised — key attached.
573+
std::env::set_var("GITLAWB_ICAPTCHA_URL", "https://icap.mynode.example");
574+
let operator_match = IcaptchaCfg::new(
575+
"did:key:zTEST",
576+
Some("https://icap.mynode.example/v1".into()),
577+
None,
578+
);
579+
580+
// Arm 3: Operator configured, default host advertised. The default host
581+
// is allowlisted and selected, but its origin differs from the
582+
// operator's, so key must not ride.
583+
let default_advert = IcaptchaCfg::new(
584+
"did:key:zTEST",
585+
Some("https://icaptcha.gitlawb.com/v2".into()),
586+
None,
587+
);
588+
589+
// Arm 4: Operator configured, no URL advertised (the node sent only
590+
// x-icaptcha-level, not x-icaptcha-url). resolve_solver_url falls back
591+
// to the operator origin and marks the key trusted. This is the common
592+
// API-key deployment path — a regression that additionally required
593+
// url.is_some() for key attachment would leave all current tests green
594+
// but make these fallback challenge/answer requests omit Authorization
595+
// and fail against the protected operator service.
596+
let operator_fallback = IcaptchaCfg::new("did:key:zTEST", None, Some(3));
597+
598+
// Arm 5: Operator configured, node advertises an attacker origin.
599+
// The advert is rejected and the solver falls back to the operator's
600+
// own origin, which keeps the key trusted — this is the exfiltration
601+
// scenario the test name describes (withhold key from *untrusted*
602+
// origin, not from the operator's fallback).
603+
let operator_hostile_advert =
604+
IcaptchaCfg::new("did:key:zTEST", Some("https://evil.example".into()), None);
605+
606+
// Arm 6: Operator configured, same host but different port.
607+
// origin_key includes the port, so :8443 must not match the
608+
// operator origin at the default :443 — the advert is rejected
609+
// and the solver falls back to the operator origin.
610+
let operator_diff_port = IcaptchaCfg::new(
611+
"did:key:zTEST",
612+
Some("https://icap.mynode.example:8443/evil".into()),
613+
None,
614+
);
615+
616+
// Restore env and release the lock BEFORE asserting, so a failing
617+
// assertion can't poison the shared lock or cascade into a sibling.
618+
match prev_key {
619+
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_API_KEY", v),
620+
None => std::env::remove_var("GITLAWB_ICAPTCHA_API_KEY"),
621+
}
622+
match prev_op {
623+
Some(v) => std::env::set_var("GITLAWB_ICAPTCHA_URL", v),
624+
None => std::env::remove_var("GITLAWB_ICAPTCHA_URL"),
625+
}
626+
drop(guard);
627+
628+
let mut failures: Vec<String> = Vec::new();
629+
630+
if untrusted.api_key.is_some() {
631+
failures.push(format!(
632+
"arm 1 (no operator, attacker advert): expected api_key=None, got {:?}",
633+
untrusted.api_key
634+
));
635+
}
636+
if untrusted.url != DEFAULT_URL {
637+
failures.push(format!(
638+
"arm 1 (no operator, attacker advert): expected url={DEFAULT_URL}, got {}",
639+
untrusted.url
640+
));
641+
}
642+
643+
if no_operator_no_advert.api_key.is_some() {
644+
failures.push(format!(
645+
"arm 1b (no operator, no advert): expected api_key=None, got {:?}",
646+
no_operator_no_advert.api_key
647+
));
648+
}
649+
if no_operator_no_advert.url != DEFAULT_URL {
650+
failures.push(format!(
651+
"arm 1b (no operator, no advert): expected url={DEFAULT_URL}, got {}",
652+
no_operator_no_advert.url
653+
));
654+
}
655+
656+
if operator_match.api_key.as_deref() != Some("secret-bearer") {
657+
failures.push(format!(
658+
"arm 2 (operator match): expected api_key=Some(\"secret-bearer\"), got {:?}",
659+
operator_match.api_key
660+
));
661+
}
662+
if operator_match.url != "https://icap.mynode.example/v1" {
663+
failures.push(format!(
664+
"arm 2 (operator match): expected url=https://icap.mynode.example/v1, got {}",
665+
operator_match.url
666+
));
667+
}
668+
669+
if default_advert.api_key.is_some() {
670+
failures.push(format!(
671+
"arm 3 (operator + default advert): expected api_key=None, got {:?}",
672+
default_advert.api_key
673+
));
674+
}
675+
if default_advert.url != "https://icaptcha.gitlawb.com/v2" {
676+
failures.push(format!(
677+
"arm 3 (operator + default advert): expected url=https://icaptcha.gitlawb.com/v2, got {}",
678+
default_advert.url
679+
));
680+
}
681+
682+
if operator_fallback.api_key.as_deref() != Some("secret-bearer") {
683+
failures.push(format!(
684+
"arm 4 (operator fallback, no advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
685+
operator_fallback.api_key
686+
));
687+
}
688+
if operator_fallback.url != "https://icap.mynode.example" {
689+
failures.push(format!(
690+
"arm 4 (operator fallback, no advert): expected url=https://icap.mynode.example, got {}",
691+
operator_fallback.url
692+
));
693+
}
694+
695+
if operator_hostile_advert.api_key.as_deref() != Some("secret-bearer") {
696+
failures.push(format!(
697+
"arm 5 (operator + hostile advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
698+
operator_hostile_advert.api_key
699+
));
700+
}
701+
if operator_hostile_advert.url != "https://icap.mynode.example" {
702+
failures.push(format!(
703+
"arm 5 (operator + hostile advert): expected url=https://icap.mynode.example, got {}",
704+
operator_hostile_advert.url
705+
));
706+
}
707+
708+
if operator_diff_port.api_key.as_deref() != Some("secret-bearer") {
709+
failures.push(format!(
710+
"arm 6 (operator + different port advert): expected api_key=Some(\"secret-bearer\"), got {:?}",
711+
operator_diff_port.api_key
712+
));
713+
}
714+
if operator_diff_port.url != "https://icap.mynode.example" {
715+
failures.push(format!(
716+
"arm 6 (operator + different port advert): expected url=https://icap.mynode.example, got {}",
717+
operator_diff_port.url
718+
));
719+
}
720+
721+
assert!(failures.is_empty(), "\n{}", failures.join("\n"));
722+
}
553723
}

0 commit comments

Comments
 (0)