chore: dispaly warning when baseURL isn't available (#6787)

This commit is contained in:
Bereket Engida
2025-12-19 08:28:23 -08:00
committed by GitHub
parent 373a1c658a
commit b9c346e944
5 changed files with 606 additions and 2 deletions

View File

@@ -273,6 +273,7 @@ export const router = <Option extends BetterAuthOptions>(
//handle disabled paths
const disabledPaths = ctx.options.disabledPaths || [];
const pathname = new URL(req.url).pathname.replace(/\/+$/, "") || "/";
const normalizedPath =
basePath === "/"
? pathname

View File

@@ -463,11 +463,287 @@ describe("disabled paths", async () => {
});
const response = await auth.handler(
new Request("http://localhost:3000/api/auth/sign-in/email/", {
new Request("http://localhost:3000/api/auth/sign-in/email%2F", {
method: "POST",
}),
);
const response2 = await auth.handler(
new Request("http://localhost:3000/api/auth/sign-inemail", {
method: "POST",
}),
);
expect(response.status).toBe(404);
expect(response2.status).toBe(404);
});
it("should return 404 for encoded paths", async () => {
const { auth } = await getTestInstance({
disabledPaths: ["/sign-in/email"],
});
const response = await auth.handler(
new Request("http://localhost:3000/api/auth/sign-in/email%2F", {
method: "POST",
}),
);
const _response2 = await auth.handler(
new Request("http://localhost:3000/api/auth/sign-inemail", {
method: "POST",
}),
);
expect(response.status).toBe(404);
});
it("should block URL encoded slash bypass attempts", async () => {
const { auth } = await getTestInstance({
disabledPaths: ["/sign-in/email"],
});
// Try various URL encoding bypass attempts
const encodedAttempts = [
"http://localhost:3000/api/auth/sign-in%2Femail", // %2F = /
"http://localhost:3000/api/auth/sign-in%252Femail", // Double encoded
"http://localhost:3000/api/auth/sign-in%2femail", // lowercase hex
];
for (const url of encodedAttempts) {
const response = await auth.handler(
new Request(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@test.com",
password: "test",
}),
}),
);
// Should either block (404) or normalize and block
expect(response.status).toBe(404);
}
});
it("should block path traversal attempts", async () => {
const { auth } = await getTestInstance({
disabledPaths: ["/sign-in/email"],
});
// Try path traversal attempts
const traversalAttempts = [
"http://localhost:3000/api/auth/sign-in/../sign-in/email",
"http://localhost:3000/api/auth/./sign-in/email",
"http://localhost:3000/api/auth/sign-in/./email",
"http://localhost:3000/api/auth/sign-in//email",
"http://localhost:3000/api/auth/sign-in///email",
];
for (const url of traversalAttempts) {
const response = await auth.handler(
new Request(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@test.com",
password: "test",
}),
}),
);
expect(response.status).toBe(404);
}
});
it("should handle unicode and special characters in disabled paths", async () => {
const { auth } = await getTestInstance({
disabledPaths: ["/sign-in/email"],
});
// Try unicode normalization attacks
const specialAttempts = [
"http://localhost:3000/api/auth/sign-in%00/email", // Null byte
"http://localhost:3000/api/auth/sign-in\u0000/email", // Unicode null
"http://localhost:3000/api/auth/sign-in/email%09", // Tab character
"http://localhost:3000/api/auth/sign-in/email%20", // Space
];
for (const url of specialAttempts) {
try {
const response = await auth.handler(
new Request(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@test.com",
password: "test",
}),
}),
);
// Should either block or handle safely
expect([404, 400]).toContain(response.status);
} catch (e) {
// URL constructor may throw for invalid URLs - this is acceptable
expect(e).toBeDefined();
}
}
});
it("should not be affected by case sensitivity bypass", async () => {
const { auth } = await getTestInstance({
disabledPaths: ["/sign-in/email"],
});
// Try case variations (these should NOT be blocked unless explicitly added)
const caseVariations = [
"http://localhost:3000/api/auth/Sign-In/Email",
"http://localhost:3000/api/auth/SIGN-IN/EMAIL",
"http://localhost:3000/api/auth/Sign-in/email",
];
for (const url of caseVariations) {
const response = await auth.handler(
new Request(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@test.com",
password: "test",
}),
}),
);
// These should NOT be blocked (404) - they're different paths
// The endpoint itself will return an error since it doesn't exist
expect(response.status).not.toBe(200);
}
});
});
describe("trustedProxyHeaders security", () => {
it("should not use X-Forwarded headers when trustedProxyHeaders is false", async () => {
let capturedBaseURL: string | undefined;
const { auth } = await getTestInstance({
baseURL: undefined,
advanced: {
trustedProxyHeaders: false,
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
capturedBaseURL = ctx.context.baseURL;
}),
},
});
await auth.handler(
new Request("http://localhost:3000/api/auth/ok", {
method: "GET",
headers: {
"x-forwarded-host": "evil.com",
"x-forwarded-proto": "https",
},
}),
);
// Should use the actual request URL, not the forwarded headers
expect(capturedBaseURL).toBe("http://localhost:3000/api/auth");
expect(capturedBaseURL).not.toContain("evil.com");
});
it("should validate X-Forwarded headers when trustedProxyHeaders is true", async () => {
let capturedBaseURL: string | undefined;
const { auth } = await getTestInstance({
baseURL: undefined,
advanced: {
trustedProxyHeaders: true,
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
capturedBaseURL = ctx.context.baseURL;
}),
},
});
await auth.handler(
new Request("http://localhost:3000/api/auth/ok", {
method: "GET",
headers: {
"x-forwarded-host": "trusted-proxy.com",
"x-forwarded-proto": "https",
},
}),
);
// When trusted, should use the forwarded headers
expect(capturedBaseURL).toBe("https://trusted-proxy.com/api/auth");
});
it("should not trust partial X-Forwarded headers", async () => {
const { auth } = await getTestInstance({
baseURL: undefined,
advanced: {
trustedProxyHeaders: true,
},
});
// Only X-Forwarded-Host without X-Forwarded-Proto
const response1 = await auth.handler(
new Request("http://localhost:3000/api/auth/ok", {
method: "GET",
headers: {
"x-forwarded-host": "evil.com",
// Missing x-forwarded-proto
},
}),
);
expect(response1.status).toBe(200);
// Only X-Forwarded-Proto without X-Forwarded-Host
const response2 = await auth.handler(
new Request("http://localhost:3000/api/auth/ok", {
method: "GET",
headers: {
// Missing x-forwarded-host
"x-forwarded-proto": "https",
},
}),
);
expect(response2.status).toBe(200);
});
it("should handle malformed X-Forwarded headers gracefully", async () => {
const { auth } = await getTestInstance({
baseURL: "http://localhost:3000",
advanced: {
trustedProxyHeaders: true,
},
});
const malformedHeaders = [
{
"x-forwarded-host": "../../../../etc/passwd",
"x-forwarded-proto": "http",
},
{ "x-forwarded-host": "evil.com:99999", "x-forwarded-proto": "http" },
{ "x-forwarded-host": "evil.com", "x-forwarded-proto": "javascript" },
{ "x-forwarded-host": "evil.com", "x-forwarded-proto": "file" },
{ "x-forwarded-host": "", "x-forwarded-proto": "http" },
{ "x-forwarded-host": " ", "x-forwarded-proto": "http" },
];
for (const headers of malformedHeaders) {
const response = await auth.handler(
new Request("http://localhost:3000/api/auth/ok", {
method: "GET",
headers,
}),
);
// Should either use fallback baseURL or handle gracefully
expect(response.status).toBe(200);
}
});
});

View File

@@ -104,6 +104,12 @@ export async function createAuthContext(
const logger = createLogger(options.logger);
const baseURL = getBaseURL(options.baseURL, options.basePath);
if (!baseURL && !options.advanced?.trustedProxyHeaders) {
logger.error(
`[better-auth] Base URL could not be determined. Please set a valid base URL using the baseURL config option or the BETTER_AUTH_BASE_URL environment variable. Without this, callbacks and redirects may not work correctly.`,
);
}
const secret =
options.secret ||
env.BETTER_AUTH_SECRET ||

View File

@@ -0,0 +1,263 @@
import { describe, expect, it } from "vitest";
import { getBaseURL } from "./url";
describe("getBaseURL", () => {
describe("trustedProxyHeaders validation", () => {
it("should reject malicious protocol in X-Forwarded-Proto", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "example.com",
"x-forwarded-proto": "javascript",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:3000/auth");
});
it("should reject file protocol in X-Forwarded-Proto", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "example.com",
"x-forwarded-proto": "file",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:3000/auth");
});
it("should reject path traversal in X-Forwarded-Host", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "../../../etc/passwd",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:3000/auth");
});
it("should reject null bytes in X-Forwarded-Host", () => {
expect(() => {
new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "evil.com\u0000.example.com",
"x-forwarded-proto": "http",
},
});
}).toThrow();
});
it("should reject HTML injection in X-Forwarded-Host", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "<script>alert('xss')</script>",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:3000/auth");
});
it("should reject empty X-Forwarded-Host", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:3000/auth");
});
it("should reject whitespace-only X-Forwarded-Host", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": " ",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:3000/auth");
});
it("should accept valid hostname with port", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "example.com:8080",
"x-forwarded-proto": "https",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("https://example.com:8080/auth");
});
it("should accept valid IPv4 address", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "192.168.1.1",
"x-forwarded-proto": "https",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("https://192.168.1.1/auth");
});
it("should accept valid IPv4 address with port", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "192.168.1.1:3000",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://192.168.1.1:3000/auth");
});
it("should accept valid IPv6 address in brackets", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "[2001:db8::1]",
"x-forwarded-proto": "https",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("https://[2001:db8::1]/auth");
});
it("should accept localhost", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "localhost",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost/auth");
});
it("should accept localhost with port", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "localhost:8080",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:8080/auth");
});
it("should reject invalid port numbers", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "example.com:999999",
"x-forwarded-proto": "http",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
// Should fall back to request URL due to invalid port
expect(result).toBe("http://localhost:3000/auth");
});
it("should only accept http or https protocols", () => {
const protocols = ["ftp", "ws", "wss", "data", "blob", "javascript"];
for (const proto of protocols) {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "example.com",
"x-forwarded-proto": proto,
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("http://localhost:3000/auth");
}
});
it("should not use proxy headers when trustedProxyHeaders is false", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "evil.com",
"x-forwarded-proto": "https",
},
});
const result = getBaseURL(undefined, "/auth", request, false, false);
expect(result).toBe("http://localhost:3000/auth");
});
it("should require both headers to be present", () => {
// Only host
const request1 = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "example.com",
},
});
const result1 = getBaseURL(undefined, "/auth", request1, false, true);
expect(result1).toBe("http://localhost:3000/auth");
// Only proto
const request2 = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-proto": "https",
},
});
const result2 = getBaseURL(undefined, "/auth", request2, false, true);
expect(result2).toBe("http://localhost:3000/auth");
});
it("should handle subdomain correctly", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "api.example.com",
"x-forwarded-proto": "https",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("https://api.example.com/auth");
});
it("should handle deep subdomain correctly", () => {
const request = new Request("http://localhost:3000/test", {
headers: {
"x-forwarded-host": "api.v1.staging.example.com",
"x-forwarded-proto": "https",
},
});
const result = getBaseURL(undefined, "/auth", request, false, true);
expect(result).toBe("https://api.v1.staging.example.com/auth");
});
});
});

View File

@@ -50,6 +50,57 @@ function withPath(url: string, path = "/api/auth") {
return `${trimmedUrl}${path}`;
}
function validateProxyHeader(header: string, type: "host" | "proto"): boolean {
if (!header || header.trim() === "") {
return false;
}
if (type === "proto") {
// Only allow http and https protocols
return header === "http" || header === "https";
}
if (type === "host") {
const suspiciousPatterns = [
/\.\./, // Path traversal
/\0/, // Null bytes
/[\s]/, // Whitespace (except legitimate spaces that should be trimmed)
/^[.]/, // Starting with dot
/[<>'"]/, // HTML/script injection characters
/javascript:/i, // Protocol injection
/file:/i, // File protocol
/data:/i, // Data protocol
];
if (suspiciousPatterns.some((pattern) => pattern.test(header))) {
return false;
}
// Basic hostname validation (allows localhost, IPs, and domains with ports)
// This is a simple check, not exhaustive RFC validation
const hostnameRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(:[0-9]{1,5})?$/;
// Also allow IPv4 addresses
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(:[0-9]{1,5})?$/;
// Also allow IPv6 addresses in brackets
const ipv6Regex = /^\[[0-9a-fA-F:]+\](:[0-9]{1,5})?$/;
// Allow localhost variations
const localhostRegex = /^localhost(:[0-9]{1,5})?$/i;
return (
hostnameRegex.test(header) ||
ipv4Regex.test(header) ||
ipv6Regex.test(header) ||
localhostRegex.test(header)
);
}
return false;
}
export function getBaseURL(
url?: string,
path?: string,
@@ -78,7 +129,14 @@ export function getBaseURL(
const fromRequest = request?.headers.get("x-forwarded-host");
const fromRequestProto = request?.headers.get("x-forwarded-proto");
if (fromRequest && fromRequestProto && trustedProxyHeaders) {
return withPath(`${fromRequestProto}://${fromRequest}`, path);
if (
validateProxyHeader(fromRequestProto, "proto") &&
validateProxyHeader(fromRequest, "host")
) {
try {
return withPath(`${fromRequestProto}://${fromRequest}`, path);
} catch (_error) {}
}
}
if (request) {