fix: hardened security, CSP enforcement, SSRF redirect validation, reenabled S/MIME chain verify, IP spoofing prevention, PDF iframe sandbox
This commit is contained in:
@@ -17,15 +17,18 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsMobile } from "@/hooks/use-media-query";
|
import { useIsMobile } from "@/hooks/use-media-query";
|
||||||
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import { FileBrowser } from "@/components/files/file-browser";
|
import { FileBrowser } from "@/components/files/file-browser";
|
||||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("files");
|
const t = useTranslations("files");
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
|
||||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
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">
|
<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">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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' },
|
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
|
||||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
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 = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
+33
-10
@@ -64,20 +64,43 @@ export async function POST(request: NextRequest) {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const MAX_REDIRECTS = 5;
|
||||||
signal: controller.signal,
|
let currentUrl = url;
|
||||||
headers: {
|
let response: Response | undefined;
|
||||||
'Accept': 'text/calendar, application/ics, text/plain, */*',
|
|
||||||
'User-Agent': 'JMAP-Webmail/1.0 Calendar-Fetcher',
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||||
},
|
if (!isValidExternalUrl(currentUrl)) {
|
||||||
redirect: 'follow',
|
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);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response || !response.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Remote server returned ${response.status}` },
|
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||||
title={name}
|
title={name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export function NavigationRail({
|
|||||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||||
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||||
|
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||||
@@ -246,7 +247,7 @@ export function NavigationRail({
|
|||||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
{ 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");
|
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||||
|
|||||||
+14
-1
@@ -116,11 +116,24 @@ export async function clearAdminSessionCookie(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the client IP from the request headers.
|
* 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 {
|
export function getClientIP(request: Request): string {
|
||||||
const forwarded = request.headers.get('x-forwarded-for');
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
if (forwarded) {
|
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';
|
return request.headers.get('x-real-ip') || '0.0.0.0';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface FeatureGates {
|
|||||||
debugModeEnabled: boolean;
|
debugModeEnabled: boolean;
|
||||||
folderIconsEnabled: boolean;
|
folderIconsEnabled: boolean;
|
||||||
hoverActionsConfigEnabled: boolean;
|
hoverActionsConfigEnabled: boolean;
|
||||||
|
filesEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
@@ -54,6 +55,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
debugModeEnabled: true,
|
debugModeEnabled: true,
|
||||||
folderIconsEnabled: true,
|
folderIconsEnabled: true,
|
||||||
hoverActionsConfigEnabled: true,
|
hoverActionsConfigEnabled: true,
|
||||||
|
filesEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||||
*
|
*
|
||||||
* v1 performs cryptographic signature validation and cert validity checks
|
* Performs cryptographic signature validation, cert validity checks,
|
||||||
* but does NOT implement full trust-chain or revocation validation.
|
* and trust-chain verification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as pkijs from 'pkijs';
|
import * as pkijs from 'pkijs';
|
||||||
@@ -61,7 +61,7 @@ export async function smimeVerify(
|
|||||||
const verifyResult = await signedData.verify(
|
const verifyResult = await signedData.verify(
|
||||||
{
|
{
|
||||||
signer: 0,
|
signer: 0,
|
||||||
checkChain: false, // v1: no trust-chain validation
|
checkChain: true,
|
||||||
},
|
},
|
||||||
cryptoEngine,
|
cryptoEngine,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function proxy(request: NextRequest) {
|
|||||||
"Permissions-Policy",
|
"Permissions-Policy",
|
||||||
"camera=(), microphone=(), geolocation=(), payment=()"
|
"camera=(), microphone=(), geolocation=(), payment=()"
|
||||||
);
|
);
|
||||||
response.headers.set("Content-Security-Policy-Report-Only", csp);
|
response.headers.set("Content-Security-Policy", csp);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user