From a3d894730ba04b1f3b6492446f401812e536f92a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:11:38 +0200 Subject: [PATCH 01/11] fix: hardened security, CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe sandbox --- app/[locale]/files/page.tsx | 13 +++++++- app/admin/policy/page.tsx | 1 + app/api/fetch-ical/route.ts | 43 +++++++++++++++++++------ components/files/file-preview-modal.tsx | 1 + components/layout/navigation-rail.tsx | 3 +- lib/admin/session.ts | 15 ++++++++- lib/admin/types.ts | 2 ++ lib/smime/smime-verify.ts | 6 ++-- proxy.ts | 2 +- 9 files changed, 69 insertions(+), 17 deletions(-) diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index 330544bd..cb488348 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsMobile } from "@/hooks/use-media-query"; +import { usePolicyStore } from "@/stores/policy-store"; import { FileBrowser } from "@/components/files/file-browser"; import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { loadFilesSettings } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog"; +import { AlertTriangle } from "lucide-react"; export default function FilesPage() { const router = useRouter(); const t = useTranslations("files"); + const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); @@ -393,7 +396,15 @@ export default function FilesPage() { )}
- {supportsFiles === false ? ( + {!filesEnabled ? ( +
+
+ +

Files feature is disabled by your administrator

+

Large file uploads via WebDAV can cause Stalwart/RocksDB instability, including out-of-memory crashes and unrecoverable disk usage. Deleted files may not be immediately purged from blob storage. This feature is not recommended for production environments.

+
+
+ ) : supportsFiles === false ? (

{t("not_available")}

diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index 4fb09b5f..c2d82dec 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial controller.abort(), FETCH_TIMEOUT_MS); - const response = await fetch(url, { - signal: controller.signal, - headers: { - 'Accept': 'text/calendar, application/ics, text/plain, */*', - 'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher', - }, - redirect: 'follow', - }); + const MAX_REDIRECTS = 5; + let currentUrl = url; + let response: Response | undefined; + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + if (!isValidExternalUrl(currentUrl)) { + clearTimeout(timeout); + return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 }); + } + + response = await fetch(currentUrl, { + signal: controller.signal, + headers: { + 'Accept': 'text/calendar, application/ics, text/plain, */*', + 'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher', + }, + redirect: 'manual', + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + clearTimeout(timeout); + return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 }); + } + // Resolve relative redirects + currentUrl = new URL(location, currentUrl).toString(); + continue; + } + break; + } clearTimeout(timeout); - if (!response.ok) { + if (!response || !response.ok) { return NextResponse.json( - { error: `Remote server returned ${response.status}` }, + { error: `Remote server returned ${response?.status ?? 'unknown'}` }, { status: 502 } ); } diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 2ef4d7d3..f18c72c5 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: {!loading && !error && fileType === "pdf" && objectUrl && (