mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-20 15:18:01 -05:00
fix: harden trusted request context (#9990)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
"@better-auth/core": patch
|
||||
"@better-auth/expo": patch
|
||||
---
|
||||
|
||||
Hardens how requests are trusted across several flows. Rate limiting is now enforced even when a client IP cannot be determined, instead of being skipped. When `baseURL` is not configured, password-reset and verification links use the current request's host rather than the host of the first request the server handled, and a request-scoped `trustedOrigins` callback no longer affects other concurrent requests. The OAuth proxy, Google One Tap, and the Expo authorization proxy reject redirect and callback targets that are not in `trustedOrigins`. Google reCAPTCHA and Cloudflare Turnstile accept optional `expectedAction` and `allowedHostnames` to reject tokens minted for a different action or hostname. Server-side fetches reject additional reserved IPv6 ranges, and malformed redirect parameters return a 400 instead of a 500.
|
||||
@@ -20,5 +20,9 @@ export const auth = betterAuth({
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
},
|
||||
},
|
||||
trustedOrigins: ["exp://"],
|
||||
// Trust the app-specific scheme only. The expo() plugin adds the broad
|
||||
// `exp://` development scheme automatically when NODE_ENV is development;
|
||||
// trusting it in production could leak the session cookie to a deep link
|
||||
// the app does not control.
|
||||
trustedOrigins: ["better-auth://"],
|
||||
});
|
||||
|
||||
@@ -440,7 +440,7 @@ const authOptions = {
|
||||
trustedOrigins: [
|
||||
"https://*.better-auth.com",
|
||||
"https://better-auth-demo-*-better-auth.vercel.app",
|
||||
"exp://",
|
||||
"better-auth://",
|
||||
"com.better-auth.demo:/",
|
||||
"https://appleid.apple.com",
|
||||
],
|
||||
|
||||
@@ -654,6 +654,98 @@ describe("origin check middleware", async () => {
|
||||
);
|
||||
expect(blocked.error?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should not skip origin check for a path that only shares a prefix with a skip path", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
trustedOrigins: ["https://trusted-site.com"],
|
||||
advanced: {
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
id: "test",
|
||||
init() {
|
||||
return {
|
||||
context: {
|
||||
skipOriginCheck: ["/public/data"],
|
||||
},
|
||||
};
|
||||
},
|
||||
endpoints: {
|
||||
siblingEndpoint: createAuthEndpoint(
|
||||
"/public/database",
|
||||
{
|
||||
method: "GET",
|
||||
query: z.object({
|
||||
callbackURL: z.string(),
|
||||
}),
|
||||
use: [originCheck((c) => c.query.callbackURL)],
|
||||
},
|
||||
async (c) => c.query.callbackURL,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// "/public/database" only shares a prefix with the configured skip path
|
||||
// "/public/data"; it must still have its callbackURL validated.
|
||||
const blocked = await client.$fetch(
|
||||
"/public/database?callbackURL=https://malicious.com",
|
||||
);
|
||||
expect(blocked.error?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("should reject a non-string redirect parameter with 400, not 500", async () => {
|
||||
const { auth } = await getTestInstance({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
advanced: {
|
||||
disableCSRFCheck: false,
|
||||
disableOriginCheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
// An object callbackURL reaches the global origin-check middleware before
|
||||
// endpoint schema validation; it must produce a controlled 400.
|
||||
const objectBodyRequest = new Request(
|
||||
"http://localhost:3000/api/auth/sign-in/email",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "test@test.com",
|
||||
password: "password12345",
|
||||
callbackURL: { object: true },
|
||||
}),
|
||||
},
|
||||
);
|
||||
const objectRes = await auth.handler(objectBodyRequest);
|
||||
expect(objectRes.status).toBe(400);
|
||||
|
||||
// Duplicate query callbackURL parameters arrive as an array.
|
||||
const duplicateQueryRequest = new Request(
|
||||
"http://localhost:3000/api/auth/sign-in/email?callbackURL=/a&callbackURL=/b",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "test@test.com",
|
||||
password: "password12345",
|
||||
}),
|
||||
},
|
||||
);
|
||||
const arrayRes = await auth.handler(duplicateQueryRequest);
|
||||
expect(arrayRes.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trusted origins with baseURL inferred from request", async () => {
|
||||
@@ -1069,3 +1161,105 @@ describe("disableCSRFCheck and disableOriginCheck separation", async () => {
|
||||
expect(res.data?.user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("request-scoped trusted origin isolation", async () => {
|
||||
it("does not let one request's trusted origins bleed into a concurrent request", async () => {
|
||||
let releaseA!: () => void;
|
||||
const aPaused = new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
let signalAtPause!: () => void;
|
||||
const aReachedPause = new Promise<void>((resolve) => {
|
||||
signalAtPause = resolve;
|
||||
});
|
||||
|
||||
const { auth } = await getTestInstance({
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
advanced: { disableCSRFCheck: false, disableOriginCheck: false },
|
||||
trustedOrigins: (request) =>
|
||||
request?.headers.get("x-tenant") === "a"
|
||||
? ["https://a.example"]
|
||||
: ["https://b.example"],
|
||||
plugins: [
|
||||
{
|
||||
id: "pause-tenant-a",
|
||||
onRequest: async (request) => {
|
||||
if (request.headers.get("x-tenant") === "a") {
|
||||
signalAtPause();
|
||||
await aPaused;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const makeRequest = (tenant: "a" | "b") =>
|
||||
new Request("http://localhost:3000/api/auth/sign-in/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-tenant": tenant,
|
||||
// Tenant A carries tenant B's origin: only the shared-context
|
||||
// bleed would let A trust it.
|
||||
origin: "https://b.example",
|
||||
cookie: "x=1",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: `${tenant}@example.com`,
|
||||
password: "password1234",
|
||||
}),
|
||||
});
|
||||
|
||||
// Start A; it pauses inside onRequest after base.ts resolved A's origins.
|
||||
const aResponsePromise = auth.handler(makeRequest("a"));
|
||||
await aReachedPause;
|
||||
// Run B to completion so it mutates any shared trust state.
|
||||
await auth.handler(makeRequest("b"));
|
||||
releaseA();
|
||||
|
||||
const aResponse = await aResponsePromise;
|
||||
// A must reject tenant B's origin; b.example is not in A's trusted list.
|
||||
expect(aResponse.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferred baseURL is not persisted across requests", async () => {
|
||||
it("does not reuse one request's host for a later request's token links", async () => {
|
||||
const resetURLs: string[] = [];
|
||||
const { auth, testUser } = await getTestInstance({
|
||||
baseURL: undefined,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
sendResetPassword: async ({ url }) => {
|
||||
resetURLs.push(url);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// First request arrives from one host.
|
||||
await auth.handler(
|
||||
new Request("https://untrusted.example/api/auth/request-password-reset", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: testUser.email, redirectTo: "/" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// A later request from the legitimate host must build its link from its
|
||||
// own host, not the host carried by the first request.
|
||||
await auth.handler(
|
||||
new Request("https://app.example/api/auth/request-password-reset", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: testUser.email, redirectTo: "/" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const legitURL = resetURLs.at(-1);
|
||||
expect(legitURL).toBeDefined();
|
||||
expect(legitURL).toContain("https://app.example");
|
||||
expect(legitURL).not.toContain("untrusted.example");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,9 +31,16 @@ function shouldSkipOriginCheck(ctx: GenericEndpointContext): boolean {
|
||||
try {
|
||||
const basePath = new URL(ctx.context.baseURL).pathname;
|
||||
const currentPath = normalizePathname(ctx.request.url, basePath);
|
||||
return skipOriginCheck.some((skipPath) =>
|
||||
currentPath.startsWith(skipPath),
|
||||
);
|
||||
// Match only an exact path or a slash-boundary child, so a configured
|
||||
// skip path like "/public/data" does not also skip "/public/database"
|
||||
// or "/public/data-delete" and silently disable origin/CSRF checks there.
|
||||
return skipOriginCheck.some((skipPath) => {
|
||||
const normalizedSkipPath = skipPath.replace(/\/+$/, "");
|
||||
return (
|
||||
currentPath === normalizedSkipPath ||
|
||||
currentPath.startsWith(`${normalizedSkipPath}/`)
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
@@ -79,7 +86,7 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
const newUserCallbackURL = body?.newUserCallbackURL;
|
||||
|
||||
const validateURL = (
|
||||
url: string | undefined,
|
||||
url: unknown,
|
||||
label:
|
||||
| "origin"
|
||||
| "callbackURL"
|
||||
@@ -90,6 +97,15 @@ export const originCheckMiddleware = createAuthMiddleware(async (ctx) => {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
// These values are read from the raw body/query before endpoint schema
|
||||
// validation. A JSON object/array body or duplicate query parameter
|
||||
// yields a non-string here; reject it as a controlled 400 instead of
|
||||
// letting a string method throw an uncontrolled 500. Never String()-coerce.
|
||||
if (typeof url !== "string") {
|
||||
throw APIError.fromStatus("BAD_REQUEST", {
|
||||
message: `Invalid ${label}: expected a string`,
|
||||
});
|
||||
}
|
||||
const isTrustedOrigin = ctx.context.isTrustedOrigin(url, {
|
||||
allowRelativePaths: label !== "origin",
|
||||
});
|
||||
|
||||
@@ -159,23 +159,33 @@ function getRateLimitStorage(
|
||||
|
||||
let ipWarningLogged = false;
|
||||
|
||||
// Sentinel IP segment for the shared rate-limit bucket used when no trusted
|
||||
// client IP can be derived. It is not a valid IP, so it never collides with a
|
||||
// real client IP key.
|
||||
const NO_TRUSTED_IP_KEY = "no-trusted-ip";
|
||||
|
||||
async function resolveRateLimitConfig(req: Request, ctx: AuthContext) {
|
||||
const basePath = new URL(ctx.baseURL).pathname;
|
||||
const path = normalizePathname(req.url, basePath);
|
||||
let currentWindow = ctx.rateLimit.window;
|
||||
let currentMax = ctx.rateLimit.max;
|
||||
const ip = getIp(req, ctx.options);
|
||||
if (!ip) {
|
||||
if (!ipWarningLogged) {
|
||||
ctx.logger.warn(
|
||||
"Rate limiting skipped: could not determine client IP address. " +
|
||||
"Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed.",
|
||||
);
|
||||
ipWarningLogged = true;
|
||||
}
|
||||
if (!ip && ctx.options.advanced?.ipAddress?.disableIpTracking) {
|
||||
// IP tracking is explicitly disabled; per-IP rate limiting does not apply.
|
||||
return null;
|
||||
}
|
||||
const key = createRateLimitKey(ip, path);
|
||||
if (!ip && !ipWarningLogged) {
|
||||
ctx.logger.warn(
|
||||
"Rate limiting could not determine a client IP and is falling back to a " +
|
||||
"single shared per-path bucket. Ensure your runtime forwards a trusted " +
|
||||
"client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed.",
|
||||
);
|
||||
ipWarningLogged = true;
|
||||
}
|
||||
// Fail closed when no client IP can be derived: key on a shared per-path
|
||||
// bucket and still enforce the limit, instead of skipping rate limiting
|
||||
// entirely (which let a client omit the IP header to bypass the limit).
|
||||
const key = createRateLimitKey(ip ?? NO_TRUSTED_IP_KEY, path);
|
||||
const specialRules = getDefaultSpecialRules();
|
||||
const specialRule = specialRules.find((rule) => rule.pathMatcher(path));
|
||||
|
||||
|
||||
@@ -322,8 +322,9 @@ describe("should work in development/test environment", () => {
|
||||
|
||||
describe("missing client IP warning", () => {
|
||||
const warningMessage =
|
||||
"Rate limiting skipped: could not determine client IP address. " +
|
||||
"Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed.";
|
||||
"Rate limiting could not determine a client IP and is falling back to a " +
|
||||
"single shared per-path bucket. Ensure your runtime forwards a trusted " +
|
||||
"client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed.";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -362,6 +363,36 @@ describe("missing client IP warning", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should fail closed and still enforce the limit when no client IP is available", async () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
vi.stubEnv("TEST", "false");
|
||||
vi.resetModules();
|
||||
|
||||
const { getTestInstance: getTestInstanceReloaded } = await import(
|
||||
"../../test-utils/test-instance"
|
||||
);
|
||||
const { client } = await getTestInstanceReloaded({
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
window: 10,
|
||||
max: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// With no derivable client IP, requests share one per-path bucket and
|
||||
// must still be limited rather than skipped (the previous fail-open let a
|
||||
// client omit the IP header to bypass rate limiting entirely).
|
||||
let limited = false;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const res = await client.getSession();
|
||||
if (res.error?.status === 429) {
|
||||
limited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(limited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv6 address normalization and rate limiting", () => {
|
||||
|
||||
@@ -42,13 +42,18 @@ export const createBetterAuth = <Options extends BetterAuthOptions>(
|
||||
resolveDynamicTrustedProxyHeaders(ctx.options),
|
||||
);
|
||||
} else {
|
||||
handlerCtx = ctx;
|
||||
// Static config with no baseURL: memoize on the shared ctx from
|
||||
// the first request. A concurrent-first-requests race is
|
||||
// harmless since both writes resolve to the same value. Cloning
|
||||
// via `Object.create` (as the dynamic branch does) would break
|
||||
// downstream references that depend on `ctx.options` being
|
||||
// mutated in place.
|
||||
// Resolve request-derived state on a per-request clone so it never
|
||||
// mutates the shared context. This isolates a request-dependent
|
||||
// `trustedOrigins`/`trustedProviders` callback from concurrent
|
||||
// requests, and (for the no-baseURL case) stops the first request's
|
||||
// host from being memoized onto the shared context, where it would
|
||||
// be reused for every later request's token links.
|
||||
handlerCtx = Object.create(
|
||||
Object.getPrototypeOf(ctx),
|
||||
Object.getOwnPropertyDescriptors(ctx),
|
||||
) as AuthContext;
|
||||
|
||||
let trustOptions = ctx.options;
|
||||
if (!ctx.options.baseURL) {
|
||||
const baseURL = getBaseURL(
|
||||
undefined,
|
||||
@@ -57,21 +62,25 @@ export const createBetterAuth = <Options extends BetterAuthOptions>(
|
||||
undefined,
|
||||
ctx.options.advanced?.trustedProxyHeaders,
|
||||
);
|
||||
if (baseURL) {
|
||||
ctx.baseURL = baseURL;
|
||||
ctx.options.baseURL = getOrigin(ctx.baseURL) || undefined;
|
||||
} else {
|
||||
if (!baseURL) {
|
||||
throw new BetterAuthError(
|
||||
"Could not get base URL from request. Please provide a valid base URL.",
|
||||
);
|
||||
}
|
||||
handlerCtx.baseURL = baseURL;
|
||||
handlerCtx.options = {
|
||||
...ctx.options,
|
||||
baseURL: getOrigin(baseURL) || undefined,
|
||||
};
|
||||
trustOptions = handlerCtx.options;
|
||||
}
|
||||
|
||||
handlerCtx.trustedOrigins = await getTrustedOrigins(
|
||||
ctx.options,
|
||||
trustOptions,
|
||||
request,
|
||||
);
|
||||
handlerCtx.trustedProviders = await getTrustedProviders(
|
||||
ctx.options,
|
||||
trustOptions,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -518,4 +518,97 @@ describe("captcha", async () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("action and hostname binding", async () => {
|
||||
it("rejects a Turnstile token whose action does not match expectedAction", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "xx-secret-key",
|
||||
expectedAction: "login",
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockBetterFetch.mockResolvedValue({
|
||||
data: { success: true, action: "signup", hostname: "myapp.com" },
|
||||
});
|
||||
const res = await client.signIn.email({
|
||||
email: "test@test.com",
|
||||
password: "test123456",
|
||||
fetchOptions: { headers: { "x-captcha-response": "token" } },
|
||||
});
|
||||
expect(res.error?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects a Turnstile token from a hostname outside allowedHostnames", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "xx-secret-key",
|
||||
allowedHostnames: ["myapp.com"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockBetterFetch.mockResolvedValue({
|
||||
data: { success: true, hostname: "untrusted.example" },
|
||||
});
|
||||
const res = await client.signIn.email({
|
||||
email: "test@test.com",
|
||||
password: "test123456",
|
||||
fetchOptions: { headers: { "x-captcha-response": "token" } },
|
||||
});
|
||||
expect(res.error?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects a reCAPTCHA v3 token whose action does not match expectedAction", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "google-recaptcha",
|
||||
secretKey: "xx-secret-key",
|
||||
expectedAction: "login",
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockBetterFetch.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
score: 0.9,
|
||||
action: "signup",
|
||||
hostname: "myapp.com",
|
||||
challenge_ts: "2022-02-28T15:14:30.096Z",
|
||||
},
|
||||
});
|
||||
const res = await client.signIn.email({
|
||||
email: "test@test.com",
|
||||
password: "test123456",
|
||||
fetchOptions: { headers: { "x-captcha-response": "token" } },
|
||||
});
|
||||
expect(res.error?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("accepts a token when action and hostname match", async () => {
|
||||
const { client, testUser } = await getTestInstance({
|
||||
plugins: [
|
||||
captcha({
|
||||
provider: "cloudflare-turnstile",
|
||||
secretKey: "xx-secret-key",
|
||||
expectedAction: "login",
|
||||
allowedHostnames: ["myapp.com"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
mockBetterFetch.mockResolvedValue({
|
||||
data: { success: true, action: "login", hostname: "myapp.com" },
|
||||
});
|
||||
const res = await client.signIn.email({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
fetchOptions: { headers: { "x-captcha-response": "token" } },
|
||||
});
|
||||
expect(res.error?.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,13 +86,19 @@ export const captcha = (options: CaptchaOptions) =>
|
||||
};
|
||||
|
||||
if (options.provider === Providers.CLOUDFLARE_TURNSTILE) {
|
||||
return await verifyHandlers.cloudflareTurnstile(handlerParams);
|
||||
return await verifyHandlers.cloudflareTurnstile({
|
||||
...handlerParams,
|
||||
expectedAction: options.expectedAction,
|
||||
allowedHostnames: options.allowedHostnames,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.provider === Providers.GOOGLE_RECAPTCHA) {
|
||||
return await verifyHandlers.googleRecaptcha({
|
||||
...handlerParams,
|
||||
minScore: options.minScore,
|
||||
expectedAction: options.expectedAction,
|
||||
allowedHostnames: options.allowedHostnames,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,31 @@ export interface BaseCaptchaOptions {
|
||||
export interface GoogleRecaptchaOptions extends BaseCaptchaOptions {
|
||||
provider: typeof Providers.GOOGLE_RECAPTCHA;
|
||||
minScore?: number | undefined;
|
||||
/**
|
||||
* Expected reCAPTCHA v3 `action`. When set, a verification whose action does
|
||||
* not match is rejected, preventing a token minted for another action on the
|
||||
* same site key from being replayed against this endpoint.
|
||||
*/
|
||||
expectedAction?: string | undefined;
|
||||
/**
|
||||
* Allow-list of hostnames the token must have been issued for. When set, a
|
||||
* verification reporting a different hostname is rejected.
|
||||
*/
|
||||
allowedHostnames?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface CloudflareTurnstileOptions extends BaseCaptchaOptions {
|
||||
provider: typeof Providers.CLOUDFLARE_TURNSTILE;
|
||||
/**
|
||||
* Expected Turnstile `action`. When set, a verification whose action does
|
||||
* not match is rejected, preventing cross-context token reuse.
|
||||
*/
|
||||
expectedAction?: string | undefined;
|
||||
/**
|
||||
* Allow-list of hostnames the token must have been issued for. When set, a
|
||||
* verification reporting a different or missing hostname is rejected.
|
||||
*/
|
||||
allowedHostnames?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface HCaptchaOptions extends BaseCaptchaOptions {
|
||||
|
||||
@@ -7,6 +7,8 @@ type Params = {
|
||||
secretKey: string;
|
||||
captchaResponse: string;
|
||||
remoteIP?: string | undefined;
|
||||
expectedAction?: string | undefined;
|
||||
allowedHostnames?: string[] | undefined;
|
||||
};
|
||||
|
||||
type SiteVerifyResponse = {
|
||||
@@ -29,6 +31,8 @@ export const cloudflareTurnstile = async ({
|
||||
captchaResponse,
|
||||
secretKey,
|
||||
remoteIP,
|
||||
expectedAction,
|
||||
allowedHostnames,
|
||||
}: Params) => {
|
||||
const response = await betterFetch<SiteVerifyResponse>(siteVerifyURL, {
|
||||
method: "POST",
|
||||
@@ -44,12 +48,32 @@ export const cloudflareTurnstile = async ({
|
||||
throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message);
|
||||
}
|
||||
|
||||
if (!response.data.success) {
|
||||
return middlewareResponse({
|
||||
const verificationFailed = () =>
|
||||
middlewareResponse({
|
||||
message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message,
|
||||
code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code,
|
||||
status: 403,
|
||||
});
|
||||
|
||||
if (!response.data.success) {
|
||||
return verificationFailed();
|
||||
}
|
||||
|
||||
// When configured, bind the token to the expected action and to an
|
||||
// allow-list of hostnames so a token issued for a different action or host
|
||||
// (e.g. under a shared widget or "Any Hostname") cannot be reused here.
|
||||
if (expectedAction && response.data.action !== expectedAction) {
|
||||
return verificationFailed();
|
||||
}
|
||||
if (
|
||||
allowedHostnames &&
|
||||
allowedHostnames.length > 0 &&
|
||||
!(
|
||||
response.data.hostname &&
|
||||
allowedHostnames.includes(response.data.hostname)
|
||||
)
|
||||
) {
|
||||
return verificationFailed();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -9,12 +9,15 @@ type Params = {
|
||||
captchaResponse: string;
|
||||
minScore?: number | undefined;
|
||||
remoteIP?: string | undefined;
|
||||
expectedAction?: string | undefined;
|
||||
allowedHostnames?: string[] | undefined;
|
||||
};
|
||||
|
||||
type SiteVerifyResponse = {
|
||||
success: boolean;
|
||||
challenge_ts: string;
|
||||
hostname: string;
|
||||
action?: string | undefined;
|
||||
"error-codes":
|
||||
| Array<
|
||||
| "missing-input-secret"
|
||||
@@ -43,6 +46,8 @@ export const googleRecaptcha = async ({
|
||||
secretKey,
|
||||
minScore = 0.5,
|
||||
remoteIP,
|
||||
expectedAction,
|
||||
allowedHostnames,
|
||||
}: Params) => {
|
||||
const response = await betterFetch<SiteVerifyResponse | SiteVerifyV3Response>(
|
||||
siteVerifyURL,
|
||||
@@ -61,15 +66,32 @@ export const googleRecaptcha = async ({
|
||||
throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message);
|
||||
}
|
||||
|
||||
if (
|
||||
!response.data.success ||
|
||||
(isV3(response.data) && response.data.score < minScore)
|
||||
) {
|
||||
return middlewareResponse({
|
||||
const verificationFailed = () =>
|
||||
middlewareResponse({
|
||||
message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message,
|
||||
code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code,
|
||||
status: 403,
|
||||
});
|
||||
|
||||
if (
|
||||
!response.data.success ||
|
||||
(isV3(response.data) && response.data.score < minScore)
|
||||
) {
|
||||
return verificationFailed();
|
||||
}
|
||||
|
||||
// When configured, bind the token to the expected v3 action and to an
|
||||
// allow-list of hostnames so a token minted for a different action or site
|
||||
// cannot be replayed against this endpoint.
|
||||
if (expectedAction && response.data.action !== expectedAction) {
|
||||
return verificationFailed();
|
||||
}
|
||||
if (
|
||||
allowedHostnames &&
|
||||
allowedHostnames.length > 0 &&
|
||||
!allowedHostnames.includes(response.data.hostname)
|
||||
) {
|
||||
return verificationFailed();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { GoogleProfile } from "@better-auth/core/social-providers";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { parseJSON } from "../../client/parser";
|
||||
import { signJWT, symmetricDecrypt, symmetricEncrypt } from "../../crypto";
|
||||
import { getTestInstance } from "../../test-utils/test-instance";
|
||||
@@ -1632,3 +1640,113 @@ describe("oauth-proxy", async () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth-proxy current URL trust", () => {
|
||||
it("does not use an untrusted request origin as the proxy callback receiver", async () => {
|
||||
const { auth } = await getTestInstance({
|
||||
baseURL: "https://myapp.com",
|
||||
plugins: [oAuthProxy({ productionURL: "https://login.myapp.com" })],
|
||||
socialProviders: {
|
||||
google: { clientId: "test", clientSecret: "test" },
|
||||
},
|
||||
});
|
||||
|
||||
// Sign-in initiated from a request host that is not
|
||||
// a trusted origin.
|
||||
const signInResponse = await auth.handler(
|
||||
new Request("https://untrusted.example/api/auth/sign-in/social", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ provider: "google", callbackURL: "/dashboard" }),
|
||||
}),
|
||||
);
|
||||
const { url } = (await signInResponse.json()) as { url: string };
|
||||
const state = new URL(url).searchParams.get("state");
|
||||
|
||||
const callbackResponse = await auth.handler(
|
||||
new Request(
|
||||
`https://login.myapp.com/api/auth/callback/google?code=test&state=${state}`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
);
|
||||
const location = callbackResponse.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
// Falls back to the configured base URL, never the untrusted request origin.
|
||||
expect(location).not.toContain("untrusted.example");
|
||||
expect(location).toContain(
|
||||
"https://myapp.com/api/auth/oauth-proxy-callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an explicitly trusted request origin as the proxy callback receiver", async () => {
|
||||
const { auth } = await getTestInstance({
|
||||
baseURL: "https://myapp.com",
|
||||
trustedOrigins: ["https://preview.myapp.com"],
|
||||
plugins: [oAuthProxy({ productionURL: "https://login.myapp.com" })],
|
||||
socialProviders: {
|
||||
google: { clientId: "test", clientSecret: "test" },
|
||||
},
|
||||
});
|
||||
|
||||
const signInResponse = await auth.handler(
|
||||
new Request("https://preview.myapp.com/api/auth/sign-in/social", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ provider: "google", callbackURL: "/dashboard" }),
|
||||
}),
|
||||
);
|
||||
const { url } = (await signInResponse.json()) as { url: string };
|
||||
const state = new URL(url).searchParams.get("state");
|
||||
|
||||
const callbackResponse = await auth.handler(
|
||||
new Request(
|
||||
`https://login.myapp.com/api/auth/callback/google?code=test&state=${state}`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
);
|
||||
const location = callbackResponse.headers.get("location");
|
||||
expect(location).toContain(
|
||||
"https://preview.myapp.com/api/auth/oauth-proxy-callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the base URL when the platform vendor value is not a URL", async () => {
|
||||
// AWS Lambda / GCP / Azure expose a bare function name, not a URL.
|
||||
vi.stubEnv("AWS_LAMBDA_FUNCTION_NAME", "my-lambda-function");
|
||||
try {
|
||||
const { auth } = await getTestInstance({
|
||||
baseURL: "https://myapp.com",
|
||||
plugins: [oAuthProxy({ productionURL: "https://login.myapp.com" })],
|
||||
socialProviders: {
|
||||
google: { clientId: "test", clientSecret: "test" },
|
||||
},
|
||||
});
|
||||
|
||||
const signInResponse = await auth.handler(
|
||||
new Request("https://untrusted.example/api/auth/sign-in/social", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const { url } = (await signInResponse.json()) as { url: string };
|
||||
const state = new URL(url).searchParams.get("state");
|
||||
|
||||
const callbackResponse = await auth.handler(
|
||||
new Request(
|
||||
`https://login.myapp.com/api/auth/callback/google?code=test&state=${state}`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
);
|
||||
const location = callbackResponse.headers.get("location");
|
||||
expect(location).toContain(
|
||||
"https://myapp.com/api/auth/oauth-proxy-callback",
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,17 +26,39 @@ function getVendorBaseURL() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current URL from various sources
|
||||
* Resolve the current URL from various sources.
|
||||
*
|
||||
* The request URL host can come from an untrusted source (`Host` / forwarded host),
|
||||
* and this origin becomes the receiver for the encrypted OAuth profile replay.
|
||||
* So a request-derived origin is only honored when it is an explicitly trusted
|
||||
* origin; otherwise resolution falls back to the configured platform/base URL,
|
||||
* never the raw request host. An explicit `opts.currentURL` and the vendor/base
|
||||
* URLs are configured by the developer and trusted as-is.
|
||||
*/
|
||||
export function resolveCurrentURL(
|
||||
ctx: GenericEndpointContext,
|
||||
opts?: OAuthProxyOptions,
|
||||
) {
|
||||
if (opts?.currentURL) {
|
||||
return new URL(opts.currentURL);
|
||||
}
|
||||
|
||||
const requestURL = ctx.request?.url;
|
||||
if (requestURL) {
|
||||
const origin = getOrigin(requestURL);
|
||||
if (origin && ctx.context.isTrustedOrigin(origin)) {
|
||||
return new URL(requestURL);
|
||||
}
|
||||
}
|
||||
|
||||
// getVendorBaseURL() returns a full URL on some platforms (Vercel, Netlify,
|
||||
// Render) but a bare function name on others (AWS Lambda, GCP, Azure). Only
|
||||
// use it when it parses as a URL; otherwise fall back to the base URL.
|
||||
const vendorBaseURL = getVendorBaseURL();
|
||||
return new URL(
|
||||
opts?.currentURL ||
|
||||
ctx.request?.url ||
|
||||
getVendorBaseURL() ||
|
||||
ctx.context.baseURL,
|
||||
vendorBaseURL && getOrigin(vendorBaseURL)
|
||||
? vendorBaseURL
|
||||
: ctx.context.baseURL,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -250,13 +250,19 @@ export const oneTapClient = (options: GoogleOneTapOptions) => {
|
||||
}
|
||||
|
||||
async function callback(idToken: string) {
|
||||
await $fetch("/one-tap/callback", {
|
||||
const res = await $fetch("/one-tap/callback", {
|
||||
method: "POST",
|
||||
body: { idToken },
|
||||
body: { idToken, callbackURL: opts?.callbackURL },
|
||||
...opts?.fetchOptions,
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
// The server validates callbackURL against trustedOrigins; do
|
||||
// not navigate if it rejected the request.
|
||||
if (res?.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((!opts?.fetchOptions && !fetchOptions) || opts?.callbackURL) {
|
||||
const target = opts?.callbackURL ?? "/";
|
||||
if (isSafeUrlScheme(target)) {
|
||||
@@ -299,13 +305,19 @@ export const oneTapClient = (options: GoogleOneTapOptions) => {
|
||||
}
|
||||
|
||||
async function callback(idToken: string) {
|
||||
await $fetch("/one-tap/callback", {
|
||||
const res = await $fetch("/one-tap/callback", {
|
||||
method: "POST",
|
||||
body: { idToken },
|
||||
body: { idToken, callbackURL: opts?.callbackURL },
|
||||
...opts?.fetchOptions,
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
// The server validates callbackURL against trustedOrigins; do
|
||||
// not navigate if it rejected the request.
|
||||
if (res?.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((!opts?.fetchOptions && !fetchOptions) || opts?.callbackURL) {
|
||||
const target = opts?.callbackURL ?? "/";
|
||||
if (isSafeUrlScheme(target)) {
|
||||
|
||||
@@ -38,6 +38,17 @@ const oneTapCallbackBodySchema = z.object({
|
||||
description:
|
||||
"Google ID token, which the client obtains from the One Tap API",
|
||||
}),
|
||||
/**
|
||||
* Sent so the global origin-check middleware validates the post-login
|
||||
* redirect target against `trustedOrigins`. Without it the client performs
|
||||
* an unvalidated `window.location` redirect, which is an open redirect.
|
||||
*/
|
||||
callbackURL: z
|
||||
.string()
|
||||
.meta({
|
||||
description: "URL to redirect to after a successful sign-in",
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const oneTap = (options?: OneTapOptions | undefined) =>
|
||||
|
||||
@@ -367,3 +367,51 @@ describe("one-tap implicit linking gate", async () => {
|
||||
expect(accounts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one-tap callbackURL origin validation", async () => {
|
||||
const googleProvider = {
|
||||
clientId: "test-client",
|
||||
clientSecret: "test-secret",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it("rejects an untrusted callbackURL via the global origin check", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
socialProviders: { google: googleProvider },
|
||||
plugins: [oneTap()],
|
||||
advanced: { disableOriginCheck: false },
|
||||
});
|
||||
|
||||
const res = await client.$fetch<{
|
||||
data: unknown;
|
||||
error: { status: number } | null;
|
||||
}>("/one-tap/callback", {
|
||||
method: "POST",
|
||||
body: {
|
||||
idToken: "stub-id-token",
|
||||
callbackURL: "https://untrusted.example/callback",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.error?.status).toBe(403);
|
||||
});
|
||||
|
||||
it("accepts a relative callbackURL (origin check passes)", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
socialProviders: { google: googleProvider },
|
||||
plugins: [oneTap()],
|
||||
advanced: { disableOriginCheck: false },
|
||||
});
|
||||
|
||||
const res = await client.$fetch<{
|
||||
data: unknown;
|
||||
error: { status: number } | null;
|
||||
}>("/one-tap/callback", {
|
||||
method: "POST",
|
||||
body: { idToken: "stub-id-token", callbackURL: "/dashboard" },
|
||||
});
|
||||
|
||||
// The origin check must not block a same-app relative redirect target.
|
||||
expect(res.error?.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,6 +233,40 @@ describe("Host Classification", () => {
|
||||
expect(classifyHost("2001:0:0:0:0:0:5601:5601").kind).toBe("reserved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IANA special-purpose ranges (not globally reachable)", () => {
|
||||
// @see https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
|
||||
it("should flag 64:ff9b:1::/48 local-use translation (RFC 8215)", () => {
|
||||
expect(classifyHost("64:ff9b:1::").kind).toBe("reserved");
|
||||
expect(classifyHost("64:ff9b:1::a9fe:a9fe").kind).toBe("reserved");
|
||||
expect(isPublicRoutableHost("64:ff9b:1::")).toBe(false);
|
||||
});
|
||||
|
||||
it("should flag 2001:2::/48 benchmarking (RFC 5180)", () => {
|
||||
expect(classifyHost("2001:2::").kind).toBe("benchmarking");
|
||||
expect(classifyHost("2001:2::1").kind).toBe("benchmarking");
|
||||
expect(isPublicRoutableHost("2001:2::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should flag 3fff::/20 documentation (RFC 9637)", () => {
|
||||
expect(classifyHost("3fff::").kind).toBe("documentation");
|
||||
expect(classifyHost("3fff:0fff::1").kind).toBe("documentation");
|
||||
expect(isPublicRoutableHost("3fff::")).toBe(false);
|
||||
});
|
||||
|
||||
it("should flag 5f00::/16 SRv6 SIDs (RFC 9602)", () => {
|
||||
expect(classifyHost("5f00::").kind).toBe("reserved");
|
||||
expect(classifyHost("5f00:abcd::1").kind).toBe("reserved");
|
||||
expect(isPublicRoutableHost("5f00::")).toBe(false);
|
||||
});
|
||||
|
||||
it("should keep globally-reachable hosts outside these blocks public", () => {
|
||||
// 2001:20::/28 (ORCHIDv2) is globally reachable; an address one
|
||||
// /48 outside the benchmarking block must stay public.
|
||||
expect(isPublicRoutableHost("2001:20::1")).toBe(true);
|
||||
expect(classifyHost("2001:2:1::").kind).toBe("public");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv4-Mapped IPv6 Handling", () => {
|
||||
|
||||
@@ -235,6 +235,10 @@ function classifyIPv6(expanded: string): HostKind {
|
||||
|
||||
if (expanded.startsWith("2001:0db8:")) return "documentation";
|
||||
|
||||
// 2001:2::/48 — Benchmarking (RFC 5180). A specific non-globally-reachable
|
||||
// block inside the otherwise-mixed 2001::/23 protocol-assignments space.
|
||||
if (expanded.startsWith("2001:0002:0000:")) return "benchmarking";
|
||||
|
||||
if (expanded.startsWith("2002:")) {
|
||||
const embedded = extractEmbeddedIPv4(expanded, 1);
|
||||
if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
|
||||
@@ -247,6 +251,10 @@ function classifyIPv6(expanded: string): HostKind {
|
||||
return "reserved";
|
||||
}
|
||||
|
||||
// 64:ff9b:1::/48 — Local-Use IPv4/IPv6 Translation (RFC 8215). Distinct from
|
||||
// the well-known NAT64 /96 prefix above and not globally reachable.
|
||||
if (expanded.startsWith("0064:ff9b:0001:")) return "reserved";
|
||||
|
||||
if (expanded.startsWith("2001:0000:")) {
|
||||
const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true });
|
||||
if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
|
||||
@@ -255,6 +263,13 @@ function classifyIPv6(expanded: string): HostKind {
|
||||
|
||||
if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved";
|
||||
|
||||
// 3fff::/20 — Documentation (RFC 9637). The /20 fixes the first 16 bits to
|
||||
// `3fff` and the next nibble to 0, so only `3fff:0xxx` is in range.
|
||||
if (expanded.startsWith("3fff:0")) return "documentation";
|
||||
|
||||
// 5f00::/16 — SRv6 SIDs (RFC 9602), not globally reachable.
|
||||
if (expanded.startsWith("5f00:")) return "reserved";
|
||||
|
||||
return "public";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SafeUrlSchema } from "./redirect-uri";
|
||||
import { isSafeUrlScheme } from "./url";
|
||||
import { isSafeUrlScheme, normalizePathname } from "./url";
|
||||
|
||||
describe("isSafeUrlScheme", () => {
|
||||
it("rejects code-execution schemes", () => {
|
||||
@@ -24,6 +24,45 @@ describe("isSafeUrlScheme", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePathname", () => {
|
||||
it("strips the basePath prefix", () => {
|
||||
expect(
|
||||
normalizePathname("http://localhost:3000/api/auth/sign-in", "/api/auth"),
|
||||
).toBe("/sign-in");
|
||||
});
|
||||
|
||||
it("canonicalizes a trailing-slash basePath", () => {
|
||||
// A baseURL of "https://app.com/api/auth/" yields basePath "/api/auth/".
|
||||
// Without canonicalization the prefix never matches and the full path
|
||||
// leaks through to disabledPaths / rate-limit special-rule matching.
|
||||
expect(
|
||||
normalizePathname("http://localhost:3000/api/auth/sign-in", "/api/auth/"),
|
||||
).toBe("/sign-in");
|
||||
expect(
|
||||
normalizePathname("http://localhost:3000/api/auth", "/api/auth/"),
|
||||
).toBe("/");
|
||||
});
|
||||
|
||||
it("treats '/' and empty basePath as no prefix", () => {
|
||||
expect(normalizePathname("http://localhost:3000/sign-in/", "/")).toBe(
|
||||
"/sign-in",
|
||||
);
|
||||
expect(normalizePathname("http://localhost:3000/sign-in", "")).toBe(
|
||||
"/sign-in",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not strip a basePath that is only a string prefix of the path", () => {
|
||||
expect(
|
||||
normalizePathname("http://localhost:3000/api/authevil/x", "/api/auth"),
|
||||
).toBe("/api/authevil/x");
|
||||
});
|
||||
|
||||
it("returns '/' for a malformed URL", () => {
|
||||
expect(normalizePathname("not a url", "/api/auth")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SafeUrlSchema", () => {
|
||||
it("rejects dangerous schemes", () => {
|
||||
expect(SafeUrlSchema.safeParse("javascript:alert(1)").success).toBe(false);
|
||||
|
||||
@@ -25,18 +25,24 @@ export function normalizePathname(
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (basePath === "/" || basePath === "") {
|
||||
// Canonicalize the basePath the same way as the request pathname. A baseURL
|
||||
// with a trailing slash yields a basePath like "/api/auth/"; without this it
|
||||
// would never match the slash-stripped pathname and the prefix would leak
|
||||
// through to disabledPaths and rate-limit special-rule matching.
|
||||
const normalizedBasePath = basePath.replace(/\/+$/, "");
|
||||
|
||||
if (normalizedBasePath === "") {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
// Check for exact match or proper path boundary (basePath followed by "/" or end)
|
||||
// This prevents "/api/auth" from matching "/api/authevil/..."
|
||||
if (pathname === basePath) {
|
||||
if (pathname === normalizedBasePath) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (pathname.startsWith(basePath + "/")) {
|
||||
return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
|
||||
if (pathname.startsWith(normalizedBasePath + "/")) {
|
||||
return pathname.slice(normalizedBasePath.length).replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
return pathname;
|
||||
|
||||
@@ -13,6 +13,41 @@ export const expoAuthorizationProxy = createAuthEndpoint(
|
||||
metadata: HIDE_METADATA,
|
||||
},
|
||||
async (ctx) => {
|
||||
const { authorizationURL } = ctx.query;
|
||||
|
||||
// This endpoint sets an OAuth state cookie and redirects, so the target
|
||||
// must be an external provider authorization endpoint. Reject malformed
|
||||
// or non-https targets and any same-origin Better Auth URL: a same-origin
|
||||
// target would allow a state cookie to be planted and a
|
||||
// login-CSRF / session-fixation flow through the auth domain.
|
||||
// FIXME(next): bind the redirect to a server-generated, signed proxy
|
||||
// token (or validate against the configured provider authorization
|
||||
// endpoints) to also close redirects to unrelated external https hosts.
|
||||
//
|
||||
// A fragment is never part of a valid OAuth authorization endpoint, and a
|
||||
// bare trailing "#" parses to an empty url.hash, so reject on the raw value.
|
||||
if (authorizationURL.includes("#")) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid authorizationURL",
|
||||
});
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(authorizationURL);
|
||||
} catch {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid authorizationURL",
|
||||
});
|
||||
}
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.origin === new URL(ctx.context.baseURL).origin
|
||||
) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid authorizationURL",
|
||||
});
|
||||
}
|
||||
|
||||
const { oauthState } = ctx.query;
|
||||
if (oauthState) {
|
||||
const oauthStateCookie = ctx.context.createAuthCookie("oauth_state", {
|
||||
@@ -23,11 +58,9 @@ export const expoAuthorizationProxy = createAuthEndpoint(
|
||||
oauthState,
|
||||
oauthStateCookie.attributes,
|
||||
);
|
||||
return ctx.redirect(ctx.query.authorizationURL);
|
||||
return ctx.redirect(authorizationURL);
|
||||
}
|
||||
|
||||
const { authorizationURL } = ctx.query;
|
||||
const url = new URL(authorizationURL);
|
||||
const state = url.searchParams.get("state");
|
||||
if (!state) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
|
||||
@@ -1427,3 +1427,59 @@ describe("ExpoOnlineManager duplicate notification prevention", () => {
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expo authorization proxy", async () => {
|
||||
const { auth } = await getTestInstance({
|
||||
baseURL: "https://app.example",
|
||||
socialProviders: {
|
||||
google: { clientId: "test", clientSecret: "test" },
|
||||
},
|
||||
plugins: [expo()],
|
||||
});
|
||||
|
||||
const proxy = (authorizationURL: string) =>
|
||||
auth.handler(
|
||||
new Request(
|
||||
`https://app.example/api/auth/expo-authorization-proxy?authorizationURL=${encodeURIComponent(
|
||||
authorizationURL,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
|
||||
it("rejects a same-origin authorizationURL (login-CSRF bounce)", async () => {
|
||||
const res = await proxy(
|
||||
"https://app.example/api/auth/callback/google?state=x",
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects a non-https authorizationURL", async () => {
|
||||
const res = await proxy(
|
||||
"http://accounts.google.com/o/oauth2/v2/auth?state=x",
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects a malformed authorizationURL", async () => {
|
||||
const res = await proxy("not-a-url");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects an authorizationURL with a fragment", async () => {
|
||||
const withFragment = await proxy(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?state=x#fragment",
|
||||
);
|
||||
expect(withFragment.status).toBe(400);
|
||||
const bareFragment = await proxy(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?state=x#",
|
||||
);
|
||||
expect(bareFragment.status).toBe(400);
|
||||
});
|
||||
|
||||
it("redirects to an external https provider authorization endpoint", async () => {
|
||||
const target =
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?client_id=x&state=abc";
|
||||
const res = await proxy(target);
|
||||
expect(res.headers.get("location")).toBe(target);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user