fix: hardened security, CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe sandbox

This commit is contained in:
Linus Rath
2026-03-31 15:11:38 +02:00
parent 68214c3e91
commit a3d894730b
9 changed files with 69 additions and 17 deletions
+12 -1
View File
@@ -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() {
)}
<div className="flex-1 min-h-0">
{supportsFiles === false ? (
{!filesEnabled ? (
<div className="flex items-center justify-center h-full">
<div className="max-w-lg text-center space-y-3 px-4">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">Files feature is disabled by your administrator</p>
<p className="text-xs text-muted-foreground">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.</p>
</div>
</div>
) : supportsFiles === false ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
</div>
+1
View File
@@ -19,6 +19,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
};
const RESTRICTABLE_SETTINGS = [
+27 -4
View File
@@ -64,20 +64,43 @@ export async function POST(request: NextRequest) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
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: 'follow',
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 }
);
}
+1
View File
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
{!loading && !error && fileType === "pdf" && objectUrl && (
<iframe
src={objectUrl}
sandbox="allow-same-origin"
className="w-full max-w-5xl h-full rounded-lg bg-white"
title={name}
/>
+2 -1
View File
@@ -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");
+14 -1
View File
@@ -116,11 +116,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
/**
* 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';
}
+2
View File
@@ -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 {
+3 -3
View File
@@ -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,
);
+1 -1
View File
@@ -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;
}