Skip to content

fix(security): require OAuth state verification + refresh deps - #14

Merged
tirthpatell merged 6 commits into
mainfrom
fix/oauth-state-csrf
Apr 21, 2026
Merged

fix(security): require OAuth state verification + refresh deps#14
tirthpatell merged 6 commits into
mainfrom
fix/oauth-state-csrf

Conversation

@tirthpatell

Copy link
Copy Markdown
Owner

Summary

Two security-related changes ported from the threads-go fixes:

  1. CSRF: enforce OAuth state in exchange_code_for_token (src/auth.rs)

    • Signature now requires stored_state (from get_auth_url) + received_state (from callback)
    • Constant-time compare via small private helper; empty values fail closed
    • Mismatch / empty → AuthenticationError (401) before any network call
    • Three new unit tests cover empty-stored, empty-received, and mismatch
    • README example updated
  2. Dependency hygiene (resolves cargo audit findings)

    • reqwest 0.12 → 0.13 (added form, query features split out in 0.13)
    • rand 0.9 → 0.10 (RngRngExt for random())
    • rustls-webpki bumped transitively to 0.103.12 — fixes RUSTSEC-2026-0098 / RUSTSEC-2026-0099
    • rand bump also addresses RUSTSEC-2026-0097 unsoundness warning

Breaking API change to exchange_code_for_token. Callers migrate:

- client.exchange_code_for_token("CODE").await?;
+ let (auth_url, state) = client.get_auth_url(&[]);
+ // persist state, redirect user, then on callback:
+ client.exchange_code_for_token("CODE", &state, received_state).await?;

Test plan

  • cargo fmt
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --all-features (173 tests + doctest pass)
  • cargo audit (zero vulnerabilities, was 2 + 1 unsound warning)

@greptile-apps

greptile-apps Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR enforces OAuth CSRF state verification in exchange_code_for_token (now requiring both stored_state and received_state with constant-time comparison via the subtle crate), and upgrades reqwest to 0.13, rand to 0.10, and adds subtle = "2" to address outstanding cargo audit advisories. The previous concern about a hand-rolled XOR loop has been fully addressed by switching to subtle::ConstantTimeEq.

Confidence Score: 5/5

Safe to merge — security fix is correctly implemented, prior subtle feedback is addressed, and no new issues found.

All changes are correctness improvements (CSRF enforcement, constant-time state comparison, CVE-covering dep bumps). The previous P1 comment about the hand-rolled XOR loop has been resolved by adopting subtle::ConstantTimeEq. No new logic bugs, security issues, or broken contracts were found.

No files require special attention.

Important Files Changed

Filename Overview
src/auth.rs CSRF state enforcement added with correct constant-time comparison via subtle::ConstantTimeEq; three new unit tests cover all fail-closed paths; empty/mismatch checks happen before any network call.
Cargo.toml reqwest bumped to 0.13 with correct form/query feature split; rand to 0.10; subtle = "2" added for constant-time comparisons.
README.md Authentication example updated to reflect the new three-argument exchange_code_for_token signature with correct state persistence guidance.
Cargo.lock Lock file reflects dependency upgrades; new transitive deps (aws-lc-rs, subtle, webpki-root-certs) are expected for reqwest 0.13/rustls.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App calls get_auth_url] --> B[Client generates random state\nand builds authorize URL]
    B --> C[App persists state in session\nand redirects user]
    C --> D[User grants access on Threads]
    D --> E[Threads redirects to callback\nwith params in URI]
    E --> F[App calls exchange_code_for_token\nstored_state + received_state]
    F --> G{State checks}
    G -->|empty stored or received| H[401 AuthenticationError\nstate missing]
    G -->|states differ\nconstant-time compare| I[401 AuthenticationError\nstate mismatch]
    G -->|states match| J[POST /oauth/access_token]
    J --> K[Store TokenInfo in client]
    K --> L[Ok]
Loading

Reviews (2): Last reviewed commit: "chore: replace hand-rolled constant_time..." | Re-trigger Greptile

Comment thread src/auth.rs
Previously `exchange_code_for_token(code)` delegated CSRF protection
entirely to the caller — a caller who forgot to compare the callback
`state` to the one persisted after `get_auth_url` had an open CSRF hole
(RFC 6749 §10.12).

The method now takes the caller-persisted `stored_state` and the
callback `received_state`, and fails closed before any network call:

  * empty stored or received state → AuthenticationError (401, "state
    missing"); prevents the empty-string-equals-empty-string trap.
  * constant-time compare via a small private helper; avoids timing
    leaks about the matching prefix length.
  * on mismatch → AuthenticationError (401, "state mismatch").

Callers migrate:
  - client.exchange_code_for_token("CODE").await?;
  + let (auth_url, state) = client.get_auth_url(&[]);
  + // persist `state`, redirect user, then on callback:
  + client.exchange_code_for_token("CODE", &state, received_state).await?;

README example updated. Three new unit tests cover the empty-stored,
empty-received, and mismatch cases; `constant_time_eq` has its own
basic test. `cargo fmt`, `clippy -D warnings`, and `cargo test
--all-features` (173 + 1 doctest) all pass.
Resolves the audit warnings reported by `cargo audit`:

  * RUSTSEC-2026-0098 / RUSTSEC-2026-0099 — `rustls-webpki` accepted
    incorrect URI name constraints / wildcard-name certificates. Fixed
    transitively by pulling in rustls-webpki >= 0.103.12 via the
    refreshed lockfile (also surfaced under reqwest 0.13's hyper-rustls
    chain).
  * RUSTSEC-2026-0097 — `rand 0.9` unsoundness with custom loggers.
    Bumped to `rand = "0.10"`. The `Rng` trait was split: the
    `random()` method now lives on `RngExt`, so the import in
    `src/auth.rs` is updated accordingly.

reqwest 0.13 split the `form` and `query` builders behind their own
feature flags; both are now enabled alongside `json`. No other source
changes were required — `cargo build`, `cargo clippy --all-targets
--all-features -D warnings`, `cargo test --all-features`
(173 + doctest), and `cargo audit` all pass cleanly.
Code review feedback: a hand-rolled XOR-accumulation loop has no
mechanism to stop LLVM from optimising it into an early-exit branch
(dead-store elimination, branch folding), so it cannot guarantee the
constant-time semantics the doc comment promises. Replace with
`subtle::ConstantTimeEq::ct_eq`, which uses proper compiler barriers.

Bump version 0.1.1 → 0.2.0: the OAuth state CSRF fix is a breaking API
change to `exchange_code_for_token`, and reqwest 0.12 → 0.13 / rand 0.9
→ 0.10 are major dep bumps that affect downstream callers via re-export.
Pre-1.0 semver convention treats minor bumps as breaking.
@tirthpatell
tirthpatell merged commit ff5f2e2 into main Apr 21, 2026
7 checks passed
@tirthpatell
tirthpatell deleted the fix/oauth-state-csrf branch April 21, 2026 03:11
tirthpatell added a commit that referenced this pull request Jul 5, 2026
The Go client (threads-go) received several fixes since this port last
synced (Go conformance audit #24 / Rust audit PR #2). This ports the
missing ones:

- debug_token now calls Meta with the app access token
  (TH|client_id|client_secret), falling back to the user token when
  credentials are absent, and defaults input_token to the stored token.
  Meta resolves the request's app context from the caller token and
  rejects user tokens for dev-mode apps (Go #28).

- Client::with_token falls back to /me?fields=id when debug_token fails
  (graph.threads.net returns HTTP 500 for valid tokens on dev-mode
  apps), bootstrapping TokenInfo with the user ID and a 60-day expiry
  (Go #29).

- HTTP error classification: API error codes 190/102 map to
  AuthenticationError regardless of HTTP status (Meta returns 5xx for
  these on some endpoints), and authentication errors are never
  retried. Code 10 (GraphMethodException) is classified as permanent
  and never retried — retrying burns publish quota (Go #29, #30).

- Recovery from /threads_publish false failures (Go #30/#31): when
  publish fails with code 10, the container may have been published
  anyway. The client polls the container status until PUBLISHED, then
  locates the post among the user's recent posts using content-based
  matchers (text/topic_tag/reply/quote state, children count for
  carousels). Matching fails closed on ambiguity or when the content
  has no unique discriminator, surfacing the original publish error.

Not ported from Go #27 (already covered or N/A here): OAuth state CSRF
(done in #14), URL log redaction (we never log query strings), the
rate-limiter swap race (single shared Arc<RateLimiter> here), and the
DeletePostWithConfirmation ownership fix (that API was never part of
the Rust surface).

Also puts the existing wiremock dev-dependency to use: new integration
suite covering the debug_token caller token, the /me fallback,
non-retryable auth errors, and the four recovery outcomes.
tirthpatell added a commit that referenced this pull request Jul 5, 2026
* fix: port threads-go fixes #28–#31 — auth hardening + publish recovery

The Go client (threads-go) received several fixes since this port last
synced (Go conformance audit #24 / Rust audit PR #2). This ports the
missing ones:

- debug_token now calls Meta with the app access token
  (TH|client_id|client_secret), falling back to the user token when
  credentials are absent, and defaults input_token to the stored token.
  Meta resolves the request's app context from the caller token and
  rejects user tokens for dev-mode apps (Go #28).

- Client::with_token falls back to /me?fields=id when debug_token fails
  (graph.threads.net returns HTTP 500 for valid tokens on dev-mode
  apps), bootstrapping TokenInfo with the user ID and a 60-day expiry
  (Go #29).

- HTTP error classification: API error codes 190/102 map to
  AuthenticationError regardless of HTTP status (Meta returns 5xx for
  these on some endpoints), and authentication errors are never
  retried. Code 10 (GraphMethodException) is classified as permanent
  and never retried — retrying burns publish quota (Go #29, #30).

- Recovery from /threads_publish false failures (Go #30/#31): when
  publish fails with code 10, the container may have been published
  anyway. The client polls the container status until PUBLISHED, then
  locates the post among the user's recent posts using content-based
  matchers (text/topic_tag/reply/quote state, children count for
  carousels). Matching fails closed on ambiguity or when the content
  has no unique discriminator, surfacing the original publish error.

Not ported from Go #27 (already covered or N/A here): OAuth state CSRF
(done in #14), URL log redaction (we never log query strings), the
rate-limiter swap race (single shared Arc<RateLimiter> here), and the
DeletePostWithConfirmation ownership fix (that API was never part of
the Rust surface).

Also puts the existing wiremock dev-dependency to use: new integration
suite covering the debug_token caller token, the /me fallback,
non-retryable auth errors, and the four recovery outcomes.

* fix: audit round — error-body parsing, forward-compat media types, cleanups

Correctness fixes found by review of the ported changes:

- http.rs: parse API error code/error_subcode as u64 and clamp on
  conversion. Meta subcodes routinely exceed u16 (e.g. 2207026), and an
  out-of-range integer failed the WHOLE error-body parse — silently
  dropping code 190/102/10 classification and re-enabling retries for
  auth errors and code-10 publish failures, defeating the previous
  commit. Also apply code/is_transient/subcode independently of the
  message: an error body with a code but empty message was previously
  misclassified as a retryable 5xx.

- types: add MediaType::Unknown with #[serde(other)]. Without it, any
  new media_type value from Meta failed deserialization of the entire
  posts response — breaking every listing call and aborting publish
  recovery exactly when the post exists.

- client.rs: with_token no longer fabricates an AuthenticationError
  when the /me fallback fails; the typed /me error is propagated so
  callers can distinguish a transient outage from an invalid token
  (matches Go, which wraps with %w). An empty /me ID surfaces the
  original debug_token error.

- recovery: own the false-failure code list (is_publish_false_failure_code)
  instead of aliasing the retry policy's permanent-code list — tuning
  "don't retry this code" must not silently start triggering recovery
  polls for codes that genuinely failed.

Cleanups: collapse the four duplicated publish/recover blocks into
publish_with_recovery; route text_matcher through the shared
media_content_matches; drop a dead chrono unwrap_or fallback; single
extract_base_fields pass in is_retryable_error; derive Default on the
four *PostContent structs (Go zero-value ergonomics) and simplify
create_quote_post and test constructors with struct-update syntax.

Regression tests: subcode > u16, code-with-empty-message, unknown
media_type unit + end-to-end recovery with an unknown-type post in the
listing window.

* chore: bump to 0.3.0 for MediaType variant addition; address review notes

- Bump version 0.2.0 -> 0.3.0: adding MediaType::Unknown to an exhaustive
  public enum is a breaking change (cargo-semver-checks enum_variant_added),
  consistent with the 0.1.1 -> 0.2.0 bump for the OAuth state change.

- Document the /me-fallback expiry tradeoff on Client::with_token: the
  fallback cannot read the token's real remaining lifetime, so expires_at
  assumes a full 60 days; callers hitting the fallback consistently should
  refresh proactively.

- Document that Default on the *PostContent structs yields values that
  fail validation (empty text/image_url/video_url/children) — required
  fields must always be set explicitly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant