Skip to content

fix(oauth): reject malformed ic2.* states in decode_hosted_oauth_state (#1441) - #1454

Merged
ilblackdragon merged 4 commits into
stagingfrom
fix/1441-oauth-flow-key-mismatch
Mar 21, 2026
Merged

ilblackdragon merged 4 commits into
stagingfrom
fix/1441-oauth-flow-key-mismatch

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Summary

  • Fix decode_hosted_oauth_state so any state starting with ic2. must fully parse as a valid versioned envelope or return Err — malformed ic2.* states no longer silently fall through to legacy handling, which would use the full envelope as the flow_id and fail to match the raw nonce in pending_oauth_flows
  • Update existing test to expect Err instead of successful fallback for non-envelope ic2. prefixed states
  • Add regression tests: malformed envelope rejection (bad base64, bad JSON, missing separator) and encode→decode round-trip consistency

Test plan

  • cargo test -p ironclaw --lib cli::oauth_defaults — 29 tests pass
  • cargo clippy --all --benches --tests --examples --all-features — zero warnings
  • cargo fmt --check — clean

Closes #1441

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings March 20, 2026 06:12
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions github-actions Bot added scope: channel/cli TUI / CLI channel size: M 50-199 changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Mar 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_state return Err for malformed ic2.* 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.

Comment thread src/cli/oauth_defaults.rs
Comment on lines +582 to +588
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}"))?;

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eebb092 — added HOSTED_STATE_PREFIX_DOT constant and replaced the &format!(...) call with it.

Comment thread src/cli/oauth_defaults.rs Outdated
Comment on lines +1271 to +1278
// 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}"

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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}"

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

@ilblackdragon
ilblackdragon requested a review from zmanian March 20, 2026 06:19
ilblackdragon and others added 3 commits March 19, 2026 23:27
…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>
@ilblackdragon
ilblackdragon force-pushed the fix/1441-oauth-flow-key-mismatch branch from eebb092 to bde8325 Compare March 20, 2026 06:31
Copilot AI review requested due to automatic review settings March 20, 2026 06:31
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) risk: medium Business logic, config, or moderate-risk modules and removed risk: low Changes to docs, tests, or low-risk modules labels Mar 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/cli/oauth_defaults.rs Outdated
// ── Platform routing helpers ────────────────────────────────────────

const HOSTED_STATE_PREFIX: &str = "ic2";
const HOSTED_STATE_PREFIX_DOT: &str = "ic2.";

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const HOSTED_STATE_PREFIX_DOT: &str = "ic2.";
const HOSTED_STATE_PREFIX_DOT: &str = concat!(HOSTED_STATE_PREFIX, ".");

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ilblackdragon
ilblackdragon merged commit 9d53813 into staging Mar 21, 2026
14 checks passed
@ilblackdragon
ilblackdragon deleted the fix/1441-oauth-flow-key-mismatch branch March 21, 2026 21:39

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 scenario
  • test_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.

@claude

claude Bot commented Mar 21, 2026

Copy link
Copy Markdown

Code review

Found 7 issues:

  1. [CRITICAL:92] Timing-sensitive checksum comparison enables token forgery
    Line 590 uses string equality instead of constant-time comparison, enabling timing attacks on OAuth state tokens. Other auth paths use subtle::ConstantTimeEq.
    https://github.com/anthropics/ironclaw/blob/9d538136b5d86a1eb0a11ef469729b7304db24fb/src/cli/oauth_defaults.rs#L588-L592

  2. [HIGH:95] Return type violates CLAUDE.md error design rule
    decode_hosted_oauth_state() returns Result<T, String> instead of typed error enum. CLAUDE.md requires thiserror types. Module already uses OAuthCallbackError elsewhere.
    https://github.com/anthropics/ironclaw/blob/9d538136b5d86a1eb0a11ef469729b7304db24fb/src/cli/oauth_defaults.rs#L581-L581

  3. [HIGH:78] User-controlled flow_id logged to tracing
    encode_hosted_oauth_state logs flow_id from user-supplied OAuth requests. If it contains sensitive params, this leaks to logs.
    https://github.com/anthropics/ironclaw/blob/9d538136b5d86a1eb0a11ef469729b7304db24fb/src/cli/oauth_defaults.rs#L569-L572

  4. [MEDIUM:80] No size limits on base64 decode allows memory DoS
    URL_SAFE_NO_PAD.decode(payload_b64) on user input has no bounds. Attacker can send large base64 to exhaust memory on public OAuth endpoint. Recommend: check payload_b64.len() before decode.
    https://github.com/anthropics/ironclaw/blob/9d538136b5d86a1eb0a11ef469729b7304db24fb/src/cli/oauth_defaults.rs#L586-L588

  5. [MEDIUM:75] String allocation on every invocation (format!())
    Line 582 calls format!("{HOSTED_STATE_PREFIX}.") each time, allocating new String. HOSTED_STATE_PREFIX is const "ic2", should use const or starts_with(). Hot path: every OAuth request.
    https://github.com/anthropics/ironclaw/blob/9d538136b5d86a1eb0a11ef469729b7304db24fb/src/cli/oauth_defaults.rs#L582-L582

  6. [MEDIUM:70] Detailed error messages leak validation structure
    Error messages describe envelope format ("missing checksum separator", "base64 decode failed", etc.), helping attackers target attacks. Consider generic messages.
    https://github.com/anthropics/ironclaw/blob/9d538136b5d86a1eb0a11ef469729b7304db24fb/src/cli/oauth_defaults.rs#L585-L596

  7. [MEDIUM:65] JSON parsing has no size limit (complementary DoS vector)
    serde_json::from_slice() on decoded payload with no max size. Combined with feat: Sandbox jobs #4, allows memory exhaustion via oversized JSON. Recommend checking payload_json.len().
    https://github.com/anthropics/ironclaw/blob/9d538136b5d86a1eb0a11ef469729b7304db24fb/src/cli/oauth_defaults.rs#L593-L594

tianhaoz95 pushed a commit to tianhaoz95/clawgo that referenced this pull request Mar 22, 2026
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>
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
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>
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/cli TUI / CLI channel size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CRITICAL] OAuth flow registration and lookup key mismatch

3 participants