Skip to content

Refuse cross-origin AccessToken service calls by default (CWE-918) - #414

Merged
pboling merged 4 commits into
ruby-oauth:mainfrom
fuzzyrichie:security/access-token-cross-origin-requests
Nov 8, 2026
Merged

pboling merged 4 commits into
ruby-oauth:mainfrom
fuzzyrichie:security/access-token-cross-origin-requests

Conversation

@fuzzyrichie

Copy link
Copy Markdown
Contributor

Summary

AccessToken#request temporarily swaps consumer.uri to any absolute URI passed to #get/#post/#put/#patch/#delete/#head, then signs and sends the OAuth request to that host. There is no check that the URI shares the configured :site origin.

If a consuming application forwards an untrusted or user-influenced absolute URL into these helpers (e.g. token.get(params[:callback_url])), the library will send the signed OAuth Authorization header to an arbitrary host — disclosing oauth_consumer_key / oauth_token to that host — or to an internal address (SSRF). The token/consumer secrets are never transmitted (they remain the HMAC key, and the signature is bound to the request URL), so this is an identifier/credential-header leak plus a request-forwarding primitive rather than a secret disclosure — but it still shouldn't happen silently by default.

This is primarily an application-misuse hazard, but the library can make the safe behavior the default, exactly as it already does for token-endpoint redirects.

What this changes

Mirrors the existing token_request_cross_origin_redirects hardening:

  • New consumer option access_token_cross_origin_requests (default false).
  • With it off, an absolute service URI whose origin (scheme, host, or effective port) differs from :site raises OAuth::Error before any request is made.
  • Same-origin absolute URIs and relative paths are unaffected — no behavior change for the common cases (API base in :site, relative paths, or absolute URLs to the same host).
  • Set the option to true to restore cross-origin service calls (for providers whose API endpoints legitimately live on a different origin than :site).

The scheme/host/port comparison is factored into Consumer#cross_origin?, which the existing token-request redirect check now reuses (no behavior change there).

Compatibility note

This is a secure-by-default behavior change: code that today calls AccessToken helpers with an absolute URL on a different origin than :site will start raising OAuth::Error until it sets access_token_cross_origin_requests: true. Relative paths and same-origin absolute URLs are unaffected. I've defaulted to false to match the philosophy of token_request_cross_origin_redirects — happy to flip the default to true (opt-in hardening) if you'd prefer to avoid any default change.

Tests

Adds spec/oauth/access_token_cross_origin_spec.rb (7 examples): cross-origin by host / port / scheme are refused; same-origin absolute and relative paths pass through to the request layer; consumer.uri is restored after a refusal; and the opt-in allows cross-origin again. All pass locally.

CHANGELOG and README updated.

🤖 Generated with Claude Code

fuzzyrichie and others added 3 commits November 6, 2026 21:44
AccessToken#request temporarily swaps consumer.uri to any absolute URI
passed to #get/#post/#put/#patch/#delete/#head and sends the OAuth-signed
request there. An application that forwards an untrusted/user-influenced
absolute URL into these helpers would send the signed OAuth Authorization
header to an arbitrary host (header disclosure) or to an internal address
(SSRF). The signed header exposes oauth_consumer_key and oauth_token (the
secrets stay the HMAC key and are never sent), but that is still a
credential/identifier leak plus a request-forwarding primitive.

This mirrors the existing token_request_cross_origin_redirects hardening:
add an access_token_cross_origin_requests consumer option (default false).
With it off, an absolute service URI whose origin (scheme, host, or
effective port) differs from the configured :site raises OAuth::Error
before any request is made. Same-origin absolute URIs and relative paths
are unaffected; set the option to true to allow cross-origin service calls.

The scheme/host/port comparison is factored into Consumer#cross_origin?,
reused by the existing token-request redirect check.

Adds spec/oauth/access_token_cross_origin_spec.rb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n_spec

Also merges latest main, whose re-baselined (now empty) rubocop-gradual
lock resolves the stale lint-lock CI failure on this branch.
@pboling

pboling commented Nov 8, 2026

Copy link
Copy Markdown
Member

Maintainer push (edits-from-maintainers is enabled on this PR):

  1. Merged latest main — this branch was based on an older main whose .rubocop_gradual.lock still contained entries for OAuth::Consumer#debug_output (Lint/EmptyWhen) and oauth.gemspec (Style/TernaryParentheses), both fixed and re-baselined on main since. The stale lock was the real cause of the Style / Deps Locked / Deps Unlocked CI failures.
  2. Fixed Layout/SpaceInsideHashLiteralBraces in spec/oauth/access_token_cross_origin_spec.rb:53 (project style: {key: value} without inner spaces).

CI is now green across the board (21 passing, JRuby/TruffleRuby matrix entries skip as usual). Local verification: full suite 198 examples, 0 failures; rubocop-gradual clean.

The change itself looks good — review notes on the design (all positive, two cosmetic nits) to follow separately.

@pboling pboling left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review (verified locally at 2a31bd4)

Full suite on this branch: 198 examples, 0 failures (5 pending, optional deps). New spec: 7/7 pass. rubocop-gradual clean. The earlier CI failures were a stale lint lock from an outdated base, now resolved by the merge of main into this branch.

Design — looks sound

  • Mirrors the existing token_request_cross_origin_redirects hardening; reusing one cross_origin?/effective_port pair for both checks is the right factoring, and the rename of token_request_effective_port is self-contained (no other callers).
  • The refusal happens before the consumer.uri swap, so a rejected call never mutates consumer state — and the spec proves restoration.
  • Comparison semantics are correct: scheme/host downcased, default ports normalize (https://site:443 ≡ https://site), userinfo tricks (https://site@attacker/) and trailing-dot hosts are refused, relative paths and same-origin absolute URIs are unaffected.
  • Defaulting access_token_cross_origin_requests to false is consistent with the token-redirect option, and the CHANGELOG/README compatibility notes are clear.

Nits (non-blocking)

  • In the spec, "network reached: #{consumer.uri}" interpolates eagerly when the stub is defined, so the message always shows the original site URI. Harmless (assertions only match /network reached/), just slightly misleading when reading a failure.
  • AccessToken#authority's comment says "port shown only when explicit", but URI::HTTP#port/URI::HTTPS#port always answer a port, so the if uri.port branch is always true for http/https URIs and the port always renders. Cosmetic — either drop the condition or adjust the comment.

pboling
pboling previously approved these changes Nov 8, 2026
- spec: make the Consumer#request stub message lazy (block form), so
  consumer.uri interpolates at call time — after any URI swap — instead of
  at stub definition; tighten the pass-through assertions to match the
  actual target URI the request would have gone to
- AccessToken#authority: correct the comment — URI::HTTP/HTTPS always
  resolve a port, so it is not "shown only when explicit"
@pboling

pboling commented Nov 8, 2026

Copy link
Copy Markdown
Member

Both review nits are addressed in f00b49c (maintainer push):

  • spec stub message is now lazy (block form), so it reports the actual target URI at call time; the pass-through assertions now match that URI exactly
  • AccessToken#authority comment corrected — URI::HTTP/HTTPS always resolve a port

CI green across the matrix.

@pboling
pboling merged commit 6ef0c1b into ruby-oauth:main Nov 8, 2026
37 checks passed
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.

2 participants