Skip to content

fix!: hold a redirect URI's query, path and port to its sent form too - #867

Merged
osanderson merged 1 commit into
mainfrom
fix/redirect-uri-query-dots-ports
Nov 15, 2026
Merged

osanderson merged 1 commit into
mainfrom
fix/redirect-uri-query-dots-ports

Conversation

@osanderson

Copy link
Copy Markdown
Collaborator

Fixes Lows 6 and 7 and two Info findings from the v0.74.0 security review. This is breaking: fix!, with a v0.75.0 section in UPGRADING.md.

What was let through

v0.74.0's "written as it's sent" check compares the URI with url.URL.String(), which gives the query back byte for byte and never touches the path's meaning. So these were accepted:

URI Problem
https://rp.example/cb?a=<x>, ?a b, ?a=%zz Query characters RFC 3986 doesn't allow, or a malformed escape, sent unencoded
https://rp.example/a/../cb (or %2e%2e) A browser resolves the segment away and requests /cb
https://rp.example:/cb, :0, :99999 Empty or impossible port
com.example.app://Wallet/cb Passed ParseRedirectURL (so client.New), but NewRegisteredClient refused it, so it was configurable but never registrable

None of these redirected anywhere other than the registered string. They're gaps in the normalized-form guarantee, and an embedder rendering the URL unescaped is the only real risk.

Change

  • fapi.ParseRedirectURL refuses all four forms, for every redirect form: https, loopback http, and both private-use forms. A private-use authority must be lowercase.

  • fapi.CheckRedirectURIForm(raw) is new. It's the form check on its own: written as sent, RFC 3986 query text, no dot segment, a port of 1–65535, whatever the scheme. A string that doesn't parse at all passes and is left to ParseRedirectURL, as before.

  • storage.NewRegisteredClient now uses CheckRedirectURIForm, so registration and the request-time parse can't disagree again. Whether a client may use a URI's scheme stays the server's decision per request.

    I tried having registration call ParseRedirectURL itself, but that also refuses URIs that are only unusable per request, such as non-loopback http on a web client. Since stored clients are rebuilt through NewRegisteredClient, one such URI would make a whole stored client fail to load, so I kept registration to form checks only.

Tests

  • Table tests: new cases in TestParseRedirectURL, and a new TestCheckRedirectURIForm, cover accepts and refusals for each rule.
  • Registration: NewRegisteredClient refuses each new form.
  • Fuzzing: FuzzParseRedirectURL now also asserts that every URI ParseRedirectURL accepts passes CheckRedirectURIForm. That's the cross-check the review noted was missing, since the old property restated the check itself. A 90 s run: 16.4M execs, no failures.
  • Query retention: this test's malformed-escape case (?bad=%zz) is no longer registrable. It now uses ?plus=%2b, a valid escape url.Values.Encode would still rewrite, so the byte-for-byte check stays meaningful.
  • Checks: all modules pass go test at Go 1.26.11; lint is clean.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Nov 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

v0.74.0 refused a redirect URI not written as it's sent, but net/url
gives a query back byte for byte, so a query with a raw space, angle
bracket or malformed %-encoding was accepted and sent unencoded; a
"." or ".." path segment was accepted though a browser resolves it
away; an empty or impossible port passed for https; and an uppercase
private-use authority passed fapi.ParseRedirectURL (so client.New)
while storage.NewRegisteredClient refused it.

fapi.ParseRedirectURL now refuses all four, and the new
fapi.CheckRedirectURIForm is the form check: written as sent, RFC 3986
query text, no dot segment, a port of 1 to 65535, whatever the
scheme. storage.NewRegisteredClient applies it to every registered
redirect URI, so registration and the request-time parse agree;
whether a client may use a URI's scheme stays the server's decision
per request. FuzzParseRedirectURL now checks that registration accepts
every URI ParseRedirectURL accepts.

BREAKING CHANGE: a redirect URI with a query RFC 3986 doesn't allow, a
dot path segment, an empty or impossible port, or an uppercase
private-use authority is refused by NewRegisteredClient,
fapi.ParseRedirectURL and client.New, and a stored client with one no
longer resolves. Register it encoded, resolved, with a real port or
none, and lowercase; see UPGRADING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@osanderson
osanderson force-pushed the fix/redirect-uri-query-dots-ports branch from 488fde6 to d321cdc Compare November 15, 2026 17:27
@sonarqubecloud

Copy link
Copy Markdown

@osanderson
osanderson merged commit 3604e69 into main Nov 15, 2026
33 checks passed
@osanderson
osanderson deleted the fix/redirect-uri-query-dots-ports branch November 15, 2026 22:23
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