Skip to content

Commit a1c5cfa

Browse files
fix: resolve redirect URI security vulnerability ([#299](#299))
Fix malformed URL bypass where for instance redirect_uri=https:evil.com could redirect to external domains. Returns HTTP 400 for invalid redirect URIs based on default allow hook.
1 parent 98dc596 commit a1c5cfa

4 files changed

Lines changed: 456 additions & 13 deletions

File tree

‎bun.lockb‎

0 Bytes
Binary file not shown.

‎packages/openauth/src/issuer.ts‎

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ export interface OnSuccessResponder<
163163
): Promise<Response>
164164
}
165165

166+
export interface AllowCallbackInput {
167+
clientID: string
168+
redirectURI: string
169+
audience?: string
170+
}
171+
166172
/**
167173
* @internal
168174
*/
@@ -416,26 +422,31 @@ export interface IssuerInput<
416422
*
417423
* - Allow if the `redirectURI` is localhost.
418424
* - Compare `redirectURI` to the request's hostname or the `x-forwarded-host` header. If they
419-
* are from the same sub-domain level, then allow.
425+
* share the same apex domain, then allow.
426+
*
427+
* :::caution[Security Notice]
428+
* The default implementation allows ANY `redirect_uri` on the same apex domain with no per-client isolation.
429+
* Consider implementing a custom `allow` function with strict per-client validation if your deployment has:
430+
* - Untrusted content on subdomains (user-generated content, third-party scripts)
431+
* - Potential XSS attack vectors
432+
* - Multiple client applications requiring isolation
433+
* :::
420434
*
421435
* @example
436+
* Recommended for production (per-client allowlist):
422437
* ```ts
423438
* {
424439
* allow: async (input, req) => {
425-
* // Allow all clients
426-
* return true
440+
* const allowedRedirects = {
441+
* 'web-client': ['https://app.example.com/callback'],
442+
* 'mobile-client': ['https://admin.example.com/oauth'],
443+
* }
444+
* return allowedRedirects[input.clientID]?.includes(input.redirectURI) ?? false
427445
* }
428446
* }
429447
* ```
430448
*/
431-
allow?(
432-
input: {
433-
clientID: string
434-
redirectURI: string
435-
audience?: string
436-
},
437-
req: Request,
438-
): Promise<boolean>
449+
allow?(input: AllowCallbackInput, req: Request): Promise<boolean>
439450
}
440451

441452
/**
@@ -474,7 +485,7 @@ export function issuer<
474485
const allow = lazy(
475486
() =>
476487
input.allow ??
477-
(async (input: any, req: Request) => {
488+
(async (input: AllowCallbackInput, req: Request) => {
478489
const redir = new URL(input.redirectURI).hostname
479490
if (redir === "localhost" || redir === "127.0.0.1") {
480491
return true
@@ -1032,7 +1043,6 @@ export function issuer<
10321043
}
10331044
: undefined,
10341045
} as AuthorizationState
1035-
c.set("authorization", authorization)
10361046

10371047
if (!redirect_uri) {
10381048
return c.text("Missing redirect_uri", { status: 400 })
@@ -1062,6 +1072,7 @@ export function issuer<
10621072
)
10631073
throw new UnauthorizedClientError(client_id, redirect_uri)
10641074
await auth.set(c, "authorization", 60 * 60 * 24, authorization)
1075+
c.set("authorization", authorization)
10651076
if (provider) return c.redirect(`/${provider}/authorize`)
10661077
const providers = Object.keys(input.providers)
10671078
if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`)
@@ -1138,6 +1149,21 @@ export function issuer<
11381149

11391150
app.onError(async (err, c) => {
11401151
console.error(err)
1152+
1153+
if (err instanceof UnauthorizedClientError) {
1154+
return c.json(
1155+
{ error: err.error, error_description: err.description },
1156+
400,
1157+
)
1158+
}
1159+
1160+
if (err instanceof MissingParameterError) {
1161+
return c.json(
1162+
{ error: err.error, error_description: err.description },
1163+
400,
1164+
)
1165+
}
1166+
11411167
if (err instanceof UnknownStateError) {
11421168
return auth.forward(c, await error(err, c.req.raw))
11431169
}

‎packages/openauth/test/issuer.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,95 @@ describe("code flow", () => {
119119
})
120120
})
121121

122+
describe("error handling (same-request)", () => {
123+
test("select() throws after authorization is set -> must redirect with OAuth error", async () => {
124+
// Two entries pointing to the same dummy provider to trigger the select() UI
125+
const multiProviderIssuer = issuer({
126+
...issuerConfig,
127+
providers: {
128+
a: issuerConfig.providers.dummy,
129+
b: issuerConfig.providers.dummy,
130+
},
131+
// Force an error after state is set but before the response is returned
132+
select: async () => {
133+
throw new Error("boom")
134+
},
135+
})
136+
137+
const client = createClient({
138+
issuer: "https://auth.example.com",
139+
clientID: "web",
140+
fetch: (a, b) => Promise.resolve(multiProviderIssuer.request(a, b)),
141+
})
142+
143+
const { url } = await client.authorize(
144+
"https://client.example.com/callback",
145+
"code",
146+
)
147+
148+
const res = await multiProviderIssuer.request(url)
149+
150+
// Desired behavior: redirect to redirect_uri with server_error
151+
expect(res.status).toBe(302)
152+
const location = new URL(res.headers.get("location")!)
153+
expect(location.origin + location.pathname).toBe(
154+
"https://client.example.com/callback",
155+
)
156+
expect(location.searchParams.get("error")).toBe("server_error")
157+
})
158+
})
159+
160+
describe("authorization precedence", () => {
161+
test("cookie wins over request-local when both are present", async () => {
162+
// 1) Create a stale authorization cookie pointing to old.example.com
163+
const staleIssuer = issuer(issuerConfig)
164+
const staleClient = createClient({
165+
issuer: "https://auth.example.com",
166+
clientID: "web",
167+
fetch: (a, b) => Promise.resolve(staleIssuer.request(a, b)),
168+
})
169+
const { url: staleUrl } = await staleClient.authorize(
170+
"https://old.example.com/callback",
171+
"code",
172+
)
173+
const staleRes = await staleIssuer.request(staleUrl)
174+
expect(staleRes.status).toBe(302)
175+
const staleCookie = staleRes.headers.get("set-cookie")!
176+
177+
// 2) In a new request, also create a fresh request-local authorization but throw before responding
178+
// to trigger app.onError within the same request. Include the stale cookie in the request.
179+
const throwingIssuer = issuer({
180+
...issuerConfig,
181+
providers: {
182+
a: issuerConfig.providers.dummy,
183+
b: issuerConfig.providers.dummy,
184+
},
185+
select: async () => {
186+
throw new Error("boom")
187+
},
188+
})
189+
const freshClient = createClient({
190+
issuer: "https://auth.example.com",
191+
clientID: "web",
192+
fetch: (a, b) => Promise.resolve(throwingIssuer.request(a, b)),
193+
})
194+
const { url: freshUrl } = await freshClient.authorize(
195+
"https://new.example.com/callback",
196+
"code",
197+
)
198+
const res = await throwingIssuer.request(freshUrl, {
199+
headers: { cookie: staleCookie },
200+
})
201+
202+
// If cookie has precedence, we should be redirected to the OLD callback
203+
expect(res.status).toBe(302)
204+
const location = new URL(res.headers.get("location")!)
205+
expect(location.origin + location.pathname).toBe(
206+
"https://old.example.com/callback",
207+
)
208+
})
209+
})
210+
122211
describe("client credentials flow", () => {
123212
test("success", async () => {
124213
const client = createClient({

0 commit comments

Comments
 (0)