diff --git a/src/interfaces/ReverseProxy.ts b/src/interfaces/ReverseProxy.ts index 192dd705..3af746b8 100644 --- a/src/interfaces/ReverseProxy.ts +++ b/src/interfaces/ReverseProxy.ts @@ -94,6 +94,10 @@ export interface ReverseProxyAuth { link_auth?: { enabled: boolean; }; + mtls_auth?: { + enabled: boolean; + ca_cert_pem?: string; + }; header_auths?: HeaderAuthConfig[]; } diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 8dc89185..ea49f2ed 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -61,6 +61,7 @@ import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal"; import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal"; import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal"; import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal"; +import AuthMTLSModal from "@/modules/reverse-proxy/auth/AuthMTLSModal"; import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets"; import ReverseProxyLayer4Content from "@/modules/reverse-proxy/ReverseProxyLayer4Content"; import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal"; @@ -242,6 +243,12 @@ export default function ReverseProxyModal({ const [linkAuthEnabled, setLinkAuthEnabled] = useState( reverseProxy?.auth?.link_auth?.enabled ?? false, ); + const [mtlsEnabled, setMTLSEnabled] = useState( + reverseProxy?.auth?.mtls_auth?.enabled ?? false, + ); + const [mtlsCACertPEM, setMTLSCACertPEM] = useState( + reverseProxy?.auth?.mtls_auth?.ca_cert_pem ?? "", + ); const [headerAuthsEnabled, setHeaderAuthsEnabled] = useState( (reverseProxy?.auth?.header_auths ?? []).some((h) => h.enabled), @@ -261,6 +268,7 @@ export default function ReverseProxyModal({ const [ssoModalOpen, setSsoModalOpen] = useState(false); const [pinModalOpen, setPinModalOpen] = useState(false); const [headerModalOpen, setHeaderModalOpen] = useState(false); + const [mtlsModalOpen, setMTLSModalOpen] = useState(false); // Target being added/edited const [targetModalOpen, setTargetModalOpen] = useState(false); @@ -331,11 +339,21 @@ export default function ReverseProxyModal({ ); }; + const canReuseStoredMTLSCert = + reverseProxy?.auth?.mtls_auth?.enabled === true; + const mtlsError = + mtlsEnabled && + !canReuseStoredMTLSCert && + mtlsCACertPEM.trim().length === 0 + ? "CA certificate PEM is required when mTLS is enabled" + : undefined; + const isUnprotected = !passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled && + !mtlsEnabled && !headerAuthsEnabled && !accessRestrictions; @@ -371,6 +389,13 @@ export default function ReverseProxyModal({ link_auth: { enabled: linkAuthEnabled, }, + mtls_auth: { + enabled: mtlsEnabled, + ca_cert_pem: + mtlsEnabled && mtlsCACertPEM.trim().length > 0 + ? mtlsCACertPEM + : undefined, + }, header_auths: headerAuthsEnabled ? headerAuths.map((h) => ({ ...h, enabled: true })) : [], @@ -569,6 +594,17 @@ export default function ReverseProxyModal({ enabled={pinEnabled} onClick={() => setPinModalOpen(true)} /> + + + mTLS + + } + description="Require clients to present a certificate signed by your trusted CA." + enabled={mtlsEnabled} + onClick={() => setMTLSModalOpen(true)} + /> @@ -742,6 +778,7 @@ export default function ReverseProxyModal({ @@ -780,6 +817,7 @@ export default function ReverseProxyModal({ !canContinueToSettings || !permission?.services?.create || !!timeoutError || + !!mtlsError || accessControlHasErrors } onClick={handleSubmit} @@ -801,6 +839,7 @@ export default function ReverseProxyModal({ !canContinueToSettings || !permission?.services?.update || !!timeoutError || + !!mtlsError || accessControlHasErrors } onClick={handleSubmit} @@ -915,6 +954,26 @@ export default function ReverseProxyModal({ }, 200); }} /> + + { + setTimeout(() => { + setMTLSCACertPEM(caCertPEM); + setMTLSEnabled(true); + }, 200); + }} + onRemove={() => { + setTimeout(() => { + setMTLSCACertPEM(""); + setMTLSEnabled(false); + }, 200); + }} + /> ); } diff --git a/src/modules/reverse-proxy/auth/AuthMTLSModal.tsx b/src/modules/reverse-proxy/auth/AuthMTLSModal.tsx new file mode 100644 index 00000000..a386cc1f --- /dev/null +++ b/src/modules/reverse-proxy/auth/AuthMTLSModal.tsx @@ -0,0 +1,241 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { Modal, ModalClose, ModalContent } from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { Textarea } from "@components/Textarea"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import { cn } from "@utils/helpers"; +import { FileUp } from "lucide-react"; +import React, { useMemo, useRef, useState } from "react"; + +const MASKED_VALUE = "••••••••"; + +function validateCertificatePEM(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return "CA certificate PEM is required"; + + const matches = trimmed.match( + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g, + ); + + if (!matches || matches.length === 0) { + return "Enter a valid PEM certificate"; + } + + for (const cert of matches) { + const base64Body = cert + .replace(/-----BEGIN CERTIFICATE-----/g, "") + .replace(/-----END CERTIFICATE-----/g, "") + .replace(/\s+/g, ""); + + if (!base64Body) { + return "Enter a valid PEM certificate"; + } + + let decoded = ""; + try { + decoded = atob(base64Body); + } catch { + return "Certificate PEM contains invalid base64 data"; + } + + if (!decoded || decoded.charCodeAt(0) !== 0x30) { + return "Certificate PEM does not contain a valid X.509 certificate"; + } + } + + const leftover = trimmed + .replace(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g, "") + .replace(/^\s*#.*$/gm, "") + .trim(); + if (leftover.length > 0) { + return "Only PEM certificate data is allowed"; + } + + return undefined; +} + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + currentCACertPEM: string; + isEnabled: boolean; + onSave: (caCertPEM: string) => void; + onRemove: () => void; +}; + +export default function AuthMTLSModal({ + open, + onOpenChange, + currentCACertPEM, + isEnabled, + onSave, + onRemove, +}: Readonly) { + const [caCertPEM, setCACertPEM] = useState(currentCACertPEM); + const [isMasked, setIsMasked] = useState(isEnabled && currentCACertPEM === ""); + const isEditing = isEnabled; + const inputRef = useRef(null); + + const validationError = useMemo(() => { + if (isMasked) return undefined; + if (!caCertPEM.trim()) return undefined; + return validateCertificatePEM(caCertPEM); + }, [caCertPEM, isMasked]); + + const handleSave = () => { + if (isMasked) { + onOpenChange(false); + onSave(""); + return; + } + + const error = validateCertificatePEM(caCertPEM); + if (error) return; + + onOpenChange(false); + onSave(caCertPEM); + }; + + const handleRemove = () => { + onOpenChange(false); + setCACertPEM(""); + setIsMasked(false); + onRemove(); + }; + + const handleFileText = (text: string) => { + setIsMasked(false); + setCACertPEM(text); + }; + + const handleFileUpload = (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + const fileReader = new FileReader(); + fileReader.readAsText(file, "UTF-8"); + fileReader.onload = (e) => { + if (e.target === null) return; + handleFileText(e.target.result as string); + }; + }; + + return ( + + + + + + +
+
+
+ +