fix(core): make redirect-uri validation runtime-safe and reject fragments (#9845)

This commit is contained in:
Gustavo Valverde
2026-05-31 13:37:39 +01:00
committed by GitHub
parent a6f38c72ee
commit 13abc7922b
4 changed files with 36 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@better-auth/oauth-provider": patch
"@better-auth/core": patch
"better-auth": patch
---
Harden redirect-URI validation across the OAuth provider plugins. `isSafeUrlScheme` and `SafeUrlSchema` no longer call `URL.canParse`, which is absent on some supported runtimes and could throw or silently disable the dangerous-scheme check. They now parse with a `try`/`catch` fallback. `SafeUrlSchema` also rejects redirect URIs that contain a fragment component, per RFC 6749 §3.1.2.

View File

@@ -7,6 +7,7 @@ import { DANGEROUS_URL_SCHEMES } from "./url";
* server stores and later hands back to a browser.
*
* - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
* - Rejects URIs with a fragment component (`#...`) per RFC 6749 §3.1.2.
* - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
* `*.localhost` per RFC 6761), where HTTP is allowed for local development.
* - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
@@ -16,7 +17,10 @@ import { DANGEROUS_URL_SCHEMES } from "./url";
* rather than re-implementing the scheme policy per plugin.
*/
export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
if (!URL.canParse(val)) {
let u: URL;
try {
u = new URL(val);
} catch {
ctx.addIssue({
code: "custom",
message: "URL must be parseable",
@@ -25,8 +29,6 @@ export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
return z.NEVER;
}
const u = new URL(val);
if (DANGEROUS_URL_SCHEMES.includes(u.protocol)) {
ctx.addIssue({
code: "custom",
@@ -35,6 +37,13 @@ export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
return;
}
if (val.includes("#")) {
ctx.addIssue({
code: "custom",
message: "Redirect URI must not contain a fragment component",
});
}
if (u.protocol === "http:" && !isLoopbackHost(u.host)) {
ctx.addIssue({
code: "custom",

View File

@@ -46,4 +46,16 @@ describe("SafeUrlSchema", () => {
);
expect(SafeUrlSchema.safeParse("http://127.0.0.1/cb").success).toBe(true);
});
it("rejects redirect URIs with a fragment component", () => {
expect(
SafeUrlSchema.safeParse("https://example.com/cb#token").success,
).toBe(false);
expect(SafeUrlSchema.safeParse("https://example.com/cb#").success).toBe(
false,
);
expect(SafeUrlSchema.safeParse("https://example.com/cb").success).toBe(
true,
);
});
});

View File

@@ -60,9 +60,12 @@ export const DANGEROUS_URL_SCHEMES = ["javascript:", "data:", "vbscript:"];
* execution schemes without rejecting relative paths or mobile deep links.
*/
export function isSafeUrlScheme(value: string): boolean {
if (!URL.canParse(value)) {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
// Relative URLs carry no scheme to abuse.
return true;
}
return !DANGEROUS_URL_SCHEMES.includes(new URL(value).protocol);
return !DANGEROUS_URL_SCHEMES.includes(parsed.protocol);
}