fix: harden proxy auth and SSRF defenses

This commit is contained in:
Linus Rath
2026-03-31 17:47:09 +02:00
parent b3d4c9241c
commit aa40c8be26
17 changed files with 462 additions and 175 deletions
+2 -7
View File
@@ -24,6 +24,7 @@ import {
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useAuthStore } from '@/stores/auth-store';
@@ -78,13 +79,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}, [pathname]);
function getJmapHeaders(): Record<string, string> {
const client = useAuthStore.getState().client;
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
return getActiveAccountSlotHeaders();
}
async function checkAuth() {
+13 -3
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
httpOnly: true,
@@ -69,10 +70,19 @@ export async function POST(request: NextRequest) {
}
// If session cookie exists, update it with the new password
const cookieStore = await cookies();
if (creds.hasSessionCookie) {
const newToken = encryptSession(creds.serverUrl, creds.username, newPassword);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, newToken, COOKIE_OPTIONS);
cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS);
}
if (creds.authHeader.startsWith('Basic ')) {
setStalwartAuthContextInStore(cookieStore, creds.slot, {
serverUrl: creds.serverUrl,
username: creds.username,
authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`,
});
}
return NextResponse.json({ ok: true });
+33 -1
View File
@@ -4,6 +4,11 @@ import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import {
clearStalwartAuthContextInStore,
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -31,12 +36,23 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const token = encryptSession(serverUrl, username, password);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
@@ -56,9 +72,16 @@ export async function GET(request: NextRequest) {
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
@@ -99,9 +122,16 @@ export async function PUT(request: NextRequest) {
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
});
return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
@@ -120,10 +150,12 @@ export async function DELETE(request: NextRequest) {
// Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i));
clearStalwartAuthContextInStore(cookieStore, i);
}
} else {
const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
}
return NextResponse.json({ ok: true });
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
return bodySlot;
}
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? 0 : slot;
}
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, authHeader, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !authHeader) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
logger.error('Failed to store Stalwart auth context', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+54 -12
View File
@@ -1,9 +1,39 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
const hostname = url.hostname.toLowerCase();
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
hostname.endsWith('.localdomain')
) {
return false;
}
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
return true;
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
if (!(await isValidExternalUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -69,7 +111,7 @@ export async function POST(request: NextRequest) {
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!isValidExternalUrl(currentUrl)) {
if (!(await isValidExternalUrl(currentUrl))) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}
+32 -8
View File
@@ -4,6 +4,32 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials';
const ALLOWED_METHODS = new Set(['PROPFIND', 'MKCOL', 'GET', 'PUT', 'DELETE', 'MOVE', 'COPY']);
function normalizeDavRelativePath(rawPath: string): string {
const sanitized = rawPath.replace(/\\/g, '/').split(/[?#]/, 1)[0] ?? '';
const segments = sanitized.split('/').filter(Boolean);
return segments.map((segment) => {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
throw new Error('Invalid WebDAV path encoding');
}
if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0')) {
throw new Error('Invalid WebDAV path segment');
}
return encodeURIComponent(decoded);
}).join('/');
}
function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string): string {
const rootUrl = new URL(`${baseUrl.replace(/\/$/, '')}/dav/file/${encodeURIComponent(username)}/`);
const relativePath = normalizeDavRelativePath(rawPath);
return relativePath ? new URL(relativePath, rootUrl).toString() : rootUrl.toString();
}
/**
* POST /api/webdav
* Proxies WebDAV requests to the Stalwart server.
@@ -29,11 +55,8 @@ export async function POST(request: NextRequest) {
}
const davPath = request.headers.get('X-WebDAV-Path') || '/';
const cleanPath = davPath.replace(/^\/+/, '');
const baseUrl = creds.apiUrl.replace(/\/$/, '');
const targetUrl = cleanPath
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanPath}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath);
// Build headers for the upstream request
const upstreamHeaders: Record<string, string> = {
@@ -50,10 +73,7 @@ export async function POST(request: NextRequest) {
// For MOVE/COPY, construct the full Destination URL from the relative path
const destination = request.headers.get('X-WebDAV-Destination');
if (destination) {
const cleanDest = destination.replace(/^\/+/, '');
upstreamHeaders['Destination'] = cleanDest
? `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/${cleanDest}`
: `${baseUrl}/dav/file/${encodeURIComponent(creds.username)}/`;
upstreamHeaders['Destination'] = buildDavTargetUrl(baseUrl, creds.username, destination);
}
const overwrite = request.headers.get('Overwrite');
@@ -104,6 +124,10 @@ export async function POST(request: NextRequest) {
status: response.status,
});
} catch (error) {
if (error instanceof Error && error.message.startsWith('Invalid WebDAV path')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
logger.error('WebDAV proxy error', { error: error instanceof Error ? error.message : 'Unknown' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
+3 -7
View File
@@ -17,6 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -219,13 +220,8 @@ export function NavigationRail({
useEffect(() => {
let cancelled = false;
const { client } = useAuthStore.getState();
if (!client) return;
const headers: Record<string, string> = {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
const headers = getActiveAccountSlotHeaders();
if (!headers['X-JMAP-Cookie-Slot']) return;
fetch('/api/admin/stalwart-check', { headers })
.then(res => res.json())
.then(data => {
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
@@ -203,7 +204,10 @@ export function CalendarManagementSettings() {
fetch('/api/caldav/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...getActiveAccountSlotHeaders(),
},
body: JSON.stringify({
accounts: Array.from(accounts.entries()).map(([key, candidates]) => ({ key, candidates })),
}),
+18
View File
@@ -0,0 +1,18 @@
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
export function getActiveAccountSlot(): number | null {
const authState = useAuthStore.getState();
const accountState = useAccountStore.getState();
const activeAccountId = authState.activeAccountId ?? accountState.activeAccountId;
const activeAccount = activeAccountId
? accountState.getAccountById(activeAccountId)
: accountState.getActiveAccount();
return typeof activeAccount?.cookieSlot === 'number' ? activeAccount.cookieSlot : null;
}
export function getActiveAccountSlotHeaders(): Record<string, string> {
const slot = getActiveAccountSlot();
return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) };
}
+80
View File
@@ -0,0 +1,80 @@
const VERIFY_TIMEOUT_MS = 10000;
export class JmapAuthVerificationError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'JmapAuthVerificationError';
this.status = status;
}
}
function isSupportedProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:';
}
export function normalizeJmapServerUrl(serverUrl: string): string {
let url: URL;
try {
url = new URL(serverUrl);
} catch {
throw new JmapAuthVerificationError('Invalid server URL', 400);
}
if (!isSupportedProtocol(url.protocol)) {
throw new JmapAuthVerificationError('Unsupported server URL protocol', 400);
}
url.hash = '';
url.search = '';
return url.toString().replace(/\/+$/, '');
}
export function validateProxyAuthHeader(authHeader: string): void {
if (!/^(?:Basic|Bearer)\s+\S+$/i.test(authHeader)) {
throw new JmapAuthVerificationError('Invalid Authorization header', 400);
}
}
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
try {
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
method: 'GET',
headers: { Authorization: authHeader },
signal: controller.signal,
});
if (!response.ok) {
throw new JmapAuthVerificationError(
response.status === 401 || response.status === 403
? 'Authentication failed'
: 'Failed to verify JMAP session',
response.status === 401 || response.status === 403 ? 401 : 502,
);
}
const session = await response.json().catch(() => null) as { apiUrl?: unknown; accounts?: unknown } | null;
if (!session || typeof session.apiUrl !== 'string' || typeof session.accounts !== 'object' || session.accounts === null) {
throw new JmapAuthVerificationError('Invalid JMAP session response', 502);
}
return normalizedServerUrl;
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
throw new JmapAuthVerificationError('JMAP session verification timed out', 504);
}
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
} finally {
clearTimeout(timeout);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { cookies } from 'next/headers';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
const STALWART_AUTH_CONTEXT_COOKIE = 'jmap_stalwart_ctx';
export interface StalwartAuthContext {
serverUrl: string;
username: string;
authHeader: string;
}
type CookieStore = Awaited<ReturnType<typeof cookies>>;
export function stalwartAuthContextCookieName(slot: number): string {
return slot === 0 ? STALWART_AUTH_CONTEXT_COOKIE : `${STALWART_AUTH_CONTEXT_COOKIE}_${slot}`;
}
function isValidContext(payload: unknown): payload is StalwartAuthContext {
if (!payload || typeof payload !== 'object') {
return false;
}
const candidate = payload as Record<string, unknown>;
return typeof candidate.serverUrl === 'string'
&& typeof candidate.username === 'string'
&& typeof candidate.authHeader === 'string';
}
function getSessionCookieOptions() {
const { maxAge: _maxAge, ...cookieOptions } = getCookieOptions();
return cookieOptions;
}
export function readStalwartAuthContextFromStore(
cookieStore: CookieStore,
slot: number,
): StalwartAuthContext | null {
const token = cookieStore.get(stalwartAuthContextCookieName(slot))?.value;
if (!token) return null;
const payload = decryptPayload(token);
return isValidContext(payload) ? payload : null;
}
export async function readStalwartAuthContext(slot: number): Promise<StalwartAuthContext | null> {
const cookieStore = await cookies();
return readStalwartAuthContextFromStore(cookieStore, slot);
}
export function setStalwartAuthContextInStore(
cookieStore: CookieStore,
slot: number,
context: StalwartAuthContext,
): void {
cookieStore.set(
stalwartAuthContextCookieName(slot),
encryptPayload(context as unknown as Record<string, unknown>),
getSessionCookieOptions(),
);
}
export async function setStalwartAuthContext(slot: number, context: StalwartAuthContext): Promise<void> {
const cookieStore = await cookies();
setStalwartAuthContextInStore(cookieStore, slot, context);
}
export function clearStalwartAuthContextInStore(cookieStore: CookieStore, slot: number): void {
cookieStore.delete(stalwartAuthContextCookieName(slot));
}
export async function clearStalwartAuthContext(slot: number): Promise<void> {
const cookieStore = await cookies();
clearStalwartAuthContextInStore(cookieStore, slot);
}
+30 -30
View File
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE } from '@/lib/auth/session-cookie';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
export interface StalwartCredentials {
/** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */
@@ -11,6 +11,7 @@ export interface StalwartCredentials {
authHeader: string;
username: string;
hasSessionCookie: boolean;
slot: number;
}
/**
@@ -30,39 +31,38 @@ function getStalwartApiUrl(jmapServerUrl: string): string {
/**
* Extract credentials from the incoming request.
*
* Tries the explicit headers first (`Authorization`, `X-JMAP-Server-URL`,
* `X-JMAP-Username`), then falls back to the encrypted session cookie.
* Credentials are read from a verified, httpOnly auth-context cookie that is
* populated after a successful JMAP login or token refresh.
*/
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
}
function getCandidateSlots(request: NextRequest): number[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
?? parseSlot(request.nextUrl.searchParams.get('slot'));
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
}
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {
const authHeader = request.headers.get('Authorization');
const serverUrl = request.headers.get('X-JMAP-Server-URL');
const username = request.headers.get('X-JMAP-Username');
const cookieStore = await cookies();
for (const slot of getCandidateSlots(request)) {
const context = readStalwartAuthContextFromStore(cookieStore, slot);
if (!context) continue;
if (authHeader && serverUrl && username) {
const cookieStore = await cookies();
const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value;
return {
apiUrl: getStalwartApiUrl(serverUrl),
serverUrl,
authHeader,
username,
hasSessionCookie,
apiUrl: getStalwartApiUrl(context.serverUrl),
serverUrl: context.serverUrl,
authHeader: context.authHeader,
username: context.username,
hasSessionCookie: !!cookieStore.get(sessionCookieName(slot))?.value,
slot,
};
}
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const credentials = decryptSession(token);
if (!credentials) return null;
const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
return {
apiUrl: getStalwartApiUrl(credentials.serverUrl),
serverUrl: credentials.serverUrl,
authHeader: basic,
username: credentials.username,
hasSessionCookie: true,
};
return null;
}
+7
View File
@@ -3,6 +3,8 @@
* The server-side proxy handles auth and forwards requests to Stalwart's /dav/file/ endpoint.
*/
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
export interface WebDAVResource {
href: string;
name: string;
@@ -26,6 +28,7 @@ export class WebDAVClient {
const headers: Record<string, string> = {
'X-WebDAV-Method': method,
'X-WebDAV-Path': path,
...getActiveAccountSlotHeaders(),
...options?.headers,
};
@@ -118,6 +121,10 @@ export class WebDAVClient {
xhr.open('POST', this.proxyUrl);
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path);
const slotHeaders = getActiveAccountSlotHeaders();
if (slotHeaders['X-JMAP-Cookie-Slot']) {
xhr.setRequestHeader('X-JMAP-Cookie-Slot', slotHeaders['X-JMAP-Cookie-Slot']);
}
xhr.setRequestHeader('Content-Type',
contentType || (file instanceof File ? file.type : 'application/octet-stream'));
+12 -98
View File
@@ -603,18 +603,6 @@
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -625,17 +613,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -1946,19 +1923,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -2403,18 +2367,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
@@ -3995,17 +3947,6 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -8205,6 +8146,18 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkijs": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
@@ -9433,19 +9386,6 @@
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
@@ -9863,19 +9803,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
@@ -9954,19 +9881,6 @@
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+1
View File
@@ -92,6 +92,7 @@
"webcrypto-liner": "$elliptic"
},
"flatted": "^3.4.2",
"picomatch": "^4.0.4",
"undici": "^7.24.0"
}
}
+2 -8
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { debug } from '@/lib/debug';
import { useAuthStore } from './auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
interface AccountSecurityState {
// Detection
@@ -44,13 +44,7 @@ interface AccountSecurityState {
}
function getApiHeaders(): Record<string, string> {
const { client } = useAuthStore.getState();
if (!client) return {};
return {
'Authorization': client.getAuthHeader(),
'X-JMAP-Server-URL': client.getServerUrl(),
'X-JMAP-Username': client.getUsername(),
};
return getActiveAccountSlotHeaders();
}
export const useAccountSecurityStore = create<AccountSecurityState>()((set, get) => ({
+49
View File
@@ -87,6 +87,27 @@ function getClientRateLimitState(client: IJMAPClient | null): Pick<AuthState, 'i
};
}
async function syncStalwartAuthContext(
serverUrl: string,
username: string,
authHeader: string,
slot: number,
): Promise<void> {
try {
const response = await fetch('/api/auth/stalwart-context', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, authHeader, slot }),
});
if (!response.ok) {
debug.warn('auth', `Failed to sync Stalwart auth context: ${response.status}`);
}
} catch (error) {
debug.warn('auth', 'Failed to sync Stalwart auth context:', error);
}
}
function bindClientStatusHandlers(
client: IJMAPClient,
set: (state: Partial<AuthState>) => void,
@@ -461,6 +482,8 @@ export const useAuthStore = create<AuthState>()(
}
}
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot);
set({
isAuthenticated: true,
isLoading: false,
@@ -636,6 +659,8 @@ export const useAuthStore = create<AuthState>()(
});
accountStore.setActiveAccount(accountId);
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot);
set({
isAuthenticated: true,
isLoading: false,
@@ -754,6 +779,9 @@ export const useAuthStore = create<AuthState>()(
});
accountStore.setActiveAccount(accountId);
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), cookieSlot);
set({
isAuthenticated: true,
isLoading: false,
@@ -825,6 +853,15 @@ export const useAuthStore = create<AuthState>()(
get().client?.updateAccessToken(access_token);
if (account) {
await syncStalwartAuthContext(
account.serverUrl,
account.username,
`Bearer ${access_token}`,
slot,
);
}
set({
accessToken: access_token,
tokenExpiresAt: Date.now() + expires_in * 1000,
@@ -1012,6 +1049,12 @@ export const useAuthStore = create<AuthState>()(
await targetClient.connect();
clients.set(accountId, targetClient);
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
await syncStalwartAuthContext(
targetAccount.serverUrl,
targetAccount.username,
targetClient.getAuthHeader(),
targetAccount.cookieSlot,
);
}
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
@@ -1021,6 +1064,7 @@ export const useAuthStore = create<AuthState>()(
bindClientStatusHandlers(targetClient, set, get, accountId);
await targetClient.connect();
clients.set(accountId, targetClient);
await syncStalwartAuthContext(serverUrl, username, targetClient.getAuthHeader(), targetAccount.cookieSlot);
}
}
} catch (err) {
@@ -1174,6 +1218,7 @@ export const useAuthStore = create<AuthState>()(
await client.connect();
clients.set(account.id, client);
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
await syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot);
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
} else {
throw new Error(`Token refresh failed: ${res.status}`);
@@ -1186,6 +1231,7 @@ export const useAuthStore = create<AuthState>()(
bindClientStatusHandlers(client, set, get, account.id);
await client.connect();
clients.set(account.id, client);
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), account.cookieSlot);
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
} else {
throw new Error(`Session cookie missing: ${res.status}`);
@@ -1401,6 +1447,9 @@ export const useAuthStore = create<AuthState>()(
});
accountStore.setActiveAccount(accountId);
const cookieSlot = accountStore.getAccountById(accountId)?.cookieSlot ?? 0;
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot);
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);