From 788f9f1a45535d9004ed17e23930a6c1aed6f589 Mon Sep 17 00:00:00 2001 From: Clifton Molina Date: Sun, 23 Aug 2026 11:57:01 -0400 Subject: [PATCH] fix(auth): honor X-Forwarded-Proto for reset/invite links and email origin req.protocol reflects the socket the app sees, not the client's real scheme. Behind a TLS-terminating reverse proxy that forwards plain HTTP to the app, forgot-password links, invite links, and the SMTP-test email's logo origin all rendered as http://. oidc.js and recipes.js already check x-forwarded-proto/x-forwarded-host before falling back to req.protocol; apply the same pattern in auth.js and app-config.js. Symptom: invite emails containing an http:// link and an http:// embedded image got flagged as spam by mail providers. Co-Authored-By: Claude Sonnet 5 --- server/routes/app-config.js | 3 ++- server/routes/auth.js | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/server/routes/app-config.js b/server/routes/app-config.js index 17ab8be..ed93047 100644 --- a/server/routes/app-config.js +++ b/server/routes/app-config.js @@ -114,7 +114,8 @@ router.post('/test-email', requireAuth, requireAdmin, wrap(async (req, res) => { const to = (typeof body.to === 'string' && body.to.trim()) || req.user?.email || undefined; // Origin lets the email template load the app logo. Recipient name // personalizes the greeting. - const origin = `${req.protocol}://${req.get('host')}`; + const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); + const origin = `${proto}://${req.headers['x-forwarded-host'] || req.get('host')}`; const recipientName = req.user?.full_name || req.user?.nickname || req.user?.username || null; try { const result = await testSmtp({ overrides, to, origin, recipientName }); diff --git a/server/routes/auth.js b/server/routes/auth.js index 24d2ebf..9e55ad4 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -395,7 +395,8 @@ router.post('/forgot-password', rateLimitLogin, wrap(async (req, res) => { const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour db.prepare('INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, user.id, expires); - const baseUrl = `${req.protocol}://${req.get('host')}`; + const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); + const baseUrl = `${proto}://${req.headers['x-forwarded-host'] || req.get('host')}`; try { await sendPasswordReset(user.email, `${baseUrl}/#/reset-password?token=${token}`); } catch (e) { @@ -444,7 +445,8 @@ router.post('/invite', requireAuth, requireAdmin, wrap(async (req, res) => { db.prepare('INSERT INTO invite_tokens (token, email, role, created_by, expires_at) VALUES (?, ?, ?, ?, ?)') .run(token, email ? email.trim().toLowerCase() : null, role, req.user.id, expires); - const baseUrl = `${req.protocol}://${req.get('host')}`; + const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim(); + const baseUrl = `${proto}://${req.headers['x-forwarded-host'] || req.get('host')}`; const inviteUrl = `${baseUrl}/#/accept-invite?token=${token}`; if (email && isEmailConfigured()) {