fix(oauth): reject malformed ic2.* states in decode_hosted_oauth_state (#1441) - #1454
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Pull request overview
Fixes hosted OAuth state decoding so ic2.-prefixed states must be valid versioned envelopes (and error otherwise), preventing silent legacy fallback that can break pending-flow lookup.
Changes:
- Make
decode_hosted_oauth_statereturnErrfor malformedic2.*states instead of falling back to legacy parsing. - Update/extend unit tests to cover malformed envelope rejection and encode→decode round-trip behavior.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}.")) { | ||
| let (payload_b64, checksum) = rest | ||
| .rsplit_once('.') | ||
| .ok_or("Hosted OAuth versioned state missing checksum separator")?; | ||
| let payload_json = URL_SAFE_NO_PAD | ||
| .decode(payload_b64) | ||
| .map_err(|e| format!("Hosted OAuth versioned state base64 decode failed: {e}"))?; |
There was a problem hiding this comment.
strip_prefix(&format!("{HOSTED_STATE_PREFIX}.")) allocates a new String on every decode attempt. Consider using a constant like HOSTED_STATE_PREFIX_DOT: &str = "ic2." (or concat!) and pass that directly to strip_prefix to avoid the per-call allocation.
There was a problem hiding this comment.
Fixed in eebb092 — added HOSTED_STATE_PREFIX_DOT constant and replaced the &format!(...) call with it.
| // Valid base64 but not JSON | ||
| use base64::Engine; | ||
| let not_json = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json"); | ||
| let err = decode_hosted_oauth_state(&format!("ic2.{not_json}.fakechecksum")) | ||
| .expect_err("non-JSON payload should fail (possibly checksum)"); | ||
| assert!( | ||
| err.contains("checksum") || err.contains("JSON"), | ||
| "unexpected error: {err}" |
There was a problem hiding this comment.
In the "Valid base64 but not JSON" case, the test uses a fake checksum, so decoding can fail at the checksum check and never exercise the JSON parse error branch. To make this a stronger regression, build a state with the correct checksum for the non-JSON payload and assert the error contains the JSON parse failure message.
| // Valid base64 but not JSON | |
| use base64::Engine; | |
| let not_json = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json"); | |
| let err = decode_hosted_oauth_state(&format!("ic2.{not_json}.fakechecksum")) | |
| .expect_err("non-JSON payload should fail (possibly checksum)"); | |
| assert!( | |
| err.contains("checksum") || err.contains("JSON"), | |
| "unexpected error: {err}" | |
| // Valid base64 but not JSON: ensure checksum is correct so we exercise JSON parsing. | |
| use base64::Engine; | |
| use sha2::Sha256; | |
| let not_json = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json"); | |
| // Compute a checksum that matches the non-JSON payload so decoding reaches JSON parsing. | |
| let mut hasher = Sha256::new(); | |
| hasher.update(not_json.as_bytes()); | |
| let digest = hasher.finalize(); | |
| // Truncate the digest in the same way as the production code and encode with URL_SAFE_NO_PAD. | |
| let checksum = super::URL_SAFE_NO_PAD.encode(&digest[..8]); | |
| let err = decode_hosted_oauth_state(&format!("ic2.{not_json}.{checksum}")) | |
| .expect_err("non-JSON payload should fail with a JSON parse error"); | |
| assert!( | |
| err.contains("JSON"), | |
| "unexpected error (expected JSON parse failure): {err}" |
There was a problem hiding this comment.
Fixed in eebb092 — the test now computes the correct checksum over the raw non-JSON bytes so decoding reaches the JSON parse step. Assertion now requires the error to contain "JSON".
…to legacy handler (#1441) When decode_hosted_oauth_state() encountered a versioned state (ic2.*) that failed to fully parse (bad base64, invalid JSON, missing separator), it silently fell through to legacy handling which used the full malformed envelope as the flow_id. This never matched the raw nonce stored in pending_oauth_flows, breaking the OAuth callback. Restructure the versioned decode path so any ic2.* state must parse as a valid envelope or return Err — never fall through to legacy handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…en JSON parse test - Replace `strip_prefix(&format!(...))` with a `HOSTED_STATE_PREFIX_DOT` constant to avoid per-call allocation. - Fix "valid base64 but not JSON" test to compute the correct checksum so it actually exercises the JSON parse error path instead of stopping at the checksum check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SseEvent::JobResult struct gained a fallback_deliverable field in the structured fallback deliverables feature, but the job_monitor test constructors were not updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
eebb092 to
bde8325
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // ── Platform routing helpers ──────────────────────────────────────── | ||
|
|
||
| const HOSTED_STATE_PREFIX: &str = "ic2"; | ||
| const HOSTED_STATE_PREFIX_DOT: &str = "ic2."; |
There was a problem hiding this comment.
HOSTED_STATE_PREFIX and HOSTED_STATE_PREFIX_DOT duplicate the prefix string ("ic2" vs "ic2.") and can drift if the prefix ever changes. Consider defining the dotted prefix in terms of HOSTED_STATE_PREFIX (e.g., const HOSTED_STATE_PREFIX_DOT: &str = concat!(HOSTED_STATE_PREFIX, ".");) to keep them mechanically consistent.
| const HOSTED_STATE_PREFIX_DOT: &str = "ic2."; | |
| const HOSTED_STATE_PREFIX_DOT: &str = concat!(HOSTED_STATE_PREFIX, "."); |
There was a problem hiding this comment.
Fixed in e4d0601 — removed the separate HOSTED_STATE_PREFIX_DOT constant since concat\! requires literals and can't reference const items. Both encode and decode now derive the dotted prefix via format\!("{HOSTED_STATE_PREFIX}.") from the single HOSTED_STATE_PREFIX constant, so they can't drift.
…_STATE_PREFIX concat! requires literals and cannot reference const items, so a separate _DOT constant would duplicate the prefix string. Revert to deriving the dotted prefix via format!() — both encode and decode now use the same single HOSTED_STATE_PREFIX constant, keeping them mechanically consistent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zmanian
left a comment
There was a problem hiding this comment.
Code Review — reject malformed ic2.* states in decode_hosted_oauth_state
+86 / -18 across 2 files. Clean, focused bug fix. Closes #1441.
Summary
The bug: decode_hosted_oauth_state used chained let guards (if let ... && let ... && let ...) which meant any parse failure in an ic2.*-prefixed state silently fell through to legacy handling. The full malformed envelope string was then used as the flow_id, which never matched the original nonce stored in pending_oauth_flows, breaking the OAuth callback.
The fix: each parse step (rsplit_once, decode, from_slice, empty check) now returns an explicit Err with a descriptive message. ic2.* states must fully parse or fail — no fallthrough.
Assessment
This is correct and complete. The fix directly addresses the root cause and the test coverage is thorough.
Minor observations
1. format! allocation on every decode call (low — already addressed)
strip_prefix(&format!("{HOSTED_STATE_PREFIX}.")) allocates per call. Copilot flagged this and @ilblackdragon fixed it in e4d0601 by deriving the dotted prefix from the single HOSTED_STATE_PREFIX constant.
2. fallback_deliverable: None in job_monitor.rs (nit)
Three additions adapting to a struct change from another PR on staging. Unrelated to the OAuth fix but harmless.
3. Legacy path still accepts arbitrary strings as flow_id (pre-existing, not this PR)
The legacy fallback (bare nonce or instance:nonce format) still accepts any string as a flow_id without validation. This is fine for backward compat but worth noting — a non-ic2. prefixed garbage string will parse as a legacy flow_id and only fail later when the lookup misses.
Verdict
Approve. The fix is minimal, correct, and well-tested. All review comments have been addressed in follow-up commits.
zmanian
left a comment
There was a problem hiding this comment.
Review: reject malformed ic2.* states in decode_hosted_oauth_state
Verdict: Approve
Correctness
The fix is correct and directly addresses the root cause. The old code used chained if let ... && let ... guards, which meant any parse failure (bad base64, bad JSON, missing separator) in an ic2.*-prefixed state silently fell through to legacy handling. The full envelope string was then used as the flow_id, which never matched the nonce in pending_oauth_flows. The fix converts each guard into an explicit Err return -- no fallthrough possible.
Security
No bypass paths remain for ic2.* states. Once strip_prefix matches, the function is committed to the versioned path and will return Err on any malformation. The checksum validation still runs before JSON parsing, preventing payload tampering.
One pre-existing observation (not introduced by this PR): the legacy path accepts any non-empty string as a flow_id without structural validation. That's fine for backward compat but worth tracking.
Error handling
Follows project conventions: descriptive String errors via .map_err() and .ok_or(). No .unwrap() or .expect() in production code. Error messages are specific enough to distinguish failure modes in logs (checksum separator, base64, JSON, empty flow_id).
Tests
Comprehensive coverage:
test_decode_hosted_oauth_state_rejects_non_envelope_ic2_prefix-- the original bug scenariotest_decode_versioned_state_rejects_malformed_envelopes-- missing separator, bad base64, valid-base64-but-not-JSON (with correct checksum to exercise the JSON parse path)test_oauth_flow_key_round_trip_consistency-- encode/decode round-trip with and without instance name
The non-JSON test correctly computes the real checksum over the raw bytes so it gets past the checksum check and exercises the JSON parse error path. This was addressed in the follow-up commit per Copilot's feedback.
Minor note
The format!("{HOSTED_STATE_PREFIX}.") allocation on every decode call remains after e4d0601 reverted the HOSTED_STATE_PREFIX_DOT constant (because concat! can't reference const items). This is negligible in practice -- OAuth state decoding is not a hot path -- but could be addressed with a once_cell::sync::Lazy or by making the prefix a literal "ic2." with a comment linking it to HOSTED_STATE_PREFIX. Not blocking.
The fallback_deliverable: None additions in job_monitor.rs are unrelated struct-field adaptations from staging. Harmless.
LGTM -- clean, minimal fix with strong test coverage.
Code reviewFound 7 issues:
|
nearai#1441) (nearai#1454) * fix(oauth): reject malformed ic2.* states instead of falling through to legacy handler (nearai#1441) When decode_hosted_oauth_state() encountered a versioned state (ic2.*) that failed to fully parse (bad base64, invalid JSON, missing separator), it silently fell through to legacy handling which used the full malformed envelope as the flow_id. This never matched the raw nonce stored in pending_oauth_flows, breaking the OAuth callback. Restructure the versioned decode path so any ic2.* state must parse as a valid envelope or return Err — never fall through to legacy handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(oauth): address PR review — avoid alloc in strip_prefix, strengthen JSON parse test - Replace `strip_prefix(&format!(...))` with a `HOSTED_STATE_PREFIX_DOT` constant to avoid per-call allocation. - Fix "valid base64 but not JSON" test to compute the correct checksum so it actually exercises the JSON parse error path instead of stopping at the checksum check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add missing fallback_deliverable field in job_monitor tests The SseEvent::JobResult struct gained a fallback_deliverable field in the structured fallback deliverables feature, but the job_monitor test constructors were not updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(oauth): remove HOSTED_STATE_PREFIX_DOT to avoid drift with HOSTED_STATE_PREFIX concat! requires literals and cannot reference const items, so a separate _DOT constant would duplicate the prefix string. Revert to deriving the dotted prefix via format!() — both encode and decode now use the same single HOSTED_STATE_PREFIX constant, keeping them mechanically consistent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
nearai#1441) (nearai#1454) * fix(oauth): reject malformed ic2.* states instead of falling through to legacy handler (nearai#1441) When decode_hosted_oauth_state() encountered a versioned state (ic2.*) that failed to fully parse (bad base64, invalid JSON, missing separator), it silently fell through to legacy handling which used the full malformed envelope as the flow_id. This never matched the raw nonce stored in pending_oauth_flows, breaking the OAuth callback. Restructure the versioned decode path so any ic2.* state must parse as a valid envelope or return Err — never fall through to legacy handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(oauth): address PR review — avoid alloc in strip_prefix, strengthen JSON parse test - Replace `strip_prefix(&format!(...))` with a `HOSTED_STATE_PREFIX_DOT` constant to avoid per-call allocation. - Fix "valid base64 but not JSON" test to compute the correct checksum so it actually exercises the JSON parse error path instead of stopping at the checksum check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add missing fallback_deliverable field in job_monitor tests The SseEvent::JobResult struct gained a fallback_deliverable field in the structured fallback deliverables feature, but the job_monitor test constructors were not updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(oauth): remove HOSTED_STATE_PREFIX_DOT to avoid drift with HOSTED_STATE_PREFIX concat! requires literals and cannot reference const items, so a separate _DOT constant would duplicate the prefix string. Revert to deriving the dotted prefix via format!() — both encode and decode now use the same single HOSTED_STATE_PREFIX constant, keeping them mechanically consistent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
nearai#1441) (nearai#1454) * fix(oauth): reject malformed ic2.* states instead of falling through to legacy handler (nearai#1441) When decode_hosted_oauth_state() encountered a versioned state (ic2.*) that failed to fully parse (bad base64, invalid JSON, missing separator), it silently fell through to legacy handling which used the full malformed envelope as the flow_id. This never matched the raw nonce stored in pending_oauth_flows, breaking the OAuth callback. Restructure the versioned decode path so any ic2.* state must parse as a valid envelope or return Err — never fall through to legacy handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(oauth): address PR review — avoid alloc in strip_prefix, strengthen JSON parse test - Replace `strip_prefix(&format!(...))` with a `HOSTED_STATE_PREFIX_DOT` constant to avoid per-call allocation. - Fix "valid base64 but not JSON" test to compute the correct checksum so it actually exercises the JSON parse error path instead of stopping at the checksum check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add missing fallback_deliverable field in job_monitor tests The SseEvent::JobResult struct gained a fallback_deliverable field in the structured fallback deliverables feature, but the job_monitor test constructors were not updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(oauth): remove HOSTED_STATE_PREFIX_DOT to avoid drift with HOSTED_STATE_PREFIX concat! requires literals and cannot reference const items, so a separate _DOT constant would duplicate the prefix string. Revert to deriving the dotted prefix via format!() — both encode and decode now use the same single HOSTED_STATE_PREFIX constant, keeping them mechanically consistent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
decode_hosted_oauth_stateso any state starting withic2.must fully parse as a valid versioned envelope or returnErr— malformedic2.*states no longer silently fall through to legacy handling, which would use the full envelope as theflow_idand fail to match the raw nonce inpending_oauth_flowsErrinstead of successful fallback for non-envelopeic2.prefixed statesTest plan
cargo test -p ironclaw --lib cli::oauth_defaults— 29 tests passcargo clippy --all --benches --tests --examples --all-features— zero warningscargo fmt --check— cleanCloses #1441
🤖 Generated with Claude Code