fix(runtime)!: enforce registered redirect_uri on /authorize - #6330
Merged
Conversation
The OAuth authorization endpoint accepted any redirect_uri that parsed as https, without comparing it to the requesting client. Registered redirect URIs were write-only: handleClientRegistration persisted them via saveClient, but getClient had no callers anywhere in the package, so the values a client declared at registration never constrained anything. The request's client_id was read and discarded. Add a single gate, checkRedirectUri, used by both /authorize and /oauth/callback: - client_id is required, resolved through persistence.getClient, and the redirect_uri must match one of that client's registered URIs by exact string comparison (RFC 6749 3.1.2.2). The only relaxation is a variable port on loopback URIs for native clients (RFC 8252 7.3). - A new optional allowedRedirectHosts config applies in addition, matching on a label boundary so a suffix cannot be spoofed by a longer host. http is accepted only for loopback. This is what a stateless deployment uses, where there is no client store to resolve. - With neither configured the gate rejects everything. The previous effective policy, any https URL, is not a policy. - Rejections render a 400 rather than redirecting to the URI under scrutiny (RFC 6749 4.1.2.1). The callback runs the same gate on the redirect URI it decodes from the state, before any of its three redirect paths. state was unsigned, and the upstream provider permits extra query params on its registered callback, so a state this server never issued could otherwise arrive at /oauth/callback and steer a real token off-origin. Fixing /authorize alone would not have closed that. Also add an optional stateSecret. When set, the state and the authorization code are AES-GCM sealed. The code previously carried the upstream provider's access and refresh tokens as plain base64url JSON, so a leaked code was a leaked token rather than something PKCE and the token endpoint could still contain. BREAKING CHANGE: /authorize now requires client_id and rejects every redirect_uri unless the server configures oauth.persistence or oauth.allowedRedirectHosts. Stateless deployments must set allowedRedirectHosts in the same deploy that picks up this version. In-flight authorizations do not survive the upgrade, since a state issued by the previous version carries no client_id.
decocms Bot
pushed a commit
that referenced
this pull request
Aug 20, 2026
PR: #6330 fix(runtime)!: enforce registered redirect_uri on /authorize Bump type: major - @decocms/runtime (packages/runtime/package.json): 2.4.2 -> 3.0.0 Deploy-Scope: both
This was referenced Aug 20, 2026
pedrofrxncx
added a commit
that referenced
this pull request
Aug 20, 2026
…tration (#6351) isValidRedirectUri (used by both /authorize and dynamic client registration) only special-cased 127.0.0.1/localhost/.localhost as non-https loopback exceptions, while the sibling isLoopbackHost() in redirect-uri.ts (added by #6330 for the /authorize matching path) also covers [::1]/::1 per RFC 8252 §7.3. A native client binding an ephemeral IPv6 loopback port would get its redirect_uri rejected at DCR (400 invalid_redirect_uri) even though the same URI would be accepted as a match against a registered client.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is this contribution about?
The OAuth authorization endpoint in
@decocms/runtimeaccepted anyredirect_urithat parsed as https, without comparing it to the requesting client.Registered redirect URIs were write-only.
handleClientRegistrationpersisted them throughsaveClient, butgetClienthad no callers anywhere in the package, so what a client declared at registration never constrained anything. The request'sclient_idwas read and then discarded. That is an open redirect on the authorization endpoint, and this runtime encodes the upstream provider token into the authorization code, so the consequence is worse than a normal code leak. PKCE does not help, because whoever builds the/authorizeURL also chooses the challenge.The fix adds one gate,
checkRedirectUri, called from both/authorizeand/oauth/callback:client_idis required, resolved throughpersistence.getClient, andredirect_urimust match one of that client's registered URIs by exact string comparison (RFC 6749 3.1.2.2). The only relaxation is a variable port on loopback URIs for native clients (RFC 8252 7.3).allowedRedirectHostsapplies in addition, matched on a label boundary so a suffix cannot be spoofed by a longer host.httpis accepted only for loopback. This is what a stateless deployment uses, where there is no client store to resolve.The callback runs the same gate on the redirect URI it decodes from the
state, before any of its three redirect paths.statewas unsigned and upstream providers permit extra query params on their registered callback, so a state this server never issued could otherwise reach/oauth/callbackand steer a real token off-origin. Fixing/authorizealone would not have closed that.Also adds an optional
stateSecret. When set, thestateand the authorization code are AES-GCM sealed. The code previously carried the upstream access and refresh tokens as plain base64url JSON, so a leaked code was a leaked token rather than something PKCE and the token endpoint could still contain.How did you verify your code works?
40 tests pass across
packages/runtime/src/oauth.test.tsand the newpackages/runtime/src/redirect-uri.test.ts.New coverage in
oauth.test.ts: unregisteredredirect_urirejected with no upstream redirect; missingclient_idrejected; unknownclient_idrejected asinvalid_client; registered URI differing only by a trailing slash or an extra query param rejected; exact match accepted; loopback port relaxation accepted while a differing path is not; everyredirect_urirejected when neither policy is configured; theallowedRedirectHostssuffix cases including the two near-miss hosts andhttpon an allowed host; a forgedstateat/oauth/callbackreturning 400 with nolocationheader on both the success and the upstream-error path; and thestateSecretround trip, including that the sealed code does not contain the upstream token and that a plaintext state is refused once a secret is configured.redirect-uri.test.tscovers the matchers directly, including path traversal and embedded-URL shapes.I inverted the existing test that asserted the old behaviour (
accepts an https redirect_uri and redirects to the upstream authorization URL) rather than appending a new one, since it encoded exactly this bug.bun run --cwd packages/runtime check,bun run fmt:check,bun run lintandknipare clean. Note thatbun test packages/runtimehas a pre-existing failure intriggers.test.tswhen the whole package runs at once; it fails identically on a stashed tree and passes when that file runs alone.How to Test
bun test packages/runtime/src/oauth.test.ts packages/runtime/src/redirect-uri.test.tsoauth.persistence, register a client through/register, then call/authorizewith aredirect_urithat is not the registered one.invalid_requestand nolocationheader, instead of a 302 to the upstream provider.Migration Notes
This is a breaking change for consumers of
@decocms/runtime, which is why the title carries!. It bumps onlypackages/runtime/package.json; the Studio app release line is untouched (scripts/release-changes.tsreturnspackageManifests: ["packages/runtime/package.json"]for this diff).Nothing in this repo configures
oauth, so there is no internal impact. Every downstream consumer is pinned to runtime 1.x, so nothing picks this up automatically.Consumers need, in the same deploy that takes the new version:
oauth.allowedRedirectHosts, or/authorizerejects every request by design.persistenceneed no config, but should audit storedredirect_urisfirst, since matching is now exact and anything registered with a trailing slash or stray query param will start failing.stateissued by the previous version carries noclient_id, so the new callback gate rejects it. Users retry and it works.stateSecretis opt-in and should be a separate, later deploy. Mid-rollout, an instance holding the secret cannot unseal a state written by one without it.Follow-up in
decocms/mcps: pass the existingALLOWED_REDIRECT_HOST_SUFFIXESinto the runtime config and deletegithub/server/lib/redirect-allowlist.ts, whoseassertAllowedRedirectUri()currently only ever sees the server's own origin and so cannot reject anything.Review Checklist
Summary by cubic
Closes an open redirect in
@decocms/runtimeOAuth by enforcing exactredirect_urivalidation. Previously/authorizeaccepted any https URL; now it requiresclient_idand only redirects to a URI registered for that client, preventing token leakage.checkRedirectUriused in both/authorizeand/oauth/callback.allowedRedirectHostsas an additional allowlist (label-boundary match).httpis only allowed for loopback. With neitherpersistencenorallowedRedirectHosts, all requests are rejected. Rejections return 400 instead of redirecting (RFC 6749 §4.1.2.1).redirect_uriat/oauth/callbackto block forgedstatefrom steering tokens off-origin.stateSecretto AES-GCM sealstateand our authorizationcode, removing upstream tokens from plaintext codes and making state tamper-evident.Migration
/authorizenow requiresclient_idand a validatedredirect_uri.oauth.allowedRedirectHosts, or every request is rejected.persistenceshould audit storedredirect_urisfor exact matches; trailing slashes or extra query params will now fail.stateSecretis optional; roll out separately. Mixed instances (with/without it) cannot read each other’sstate.Written for commit 29443ce. Summary will update on new commits.