@@ -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