From 8edcb7d3dfb338df9409f963204e770bc943a1bc Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 4 Mar 2026 13:41:40 +0100 Subject: [PATCH] feat(calendar): store only token hash in database to enhance security feat(federation): escape LIKE special characters in originDomain to prevent wildcard injection feat(oauth): redirect with token in hash fragment to avoid exposure in logs feat(OAuthCallback): retrieve token from hash fragment for improved security --- server/routes/calendar.js | 5 +++-- server/routes/federation.js | 25 +++++++++++++++---------- server/routes/oauth.js | 5 +++-- src/pages/OAuthCallback.jsx | 8 ++++++-- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 89c54f9..6c0cf45 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -751,8 +751,9 @@ router.post('/caldav-tokens', authenticateToken, async (req, res) => { const token = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const result = await db.run( - 'INSERT INTO caldav_tokens (user_id, token, token_hash, name) VALUES (?, ?, ?, ?)', - [req.user.id, token, tokenHash, name.trim()], + // Store only the hash — never the plaintext — to limit exposure on DB breach. + 'INSERT INTO caldav_tokens (user_id, token, token_hash, name) VALUES (?, NULL, ?, ?)', + [req.user.id, tokenHash, name.trim()], ); res.status(201).json({ token: { id: result.lastInsertRowid, name: name.trim() }, diff --git a/server/routes/federation.js b/server/routes/federation.js index bff4a4a..1ae1b7f 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -577,6 +577,9 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res const db = getDb(); + // Escape LIKE special characters in originDomain to prevent wildcard injection. + const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&'); + // Collect all affected users before deleting (for email notifications) let affectedUsers = []; try { @@ -585,16 +588,16 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res `SELECT u.email, u.name, u.language, ci.title, ci.from_user FROM calendar_invitations ci JOIN users u ON ci.to_user_id = u.id - WHERE ci.event_uid = ? AND ci.from_user LIKE ?`, - [event_uid, `%@${originDomain}`] + WHERE ci.event_uid = ? AND ci.from_user LIKE ? ESCAPE '\\'`, + [event_uid, `%@${safeDomain}`] ); // Users who already accepted (event in their calendar) const calUsers = await db.all( `SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user FROM calendar_events ce JOIN users u ON ce.user_id = u.id - WHERE ce.uid = ? AND ce.federated_from LIKE ?`, - [event_uid, `%@${originDomain}`] + WHERE ce.uid = ? AND ce.federated_from LIKE ? ESCAPE '\\'`, + [event_uid, `%@${safeDomain}`] ); // Merge, deduplicate by email const seen = new Set(); @@ -609,15 +612,15 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res // Remove from calendar_invitations for all users on this instance await db.run( `DELETE FROM calendar_invitations - WHERE event_uid = ? AND from_user LIKE ?`, - [event_uid, `%@${originDomain}`] + WHERE event_uid = ? AND from_user LIKE ? ESCAPE '\\'`, + [event_uid, `%@${safeDomain}`] ); // Remove from calendar_events (accepted invitations) for all users on this instance await db.run( `DELETE FROM calendar_events - WHERE uid = ? AND federated_from LIKE ?`, - [event_uid, `%@${originDomain}`] + WHERE uid = ? AND federated_from LIKE ? ESCAPE '\\'`, + [event_uid, `%@${safeDomain}`] ); log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`); @@ -667,11 +670,13 @@ router.post('/room-deleted', federationReceiveLimiter, async (req, res) => { } const db = getDb(); + // Escape LIKE special characters in originDomain to prevent wildcard injection. + const safeDomain = originDomain.replace(/[\\%_]/g, '\\$&'); // Mark all federated_rooms with this meet_id (room_uid) from this origin as deleted await db.run( `UPDATE federated_rooms SET deleted = 1, updated_at = CURRENT_TIMESTAMP - WHERE meet_id = ? AND from_user LIKE ?`, - [room_uid, `%@${originDomain}`] + WHERE meet_id = ? AND from_user LIKE ? ESCAPE '\\'`, + [room_uid, `%@${safeDomain}`] ); log.federation.info(`Room ${room_uid} marked as deleted (origin: ${originDomain})`); diff --git a/server/routes/oauth.js b/server/routes/oauth.js index 42f80f1..1248c5f 100644 --- a/server/routes/oauth.js +++ b/server/routes/oauth.js @@ -248,9 +248,10 @@ router.get('/callback', callbackLimiter, async (req, res) => { // Generate JWT const token = generateToken(user.id); - // Redirect to frontend callback page with token + // Redirect to frontend callback page with token. + // Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers). const returnTo = stateData.return_to || '/dashboard'; - res.redirect(`${baseUrl}/oauth/callback?token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`); + res.redirect(`${baseUrl}/oauth/callback#token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`); } catch (err) { log.auth.error(`OAuth callback error: ${err.message}`); const baseUrl = getBaseUrl(req); diff --git a/src/pages/OAuthCallback.jsx b/src/pages/OAuthCallback.jsx index c7c344b..d1edba6 100644 --- a/src/pages/OAuthCallback.jsx +++ b/src/pages/OAuthCallback.jsx @@ -13,9 +13,13 @@ export default function OAuthCallback() { const navigate = useNavigate(); useEffect(() => { - const token = searchParams.get('token'); + // Token is passed via hash fragment (never sent to server, not logged, not in Referer). + // Error is still a regular query param since it contains no sensitive data. + const hash = window.location.hash.slice(1); // strip leading '#' + const hashParams = new URLSearchParams(hash); + const token = hashParams.get('token'); const errorMsg = searchParams.get('error'); - const returnTo = searchParams.get('return_to') || '/dashboard'; + const returnTo = hashParams.get('return_to') || searchParams.get('return_to') || '/dashboard'; if (errorMsg) { setError(errorMsg);