fix: replace JWT tokens in URL query params with short-lived ephemeral tokens
Addresses CWE-598: long-lived JWTs were exposed in WebSocket URLs, file download links, and Immich asset proxy URLs, leaking into server logs, browser history, and Referer headers. - Add ephemeralTokens service: in-memory single-use tokens with per-purpose TTLs (ws=30s, download/immich=60s), max 10k entries, periodic cleanup - Add POST /api/auth/ws-token and POST /api/auth/resource-token endpoints - WebSocket auth now consumes an ephemeral token instead of verifying the JWT directly from the URL; client fetches a fresh token before each connect - File download ?token= query param now accepts ephemeral tokens; Bearer header path continues to accept JWTs for programmatic access - Immich asset proxy replaces authFromQuery JWT injection with ephemeral token consumption - Client: new getAuthUrl() utility, AuthedImg/ImmichImg components, and async onClick handlers replace the synchronous authUrl() pattern throughout FileManager, PlaceInspector, and MemoriesPanel - Add OIDC_DISCOVERY_URL env var and oidc_discovery_url DB setting to allow overriding the auto-constructed discovery endpoint (required for Authentik and similar providers); exposed in the admin UI and .env.example
This commit is contained in:
54
server/src/services/ephemeralTokens.ts
Normal file
54
server/src/services/ephemeralTokens.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const TTL: Record<string, number> = {
|
||||
ws: 30_000,
|
||||
download: 60_000,
|
||||
immich: 60_000,
|
||||
};
|
||||
|
||||
const MAX_STORE_SIZE = 10_000;
|
||||
|
||||
interface TokenEntry {
|
||||
userId: number;
|
||||
purpose: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, TokenEntry>();
|
||||
|
||||
export function createEphemeralToken(userId: number, purpose: string): string | null {
|
||||
if (store.size >= MAX_STORE_SIZE) return null;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const ttl = TTL[purpose] ?? 60_000;
|
||||
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl });
|
||||
return token;
|
||||
}
|
||||
|
||||
export function consumeEphemeralToken(token: string, purpose: string): number | null {
|
||||
const entry = store.get(token);
|
||||
if (!entry) return null;
|
||||
store.delete(token);
|
||||
if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null;
|
||||
return entry.userId;
|
||||
}
|
||||
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startTokenCleanup(): void {
|
||||
if (cleanupInterval) return;
|
||||
cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of store) {
|
||||
if (now > entry.expiresAt) store.delete(token);
|
||||
}
|
||||
}, 60_000);
|
||||
// Allow process to exit even if interval is active
|
||||
if (cleanupInterval.unref) cleanupInterval.unref();
|
||||
}
|
||||
|
||||
export function stopTokenCleanup(): void {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user