diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 891b1013..9e24470d 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -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 { - 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() { diff --git a/app/api/account/stalwart/password/route.ts b/app/api/account/stalwart/password/route.ts index 795a8d3a..e229e63f 100644 --- a/app/api/account/stalwart/password/route.ts +++ b/app/api/account/stalwart/password/route.ts @@ -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 }); diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 40e13f17..273afaed 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -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 }); diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts new file mode 100644 index 00000000..89894e2c --- /dev/null +++ b/app/api/auth/stalwart-context/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index 1bfb0911..5cb10c34 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -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 { 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 }); } diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts index 9bda6c1a..700c201d 100644 --- a/app/api/webdav/route.ts +++ b/app/api/webdav/route.ts @@ -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 = { @@ -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 }); } diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 301ff03c..37cab596 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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 = { - '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 => { diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index a46f9111..1e1f22a2 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -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 })), }), diff --git a/lib/auth/active-account-slot.ts b/lib/auth/active-account-slot.ts new file mode 100644 index 00000000..64cd1f7b --- /dev/null +++ b/lib/auth/active-account-slot.ts @@ -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 { + const slot = getActiveAccountSlot(); + return slot === null ? {} : { 'X-JMAP-Cookie-Slot': String(slot) }; +} \ No newline at end of file diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts new file mode 100644 index 00000000..fb4588c2 --- /dev/null +++ b/lib/auth/verify-jmap-auth.ts @@ -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 { + 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); + } +} \ No newline at end of file diff --git a/lib/stalwart/auth-context.ts b/lib/stalwart/auth-context.ts new file mode 100644 index 00000000..f2879d34 --- /dev/null +++ b/lib/stalwart/auth-context.ts @@ -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>; + +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; + 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 { + 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), + getSessionCookieOptions(), + ); +} + +export async function setStalwartAuthContext(slot: number, context: StalwartAuthContext): Promise { + 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 { + const cookieStore = await cookies(); + clearStalwartAuthContextInStore(cookieStore, slot); +} \ No newline at end of file diff --git a/lib/stalwart/credentials.ts b/lib/stalwart/credentials.ts index 1580d44c..a4ffc0b4 100644 --- a/lib/stalwart/credentials.ts +++ b/lib/stalwart/credentials.ts @@ -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 { - 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; } diff --git a/lib/webdav/client.ts b/lib/webdav/client.ts index 5905c1c9..2772d37d 100644 --- a/lib/webdav/client.ts +++ b/lib/webdav/client.ts @@ -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 = { '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')); diff --git a/package-lock.json b/package-lock.json index 2d6a4505..af747cc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 730be362..1fdd1309 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "webcrypto-liner": "$elliptic" }, "flatted": "^3.4.2", + "picomatch": "^4.0.4", "undici": "^7.24.0" } } diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts index 409cda41..10be9354 100644 --- a/stores/account-security-store.ts +++ b/stores/account-security-store.ts @@ -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 { - 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()((set, get) => ({ diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 8cafb9ca..aa421232 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -87,6 +87,27 @@ function getClientRateLimitState(client: IJMAPClient | null): Pick { + 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) => void, @@ -461,6 +482,8 @@ export const useAuthStore = create()( } } + await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot); + set({ isAuthenticated: true, isLoading: false, @@ -636,6 +659,8 @@ export const useAuthStore = create()( }); accountStore.setActiveAccount(accountId); + await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), slot); + set({ isAuthenticated: true, isLoading: false, @@ -754,6 +779,9 @@ export const useAuthStore = create()( }); 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()( 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()( 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()( 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()( 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()( 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()( }); 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);