Browse Source

Merge branch 'dev' of https://github.com/fosrl/pangolin into dev

Owen Schwartz 6 months ago
parent
commit
c8c756df28

+ 14 - 0
SECURITY.md

@@ -0,0 +1,14 @@
+# Security Policy
+
+If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
+
+1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
+2. Send a detailed report to [security@fossorial.io](mailto:security@fossorial.io) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
+
+-   Description and location of the vulnerability.
+-   Potential impact of the vulnerability.
+-   Steps to reproduce the vulnerability.
+-   Potential solutions to fix the vulnerability.
+-   Your name/handle and a link for recognition (optional).
+
+We aim to address the issue as soon as possible.

+ 5 - 1
install/Makefile

@@ -2,7 +2,11 @@
 all: build
 
 build: 
-	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer
+	CGO_ENABLED=0 GOOS=linux go build -o installer
+
+all_arches:
+	CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o installer_linux_arm64
+	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer_linux_amd64
 
 clean:
 	rm installer

+ 4 - 0
package.json

@@ -1,6 +1,10 @@
 {
     "name": "@fosrl/pangolin",
+<<<<<<< HEAD
     "version": "1.0.0-beta.2",
+=======
+    "version": "1.0.0-beta.3",
+>>>>>>> c3d19454f7f5c5546a7efb47c23fc040dbbf94d2
     "private": true,
     "type": "module",
     "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",

+ 14 - 0
server/lib/config.ts

@@ -3,7 +3,15 @@ import yaml from "js-yaml";
 import path from "path";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
+<<<<<<< HEAD
 import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
+=======
+import {
+    __DIRNAME,
+    configFilePath1,
+    configFilePath2
+} from "@server/lib/consts";
+>>>>>>> c3d19454f7f5c5546a7efb47c23fc040dbbf94d2
 import { loadAppVersion } from "@server/lib/loadAppVersion";
 import { passwordSchema } from "@server/auth/passwordSchema";
 
@@ -11,9 +19,15 @@ const portSchema = z.number().positive().gt(0).lte(65535);
 const hostnameSchema = z
     .string()
     .regex(
+<<<<<<< HEAD
         /^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.[a-zA-Z]{2,})*$/,
         "Invalid hostname. Must be a valid hostname like 'localhost' or 'test.example.com'."
     );
+=======
+        /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
+    )
+    .or(z.literal("localhost"));
+>>>>>>> c3d19454f7f5c5546a7efb47c23fc040dbbf94d2
 
 const environmentSchema = z.object({
     app: z.object({

+ 1 - 1
src/app/[orgId]/layout.tsx

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

+ 1 - 1
src/app/[orgId]/settings/general/layout.tsx

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

+ 1 - 1
src/app/[orgId]/settings/layout.tsx

@@ -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();

+ 4 - 4
src/app/auth/login/DashboardLoginForm.tsx

@@ -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("/");
                         }

+ 9 - 3
src/app/auth/login/page.tsx

@@ -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"
                     >

+ 3 - 4
src/app/auth/reset-password/ResetPasswordForm.tsx

@@ -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");
                 }

+ 7 - 1
src/app/auth/reset-password/page.tsx

@@ -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"
                 >

+ 1 - 5
src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx

@@ -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()
                                             }

+ 11 - 1
src/app/auth/resource/[resourceId]/page.tsx

@@ -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 ||

+ 6 - 5
src/app/auth/signup/SignupForm.tsx

@@ -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("/");
             }

+ 9 - 3
src/app/auth/signup/page.tsx

@@ -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"
                 >

+ 3 - 4
src/app/auth/verify-email/VerifyEmailForm.tsx

@@ -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("/");
                 }

+ 7 - 1
src/app/auth/verify-email/page.tsx

@@ -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}
             />
         </>
     );

+ 10 - 7
src/app/layout.tsx

@@ -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 && (
                                     <>

+ 6 - 2
src/app/page.tsx

@@ -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`);
         }
@@ -80,6 +83,7 @@ export default async function Page(props: {
 
                 <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
                     <OrganizationLanding
+                        disableCreateOrg={env.flags.disableUserCreateOrg && !user.serverAdmin}
                         organizations={orgs.map((org) => ({
                             name: org.name,
                             id: org.orgId

+ 1 - 1
src/components/LoginForm.tsx

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

+ 18 - 0
src/lib/cleanRedirect.ts

@@ -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 : "/";
+}