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] 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 ? (
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 && (
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index e72f166a..301ff03c 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -169,6 +169,7 @@ export function NavigationRail({
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
+ const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
@@ -246,7 +247,7 @@ export function NavigationRail({
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
- { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
+ { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled },
];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
diff --git a/lib/admin/session.ts b/lib/admin/session.ts
index 629c6f79..0a69865d 100644
--- a/lib/admin/session.ts
+++ b/lib/admin/session.ts
@@ -116,11 +116,24 @@ export async function clearAdminSessionCookie(): Promise {
/**
* Get the client IP from the request headers.
+ *
+ * Proxies typically *append* to X-Forwarded-For, so the last entry
+ * before our trusted proxy is the most reliable client IP. When a
+ * single reverse proxy sits in front of the app the rightmost entry
+ * is the one added by that proxy. We take the rightmost entry to
+ * avoid trusting attacker-controlled values prepended to the header.
+ *
+ * If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
+ * to the number of trusted proxies (default 1).
*/
export function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
- return forwarded.split(',')[0].trim();
+ const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
+ const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
+ // Take the entry at position (length - depth), clamped to 0
+ const index = Math.max(0, parts.length - depth);
+ return parts[index] || '0.0.0.0';
}
return request.headers.get('x-real-ip') || '0.0.0.0';
}
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 78797a3e..5454fcdf 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -37,6 +37,7 @@ export interface FeatureGates {
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
+ filesEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -54,6 +55,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
debugModeEnabled: true,
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
+ filesEnabled: true,
};
export interface ThemePolicy {
diff --git a/lib/smime/smime-verify.ts b/lib/smime/smime-verify.ts
index f9849dc9..8b6adc53 100644
--- a/lib/smime/smime-verify.ts
+++ b/lib/smime/smime-verify.ts
@@ -1,8 +1,8 @@
/**
* Verify CMS SignedData (opaque signed) and extract the inner content.
*
- * v1 performs cryptographic signature validation and cert validity checks
- * but does NOT implement full trust-chain or revocation validation.
+ * Performs cryptographic signature validation, cert validity checks,
+ * and trust-chain verification.
*/
import * as pkijs from 'pkijs';
@@ -61,7 +61,7 @@ export async function smimeVerify(
const verifyResult = await signedData.verify(
{
signer: 0,
- checkChain: false, // v1: no trust-chain validation
+ checkChain: true,
},
cryptoEngine,
);
diff --git a/proxy.ts b/proxy.ts
index 01a00312..051817f4 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -65,7 +65,7 @@ export function proxy(request: NextRequest) {
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()"
);
- response.headers.set("Content-Security-Policy-Report-Only", csp);
+ response.headers.set("Content-Security-Policy", csp);
return response;
}