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
+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);
}
}