From 8d99c3d2d662a1ab6c17c86861148e1895c52cbb Mon Sep 17 00:00:00 2001 From: Ulas Date: Tue, 14 Apr 2026 17:37:22 +0200 Subject: [PATCH] fix: resolve iOS PWA session/CSRF issues causing forbidden errors - Renew CSRF cookie on /auth/me (first call after iOS PWA resume) - Add try-catch + hex validation to CSRF middleware for corrupted tokens - Auto-retry state-changing requests on 403 by refreshing CSRF token - Add 200ms delay before SW controllerchange reload to prevent blank page on iOS --- public/api.js | 9 ++++++++- public/sw-register.js | 10 +++++++--- server/auth.js | 13 +++++++++++++ server/middleware/csrf.js | 21 ++++++++++++++------- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/public/api.js b/public/api.js index f12f104..e6b9f50 100644 --- a/public/api.js +++ b/public/api.js @@ -22,7 +22,7 @@ function getCsrfToken() { * @param {RequestInit} options - Fetch-Optionen * @returns {Promise} Geparstes JSON oder wirft einen Fehler */ -async function apiFetch(path, options = {}) { +async function apiFetch(path, options = {}, _retried = false) { const url = `${API_BASE}${path}`; const method = options.method ?? 'GET'; @@ -45,6 +45,13 @@ async function apiFetch(path, options = {}) { throw new Error('Sitzung abgelaufen.'); } + // CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me + // ausfuehren um den CSRF-Cookie zu erneuern, dann den Request wiederholen. + if (response.status === 403 && stateChanging && !_retried) { + await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin', cache: 'no-store' }); + return apiFetch(path, options, true); + } + const data = await response.json().catch(() => null); if (!response.ok) { diff --git a/public/sw-register.js b/public/sw-register.js index e0ade6c..555823d 100644 --- a/public/sw-register.js +++ b/public/sw-register.js @@ -12,12 +12,16 @@ if ('serviceWorker' in navigator) { }); }); - // Nahtloses Update: Neuer SW hat skipWaiting() + clients.claim() aufgerufen - // → Controller wechselt → Seite neu laden für konsistenten Stand + // SW-Update: Auf iOS-PWA fuehrt ein sofortiger Reload bei controllerchange + // zu Timing-Problemen (leere Seite, verlorene Cookies). Stattdessen nur + // nachladen wenn die Seite gerade nicht mitten im Initialisieren ist. let refreshing = false; navigator.serviceWorker.addEventListener('controllerchange', () => { if (refreshing) return; refreshing = true; - window.location.reload(); + // Kurz warten damit der neue SW vollstaendig aktiviert ist und + // clients.claim() abgeschlossen hat, bevor die Seite neu laedt. + // Auf iOS-Standalone verhindert das den "leere Seite"-Bug. + setTimeout(() => window.location.reload(), 200); }); } diff --git a/server/auth.js b/server/auth.js index bf13a04..178e1d6 100644 --- a/server/auth.js +++ b/server/auth.js @@ -247,6 +247,19 @@ router.get('/me', requireAuth, (req, res) => { return res.status(401).json({ error: 'Benutzer nicht gefunden.', code: 401 }); } + // CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume: + // iOS kann den CSRF-Cookie verwerfen waehrend die Session-Cookie erhalten bleibt. + // /me ist der erste API-Call nach App-Resume, also hier den Cookie wiederherstellen.) + if (!req.session.csrfToken) { + req.session.csrfToken = generateToken(); + } + res.cookie('csrf-token', req.session.csrfToken, { + httpOnly: false, + sameSite: 'lax', + secure: process.env.SESSION_SECURE !== 'false', + maxAge: 1000 * 60 * 60 * 24 * 7, + }); + res.json({ user }); } catch (err) { log.error('/me Fehler:', err); diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js index 68a003e..f00596c 100644 --- a/server/middleware/csrf.js +++ b/server/middleware/csrf.js @@ -51,13 +51,20 @@ function csrfMiddleware(req, res, next) { const sessionToken = req.session.csrfToken; const expectedLen = TOKEN_LENGTH * 2; // 64 Hex-Zeichen - const tokenValid = - headerToken.length === expectedLen && - sessionToken.length === expectedLen && - crypto.timingSafeEqual( - Buffer.from(headerToken, 'hex'), - Buffer.from(sessionToken, 'hex') - ); + let tokenValid = false; + try { + tokenValid = + headerToken.length === expectedLen && + sessionToken.length === expectedLen && + // Nur valides Hex vergleichen (iOS kann Cookies korrumpieren) + /^[0-9a-f]+$/i.test(headerToken) && + crypto.timingSafeEqual( + Buffer.from(headerToken, 'hex'), + Buffer.from(sessionToken, 'hex') + ); + } catch { + // Buffer-Fehler bei korruptem Token - tokenValid bleibt false + } if (!tokenValid) { return res.status(403).json({ error: 'Ungültiges CSRF-Token.', code: 403 });