test(icaptcha-client): add test that new() honors key_trusted gate (#212)#213
test(icaptcha-client): add test that new() honors key_trusted gate (#212)#213Gravirei wants to merge 4 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds a unit test covering ChangesiCaptcha API-key gate
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
69f612e to
7c24cd7
Compare
beardthelion
left a comment
There was a problem hiding this comment.
Verified this load-bearing by mutation at 7c24cd7 before anything else: baseline 19/19; making new() attach the key unconditionally goes RED on exactly the untrusted-arm assertion (left: Some("secret-bearer"), right: None); making it never attach goes RED on the operator arm; restore returns 19/19. Both arms bind the gate, the restore path uses remove_var rather than an empty set_var, and both asserts run after env-restore plus lock-drop, so a failure can't poison ICAPTCHA_ENV_LOCK. Ambient GITLAWB_ICAPTCHA_INSECURE can't flip either arm (every URL here is https, and with no operator configured lib.rs:166 pins key_trusted false). CI is green including MSRV. Solid work, and exactly what #212 asked for.
The same mutation pass turned up one state the test doesn't drive, worth closing while you're in this block.
Findings
-
[P2] Drive the third reachable key_trusted state: operator configured, default host advertised
crates/icaptcha-client/src/lib.rs:517
The untrusted arm removes GITLAWB_ICAPTCHA_URL, so no arm ever observes the key withheld while an operator IS configured. That state is reachable: with the operator var set and a node advertising https://icaptcha.gitlawb.com/v2, the advert is allowlisted and chosen while its origin differs from the operator's, so key_trusted must be false. I ran both regressions that live in this gap:if key_trusted->if operator.is_some()at lib.rs:204, and dropping the origin comparison at lib.rs:166. Each passes the full crate suite 19/19 with the test as submitted, and either one hands the operator's bearer key to an origin the operator did not choose, steerable by any node via x-icaptcha-url. Fix: before the restore block, while the operator var is still set, construct once more with Some("https://icaptcha.gitlawb.com/v2".into()) and assert api_key is None. I ran that arm both ways: RED under both mutations, green on the current code. -
[P3] Pin cfg.url in both arms
crates/icaptcha-client/src/lib.rs:519
The test asserts only api_key, so new()'s URL wiring is unpinned: url: url.unwrap_or(resolved_url) at lib.rs:212 passes the whole crate suite yet routes the solve to the hostile advertised origin. Capturing the cfg and asserting url == DEFAULT_URL in the untrusted arm (and "https://icap.mynode.example/v1" in the operator arm) closes it; I ran those asserts both ways (RED under that mutation with left "https://evil.example", green on current code). -
[P3] Retitle to test(icaptcha-client): the change is test-only
CONTRIBUTING.md line 55: commit prefixes drive the release-please version bump, so a fix: title on squash-merge would cut a patch release for a tests-only change. Recent test-only merges use test(...) (ad7c2b2, c296e23).
Out of scope here: the empty-string GITLAWB_ICAPTCHA_API_KEY path through new()'s filter (filing that one separately), and wire-level observation that the bearer actually rides both challenge and answer requests, which belongs with the proxy-observer machinery #214 is building rather than a new issue.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P2] Exercise the configured-operator/default-host key gate
crates/icaptcha-client/src/lib.rs:522
WithGITLAWB_ICAPTCHA_URL=https://icap.mynode.example, a node may advertise the separately allowlistedhttps://icaptcha.gitlawb.com/v2;resolve_solver_urlselects that URL, butkey_trustedmust be false because it is not the operator origin. The two current arms both remain green ifnew()regresses fromif key_trustedto merely testing whether an operator is configured (or if the origin comparison is removed), which sends the operator bearer token to the node-selectable default origin. Add that third construction while the operator environment variable is set and assert thatapi_keyisNone. -
[P2] Assert the resolved URL from the constructor
crates/icaptcha-client/src/lib.rs:518
This test keeps onlyapi_key, so it does not bindnew()to useresolved_url. A regression such as assigning the raw advertised URL toIcaptchaCfg::urlstill passes both assertions and the direct resolver tests, yet makes the untrustedhttps://evil.exampleadvertisement the challenge/answer destination. Retain each constructed config and assert that the rejected advertisement resolves toDEFAULT_URLand the matching operator advertisement retains its allowed/v1URL. -
[P3] Use the test Conventional Commit type for this test-only change
7c24cd72943b0d02289ca1bdbbccc887a7d750d7
The PR changes only a unit test, but itsfix(icaptcha-client):title/commit will cause release-please to select a patch release:CONTRIBUTING.mdstates that Conventional Commit prefixes drive version bumps. Please retitle/recommit it astest(icaptcha-client): ...so merging this coverage-only change does not publish an unnecessary release.
7c24cd7 to
6b2ec93
Compare
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
- [P2] Exercise the configured-operator fallback construction
crates/icaptcha-client/src/lib.rs:522
All key-attached constructions pass an advertised operator URL. A normal API-key deployment also reachesIcaptchaCfg::newwithGITLAWB_ICAPTCHA_URLconfigured but nox-icaptcha-urlheader (for example, the client receives onlyx-icaptcha-level);resolve_solver_url(None, Some(operator))selects that operator and marks the key trusted. Consequently, a regression such as additionally requiringurl.is_some()when attaching the key leaves all 19 current crate tests green but makes these fallback challenge/answer requests omitAuthorizationand fail against the protected operator service. Construct thisurl = Nonecase while the operator env var is set and assert both the operator URL and attached key.
beardthelion
left a comment
There was a problem hiding this comment.
Re-reviewed 6b2ec93d. My two prior findings are resolved, both confirmed by mutation: the operator-configured / default-host-advertised state now withholds the key, and each arm pins the resolved URL.
Concurring with jatmn's remaining finding: the operator-configured, no-advertised-URL fallback (resolve_solver_url(None, Some(operator))) attaches the key with no arm covering it, so a url.is_some()-style regression ships green. I ran it: with if key_trusted && url.is_some() the 19 submitted tests all stay green while the real url = None path drops the Authorization header. One construction with the operator var set and url = None, asserting the key attaches, closes it. Not approving over that.
…e operator fallback handling in tests
beardthelion
left a comment
There was a problem hiding this comment.
Re-reviewed 9be5eac3. All three prior P2 asks are in, and I certified each by mutation at this head: if key_trusted -> if operator.is_some() and the dropped origin comparison both go RED on exactly the arm-3 assertion (left: Some("secret-bearer"), right: None); key_trusted && url.is_some() goes RED on arm 4 (left: None, right: Some("secret-bearer")); url.unwrap_or(resolved_url) goes RED on arm 1's URL pin. Restore returns 19/19. The Cargo.lock delta is a clean regeneration against main's 0.5.1 manifests (sha2 v0.10.9 was already declared and consumed by pow.rs; no new packages, no checksum churn).
One state is still unpinned, and it is the scenario in the test's own name.
Findings
-
[P2] Pin the operator-configured hostile-advert state (the exfiltration direction)
crates/icaptcha-client/src/lib.rs:517
Arm 1 is the only hostile-advert arm and it removes GITLAWB_ICAPTCHA_URL first, so itsapi_key == Nonepasses because no operator exists, not because a live key was withheld from an attacker origin. I ran the regression that lives in this gap:url: if operator.is_some() && url.is_some() { url.clone().unwrap() } else { resolved_url }at lib.rs:212 keeps all 19 tests green while (operator set, key set, adverthttps://evil.example) yieldsurl=https://evil.example,api_key=Some("secret-bearer"): the operator bearer rides to a node-steered origin, and arm 1's URL pin cannot see it because the override only fires when an operator is configured. Fix: a fifth construction with the operator var and key still set and advertSome("https://evil.example".into()), assertingurl == "https://icap.mynode.example"andapi_key.as_deref() == Some("secret-bearer"). I ran that arm both ways: RED under the mutation (it observes the exfil state directly), GREEN on the current code. -
[P3] Retitle the PR itself to test(icaptcha-client)
Commit 6b2ec93 carries the requested test( type, but this repo squash-merges with COMMIT_OR_PR_TITLE and the PR now has two commits, so the merge commit takes the PR title, which still says fix(icaptcha-client). Per CONTRIBUTING.md that cuts a patch release for a tests-only change. With the title fixed the squash result is right regardless of the second commit's fix( message. -
[P3] Optional: pin the (no operator, no advert, key set) state while you are in this block
crates/icaptcha-client/src/lib.rs:204
if key_trusted || (url.is_none() && operator.is_none())passes all 19 tests and would attach the bearer to the public default host whenever the node omits x-icaptcha-url. One construction in arm 1's env state withurl = None, assertingapi_key == Noneandurl == DEFAULT_URL, closes it; I ran it both ways (RED under that mutation, GREEN on current code). Take it or leave it in this PR.
Out of scope for this round, deliberately: wire-level observation that the bearer actually rides (or is absent from) the challenge and answer requests stays with the proxy-observer machinery #214 is building, and the empty-string GITLAWB_ICAPTCHA_API_KEY filter is still mine to file separately. Everything else from the prior rounds is resolved and verified.
beardthelion
left a comment
There was a problem hiding this comment.
Re-certified the gate by mutation at 82fec97a: baseline 19/19, all six prior gate-regressions each go RED on their arm, restore returns 19/19. Arm 5 (operator + hostile advert) and the URL pins are in and correct. One security-relevant state is still unpinned, and it is the same shape as the arms you already added.
Findings
-
[P2] Pin https port-isolation through
new()
crates/icaptcha-client/src/lib.rs:116
Every port test reachesorigin_keythrough theGITLAWB_ICAPTCHA_INSECUREhttp-localhost escape hatch (rejects_insecure_advertised_url_on_different_port), so the production https path never pins that a differing-port advert is a distinct origin. I ran the regression: stripping the port fromorigin_keyforscheme == "https"keeps all 19 tests green, and under it an advert ofhttps://icap.mynode.example:8443/evilresolves as the operator origin, so the solve (and the trusted bearer) rides to the node-steered:8443URL on the operator host. Production code is correct today; this is the missing regression fence. Add one arm with the operator var set:IcaptchaCfg::new("did:key:zTEST", Some("https://icap.mynode.example:8443/evil".into()), None), asserturl == "https://icap.mynode.example"andapi_key == Some("secret-bearer"). I ran that arm both ways: GREEN on current code, RED under the mutant (theurlpin catches it). -
[P3] Collect arm failures instead of asserting sequentially
crates/icaptcha-client/src/lib.rs:572
The six arms assert in sequence, so the first failing arm masks every later one in a single run. Either split per-arm or push mismatches into aVecandassert!(failures.is_empty(), ...)once at the end, so a multi-arm regression shows every affected arm. -
[P3] Add assert messages to the three bare
assert_eq!s
crates/icaptcha-client/src/lib.rs:590
operator_match.api_key,operator_match.url, anddefault_advert.urlassert without the descriptive message every sibling arm carries. Match the convention so a failure names the arm.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
- [P2] Exercise HTTPS port isolation through
IcaptchaCfg::new
crates/icaptcha-client/src/lib.rs:527
The currentorigin_keyimplementation correctly preserves ports, but the added constructor arms never combine the configured HTTPS operator with a same-host advertised URL on a different port; the only port test enables the insecure localhost escape hatch and callsresolve_solver_urldirectly. If a later change regresses HTTPS port handling, an advert such ashttps://icap.mynode.example:8443/evilmatches configuredhttps://icap.mynode.example, sonew()preserves that node-selected URL and attaches the operator bearer. All current tests stay green, but both challenge and answer requests then send the bearer to:8443. Add a configured-operator constructor arm for that advert and assert fallback to the operator URL with the key attached.
Summary
Adds a guard test that constructs
IcaptchaCfgthroughnew()and verifies the API key gate is honored — key withheld for untrusted origins, attached for the operator's own origin.Motivation & context
Closes #212
All existing tests called
resolve_solver_urldirectly; none exercisednew()'s wiring that consumeskey_trusted. A regression dropping the gate would ship green.Kind of change
What changed
crates/icaptcha-client/src/lib.rs: Addednew_withholds_key_from_untrusted_origin_but_attaches_for_operatortestHow a reviewer can verify
cargo test -p icaptcha-clientBefore you request review
cargo test --workspacepasses locallycargo clippy --workspace --all-targets -- -D warningsis cleanfix(...)).env.exampleupdated if behavior or config changed (or N/A)Protocol & signing impact
No protocol impact.
Notes for reviewers
This test saves/restores env vars and releases the lock before asserting, so a failure won't poison the shared lock or cascade into sibling tests.
Summary by CodeRabbit
IcaptchaCfg::newto ensure the iCaptcha API key is attached only when the node-advertised iCaptcha origin matches the configured operator URL.