verify redirects are safe before redirecting

This commit is contained in:
Milo Schwartz 2025-01-09 23:21:57 -05:00
parent a556339b76
commit 6c813186b8
No known key found for this signature in database
18 changed files with 99 additions and 45 deletions

View file

@ -101,7 +101,8 @@ export async function verifyResourceSession(
return allowed(res);
}
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
// const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}`;
if (!sessions) {
return notAllowed(res);

View file

@ -25,7 +25,7 @@ export default async function OrgLayout(props: {
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${orgId}`);
redirect(`/`);
}
try {

View file

@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${orgId}/settings/general`);
redirect(`/`);
}
let orgUser = null;

View file

@ -61,7 +61,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${params.orgId}/`);
redirect(`/`);
}
const cookie = await authCookieHeader();

View file

@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
type DashboardLoginFormProps = {
redirect?: string;
@ -57,10 +58,9 @@ export default function DashboardLoginForm({
<LoginForm
redirect={redirect}
onLogin={() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
} else if (redirect) {
router.push(redirect);
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}

View file

@ -5,6 +5,7 @@ import { cache } from "react";
import DashboardLoginForm from "./DashboardLoginForm";
import { Mail } from "lucide-react";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
@ -25,6 +26,11 @@ export default async function Page(props: {
redirect("/");
}
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
return (
<>
{isInvite && (
@ -42,16 +48,16 @@ export default async function Page(props: {
</div>
)}
<DashboardLoginForm redirect={searchParams.redirect as string} />
<DashboardLoginForm redirect={redirectUrl} />
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">
Don't have an account?{" "}
<Link
href={
!searchParams.redirect
!redirectUrl
? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}`
: `/auth/signup?redirect=${redirectUrl}`
}
className="underline"
>

View file

@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { passwordSchema } from "@server/auth/passwordSchema";
import { cleanRedirect } from "@app/lib/cleanRedirect";
const requestSchema = z.object({
email: z.string().email()
@ -186,11 +187,9 @@ export default function ResetPasswordForm({
setSuccessMessage("Password reset successfully! Back to login...");
setTimeout(() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
}
if (redirect) {
router.push(redirect);
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/login");
}

View file

@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import ResetPasswordForm from "./ResetPasswordForm";
import Link from "next/link";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
@ -21,6 +22,11 @@ export default async function Page(props: {
redirect("/");
}
let redirectUrl: string | undefined = undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect);
}
return (
<>
<ResetPasswordForm
@ -34,7 +40,7 @@ export default async function Page(props: {
href={
!searchParams.redirect
? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}`
: `/auth/signup?redirect=${redirectUrl}`
}
className="underline"
>

View file

@ -481,11 +481,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<LoginForm
redirect={
typeof window !== "undefined"
? window.location.href
: ""
}
redirect={`/auth/resource/${props.resource.id}`}
onLogin={async () =>
await handleSSOAuth()
}

View file

@ -55,7 +55,17 @@ export default async function ResourceAuthPage(props: {
);
}
const redirectUrl = searchParams.redirect || authInfo.url;
let redirectUrl = authInfo.url;
// if (searchParams.redirect) {
// try {
// const serverResourceHost = new URL(authInfo.url).host;
// const redirectHost = new URL(searchParams.redirect).host;
//
// if (serverResourceHost === redirectHost) {
// redirectUrl = searchParams.redirect;
// }
// } catch (e) {}
// }
const hasAuth =
authInfo.password ||

View file

@ -30,6 +30,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
type SignupFormProps = {
redirect?: string;
@ -92,17 +93,17 @@ export default function SignupForm({
if (res.data?.data?.emailVerificationRequired) {
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
const safe = cleanRedirect(redirect);
router.push(`/auth/verify-email?redirect=${safe}`);
} else {
router.push("/auth/verify-email");
}
return;
}
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
} else if (redirect) {
router.push(redirect);
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}

View file

@ -1,5 +1,6 @@
import SignupForm from "@app/app/auth/signup/SignupForm";
import { verifySession } from "@app/lib/auth/verifySession";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv";
import { Mail } from "lucide-react";
import Link from "next/link";
@ -41,6 +42,11 @@ export default async function Page(props: {
}
}
let redirectUrl: string | undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect);
}
return (
<>
{isInvite && (
@ -59,7 +65,7 @@ export default async function Page(props: {
)}
<SignupForm
redirect={searchParams.redirect as string}
redirect={redirectUrl}
inviteToken={inviteToken}
inviteId={inviteId}
/>
@ -68,9 +74,9 @@ export default async function Page(props: {
Already have an account?{" "}
<Link
href={
!searchParams.redirect
!redirectUrl
? `/auth/login`
: `/auth/login?redirect=${searchParams.redirect}`
: `/auth/login?redirect=${redirectUrl}`
}
className="underline"
>

View file

@ -36,6 +36,7 @@ import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";;
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cleanRedirect } from "@app/lib/cleanRedirect";
const FormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
@ -91,11 +92,9 @@ export default function VerifyEmailForm({
"Email successfully verified! Redirecting you..."
);
setTimeout(() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
}
if (redirect) {
router.push(redirect);
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}

View file

@ -1,5 +1,6 @@
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
import { verifySession } from "@app/lib/auth/verifySession";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation";
import { cache } from "react";
@ -27,11 +28,16 @@ export default async function Page(props: {
redirect("/");
}
let redirectUrl: string | undefined;
if (searchParams.redirect) {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
return (
<>
<VerifyEmailForm
email={user.email}
redirect={searchParams.redirect as string}
redirect={redirectUrl}
/>
</>
);

View file

@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText } from "lucide-react";
import Image from "next/image";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@ -38,10 +40,10 @@ export default async function RootLayout({
<div className="flex-grow">{children}</div>
{/* Footer */}
<footer className="w-full mt-12 py-3 mb-6">
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
<div className="whitespace-nowrap">
Pangolin
<footer className="w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-600 select-none">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<div className="whitespace-nowrap">
@ -60,7 +62,7 @@ export default async function RootLayout({
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
@ -70,10 +72,11 @@ export default async function RootLayout({
href="https://docs.fossorial.io/Pangolin/overview"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
aria-label="Documentation"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Docs</span>
<span>Documentation</span>
<BookOpenText className="w-3 h-3" />
</a>
{version && (
<>

View file

@ -11,6 +11,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "./components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const dynamic = "force-dynamic";
@ -29,7 +30,8 @@ export default async function Page(props: {
if (!user) {
if (params.redirect) {
redirect(`/auth/login?redirect=${params.redirect}`);
const safe = cleanRedirect(params.redirect);
redirect(`/auth/login?redirect=${safe}`);
} else {
redirect(`/auth/login`);
}
@ -40,7 +42,8 @@ export default async function Page(props: {
env.flags.emailVerificationRequired
) {
if (params.redirect) {
redirect(`/auth/verify-email?redirect=${params.redirect}`);
const safe = cleanRedirect(params.redirect);
redirect(`/auth/verify-email?redirect=${safe}`);
} else {
redirect(`/auth/verify-email`);
}

View file

@ -41,7 +41,7 @@ import Image from 'next/image'
type LoginFormProps = {
redirect?: string;
onLogin?: () => void;
onLogin?: () => void | Promise<void>;
};
const formSchema = z.object({

18
src/lib/cleanRedirect.ts Normal file
View file

@ -0,0 +1,18 @@
type PatternConfig = {
name: string;
regex: RegExp;
};
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+$/ }
];
export function cleanRedirect(input: string): string {
if (!input || typeof input !== "string") {
return "/";
}
const isAccepted = patterns.some((pattern) => pattern.regex.test(input));
return isAccepted ? input : "/";
}