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:
@@ -278,6 +278,8 @@ const server = app.listen(PORT, () => {
|
||||
scheduler.start();
|
||||
scheduler.startTripReminders();
|
||||
scheduler.startDemoReset();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
setupWebSocket(server);
|
||||
});
|
||||
|
||||
@@ -240,17 +240,19 @@ router.get('/oidc', (_req: Request, res: Response) => {
|
||||
client_secret_set: !!secret,
|
||||
display_name: get('oidc_display_name'),
|
||||
oidc_only: get('oidc_only') === 'true',
|
||||
discovery_url: get('oidc_discovery_url'),
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/oidc', (req: Request, res: Response) => {
|
||||
const { issuer, client_id, client_secret, display_name, oidc_only } = req.body;
|
||||
const { issuer, client_id, client_secret, display_name, oidc_only, discovery_url } = req.body;
|
||||
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
|
||||
set('oidc_issuer', issuer);
|
||||
set('oidc_client_id', client_id);
|
||||
if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? '');
|
||||
set('oidc_display_name', display_name);
|
||||
set('oidc_only', oidc_only ? 'true' : 'false');
|
||||
set('oidc_discovery_url', discovery_url);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AuthRequest, OptionalAuthRequest, User } from '../types';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto';
|
||||
import { startTripReminders } from '../scheduler';
|
||||
import { createEphemeralToken } from '../services/ephemeralTokens';
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
@@ -951,4 +952,24 @@ router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) =>
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Short-lived single-use token for WebSocket connections (avoids JWT in WS URL)
|
||||
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const token = createEphemeralToken(authReq.user.id, 'ws');
|
||||
if (!token) return res.status(503).json({ error: 'Service unavailable' });
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
// Short-lived single-use token for direct resource URLs (file downloads, Immich assets)
|
||||
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { purpose } = req.body as { purpose?: string };
|
||||
if (purpose !== 'download' && purpose !== 'immich') {
|
||||
return res.status(400).json({ error: 'Invalid purpose' });
|
||||
}
|
||||
const token = createEphemeralToken(authReq.user.id, purpose);
|
||||
if (!token) return res.status(503).json({ error: 'Service unavailable' });
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||
import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
@@ -84,17 +85,25 @@ function getPlaceFiles(tripId: string | number, placeId: number) {
|
||||
router.get('/:id/download', (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
// Accept token from Authorization header or query parameter
|
||||
// Accept token from Authorization header (JWT) or query parameter (ephemeral token)
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = (authHeader && authHeader.split(' ')[1]) || (req.query.token as string);
|
||||
if (!token) return res.status(401).json({ error: 'Authentication required' });
|
||||
const bearerToken = authHeader && authHeader.split(' ')[1];
|
||||
const queryToken = req.query.token as string | undefined;
|
||||
|
||||
if (!bearerToken && !queryToken) return res.status(401).json({ error: 'Authentication required' });
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
userId = decoded.id;
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
if (bearerToken) {
|
||||
try {
|
||||
const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
userId = decoded.id;
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
} else {
|
||||
const uid = consumeEphemeralToken(queryToken!, 'download');
|
||||
if (!uid) return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
userId = uid;
|
||||
}
|
||||
|
||||
const trip = verifyTripOwnership(tripId, userId);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { AuthRequest } from '../types';
|
||||
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -254,11 +255,16 @@ router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Resp
|
||||
|
||||
// ── Proxy Immich Assets ─────────────────────────────────────────────────────
|
||||
|
||||
// Asset proxy routes accept token via query param (for <img> src usage)
|
||||
function authFromQuery(req: Request, res: Response, next: Function) {
|
||||
const token = req.query.token as string;
|
||||
if (token && !req.headers.authorization) {
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
// Asset proxy routes accept ephemeral token via query param (for <img> src usage)
|
||||
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
||||
const queryToken = req.query.token as string | undefined;
|
||||
if (queryToken) {
|
||||
const userId = consumeEphemeralToken(queryToken, 'immich');
|
||||
if (!userId) return res.status(401).send('Invalid or expired token');
|
||||
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
|
||||
if (!user) return res.status(401).send('User not found');
|
||||
(req as AuthRequest).user = user;
|
||||
return next();
|
||||
}
|
||||
return (authenticate as any)(req, res, next);
|
||||
}
|
||||
|
||||
@@ -60,22 +60,24 @@ function getOidcConfig() {
|
||||
const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id');
|
||||
const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret'));
|
||||
const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO';
|
||||
const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null;
|
||||
if (!issuer || !clientId || !clientSecret) return null;
|
||||
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
|
||||
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl };
|
||||
}
|
||||
|
||||
let discoveryCache: OidcDiscoveryDoc | null = null;
|
||||
let discoveryCacheTime = 0;
|
||||
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
async function discover(issuer: string) {
|
||||
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) {
|
||||
async function discover(issuer: string, discoveryUrl?: string | null) {
|
||||
const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`;
|
||||
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) {
|
||||
return discoveryCache;
|
||||
}
|
||||
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||
const doc = await res.json() as OidcDiscoveryDoc;
|
||||
doc._issuer = issuer;
|
||||
doc._issuer = url;
|
||||
discoveryCache = doc;
|
||||
discoveryCacheTime = Date.now();
|
||||
return doc;
|
||||
@@ -120,7 +122,7 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await discover(config.issuer);
|
||||
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value;
|
||||
if (!appUrl) {
|
||||
@@ -172,7 +174,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await discover(config.issuer);
|
||||
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||
|
||||
const tokenRes = await fetch(doc.token_endpoint, {
|
||||
method: 'POST',
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from './config';
|
||||
import { db, canAccessTrip } from './db/database';
|
||||
import { consumeEphemeralToken } from './services/ephemeralTokens';
|
||||
import { User } from './types';
|
||||
import http from 'http';
|
||||
|
||||
@@ -70,27 +69,27 @@ function setupWebSocket(server: http.Server): void {
|
||||
return;
|
||||
}
|
||||
|
||||
let user: User | undefined;
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
user = db.prepare(
|
||||
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(decoded.id) as User | undefined;
|
||||
if (!user) {
|
||||
nws.close(4001, 'User not found');
|
||||
return;
|
||||
}
|
||||
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
|
||||
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
|
||||
if (requireMfa && !mfaOk) {
|
||||
nws.close(4403, 'MFA required');
|
||||
return;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const userId = consumeEphemeralToken(token, 'ws');
|
||||
if (!userId) {
|
||||
nws.close(4001, 'Invalid or expired token');
|
||||
return;
|
||||
}
|
||||
|
||||
let user: User | undefined;
|
||||
user = db.prepare(
|
||||
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
|
||||
).get(userId) as User | undefined;
|
||||
if (!user) {
|
||||
nws.close(4001, 'User not found');
|
||||
return;
|
||||
}
|
||||
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
|
||||
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
|
||||
if (requireMfa && !mfaOk) {
|
||||
nws.close(4403, 'MFA required');
|
||||
return;
|
||||
}
|
||||
|
||||
nws.isAlive = true;
|
||||
const sid = nextSocketId++;
|
||||
socketId.set(nws, sid);
|
||||
|
||||
Reference in New Issue
Block a user