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 { 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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+33
-10
@@ -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, {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user