improve clean redirects

This commit is contained in:
miloschwartz
2025-12-20 12:00:58 -05:00
parent afc19f192b
commit 9ffa391416
3 changed files with 84 additions and 17 deletions

View File

@@ -66,6 +66,7 @@ export default async function Page(props: {
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);
searchParams.redirect = redirectUrl;
}
let loginIdps: LoginFormIDP[] = [];

View File

@@ -15,6 +15,7 @@ import {
useSearchParams
} from "next/navigation";
import { useRouter } from "next/navigation";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export type LoginFormIDP = {
idpId: number;
@@ -63,9 +64,10 @@ export default function IdpLoginButtons({
redirect || "/",
orgId
);
const safeRedirect = cleanRedirect(redirect || "/");
const response = await generateOidcUrlProxy(
idpId,
redirect || "/",
safeRedirect,
orgId
);

View File

@@ -1,22 +1,86 @@
type PatternConfig = {
name: string;
regex: RegExp;
type CleanRedirectOptions = {
fallback?: string;
maxRedirectDepth?: number;
};
const patterns: PatternConfig[] = [
{ name: "Invite Token", regex: /^\/invite\?token=[a-zA-Z0-9-]+$/ },
{ name: "Setup", regex: /^\/setup$/ },
{ name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ },
{
name: "Device Login",
regex: /^\/auth\/login\/device(\?code=[a-zA-Z0-9-]+)?$/
}
];
const ALLOWED_QUERY_PARAMS = new Set([
"forceLogin",
"code",
"token",
"redirect"
]);
const DUMMY_BASE = "https://internal.local";
export function cleanRedirect(
input: string,
options: CleanRedirectOptions = {}
): string {
const { fallback = "/", maxRedirectDepth = 2 } = options;
export function cleanRedirect(input: string, fallback?: string): string {
if (!input || typeof input !== "string") {
return "/";
return fallback;
}
try {
return sanitizeUrl(input, fallback, maxRedirectDepth);
} catch {
return fallback;
}
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
return isAccepted ? input : fallback || "/";
}
function sanitizeUrl(
input: string,
fallback: string,
remainingRedirectDepth: number
): string {
if (
input.startsWith("javascript:") ||
input.startsWith("data:") ||
input.startsWith("//")
) {
return fallback;
}
const url = new URL(input, DUMMY_BASE);
// Must be a relative/internal path
if (url.origin !== DUMMY_BASE) {
return fallback;
}
if (!url.pathname.startsWith("/")) {
return fallback;
}
const cleanParams = new URLSearchParams();
for (const [key, value] of url.searchParams.entries()) {
if (!ALLOWED_QUERY_PARAMS.has(key)) {
continue;
}
if (key === "redirect") {
if (remainingRedirectDepth <= 0) {
continue;
}
const cleanedRedirect = sanitizeUrl(
value,
"",
remainingRedirectDepth - 1
);
if (cleanedRedirect) {
cleanParams.set("redirect", cleanedRedirect);
}
continue;
}
cleanParams.set(key, value);
}
const queryString = cleanParams.toString();
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
}