From 83727957abb61f55506e8355cb03e2214e2e3ad4 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Sat, 21 Feb 2026 21:55:01 -0500 Subject: [PATCH] fix(auth): return JSON 401 for API sessions and harden cross-site cookies --- auth/authEmail.js | 8 +++--- config/cookiesOptions.js | 51 +++++++++++++++++++++++++++++++----- config/corsOptions.js | 1 + index.js | 4 +-- middleware/sessionChecker.js | 27 ++++++++++++++----- 5 files changed, 72 insertions(+), 19 deletions(-) diff --git a/auth/authEmail.js b/auth/authEmail.js index b08ffd4..99415ca 100644 --- a/auth/authEmail.js +++ b/auth/authEmail.js @@ -3,7 +3,7 @@ const { client_logger } = require('../utils/analyticsLogger'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils.js'); -const { cookiesOptions } = require('../config/cookiesOptions'); +const { getCookiesOptions } = require('../config/cookiesOptions'); const Notifications = require("../notifications"); // Object Definitions @@ -19,6 +19,7 @@ const createPasswordTokenHash = (rawToken) => const createSessionFromUser = async ({ DB, user, req, res }) => { const sessionObj = await DB.newSession(user._id); + const cookiesOptions = getCookiesOptions(req); res.cookie('user_sid', user._id, cookiesOptions); res.cookie('session_id', sessionObj.insertedId, cookiesOptions); const latestUpdatedProfile = await DB.latestProfile(user._id); @@ -143,8 +144,9 @@ const logout = async function (req, res) { const session_id = getSessionId(req); const user_sid = getUserId(req); if (session_id && user_sid) { - res.clearCookie('session_id'); - res.clearCookie('user_sid'); + const cookiesOptions = getCookiesOptions(req); + res.clearCookie('session_id', cookiesOptions); + res.clearCookie('user_sid', cookiesOptions); //remove from DB const DB = await MongoDB.getDB; DB.removeSession(session_id); diff --git a/config/cookiesOptions.js b/config/cookiesOptions.js index 958787d..9e5eb68 100644 --- a/config/cookiesOptions.js +++ b/config/cookiesOptions.js @@ -1,12 +1,49 @@ const isProduction = process.env.NODE_ENV === "production"; const forceSecureCookie = process.env.COOKIE_SECURE === "true"; -const secure = forceSecureCookie || isProduction; -const cookiesOptions = { - maxAge: 1000 * 60 * 60 * 24 * 90, // would expire after 90 days - httpOnly: true, // The cookie only accessible by the web server - sameSite: secure ? 'none' : 'lax', - secure, +const COOKIE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 90; // 90 days +const LOCAL_ORIGIN_REGEX = /^http:\/\/(localhost|127\.0\.0\.1|aeropi\.local)(:\d+)?$/i; +const LOCAL_HOST_REGEX = /^(localhost|127\.0\.0\.1|aeropi\.local)(:\d+)?$/i; + +const getHeaderValue = (req, key) => { + if (!req || !req.headers) return ""; + const raw = req.headers[key]; + if (Array.isArray(raw)) return raw[0] || ""; + return raw || ""; }; -module.exports = { cookiesOptions }; +const isLocalRequest = (req) => { + const origin = getHeaderValue(req, "origin"); + const host = getHeaderValue(req, "host"); + return LOCAL_ORIGIN_REGEX.test(origin) || LOCAL_HOST_REGEX.test(host); +}; + +const isHttpsRequest = (req) => { + if (!req) return false; + const forwardedProto = String(getHeaderValue(req, "x-forwarded-proto")).split(",")[0].trim().toLowerCase(); + const reqProtocol = String(req.protocol || "").toLowerCase(); + const origin = String(getHeaderValue(req, "origin") || "").toLowerCase(); + if (forwardedProto === "https" || reqProtocol === "https") return true; + return origin.startsWith("https://"); +}; + +const shouldUseSecureCookie = (req) => { + if (forceSecureCookie) return true; + if (isLocalRequest(req)) return false; + if (isHttpsRequest(req)) return true; + return isProduction; +}; + +const getCookiesOptions = (req) => { + const secure = shouldUseSecureCookie(req); + return { + maxAge: COOKIE_MAX_AGE_MS, + httpOnly: true, + sameSite: secure ? "none" : "lax", + secure, + }; +}; + +const cookiesOptions = getCookiesOptions(); + +module.exports = { cookiesOptions, getCookiesOptions }; diff --git a/config/corsOptions.js b/config/corsOptions.js index 4d3f269..8adfdcc 100644 --- a/config/corsOptions.js +++ b/config/corsOptions.js @@ -7,6 +7,7 @@ var corsOptions = { 'http://127.0.0.1:8081', 'http://localhost:3000', "https://social.emmint.com", + "https://www.social.emmint.com", "https://fellowship.emmint.com", "https://aeropi.local", ], diff --git a/index.js b/index.js index 7c4ed23..9dde57d 100644 --- a/index.js +++ b/index.js @@ -240,7 +240,7 @@ const webPushEmail = process.env.WEB_PUSH_EMAIL; webPush.setVapidDetails('mailto:' + webPushEmail, publicVapidKey, privateVapidKey); -const { cookiesOptions } = require('./config/cookiesOptions'); +const { getCookiesOptions } = require('./config/cookiesOptions'); const { client_logger } = require('./utils/analyticsLogger.js'); const { getSessionId, getUserId, getProfileId } = require('./utils/sessionUtils.js'); @@ -410,7 +410,7 @@ DB.getDB.then((DB) => { return res.status(403).json({ status: "Profile does not belong to the logged-in user" }); } // Update active profile cookie - res.cookie('profile_id', profile._id, cookiesOptions); + res.cookie('profile_id', profile._id, getCookiesOptions(req)); return res.json({ status: "ok", profile }); } catch (error) { console.error("Error changing profile:", error); diff --git a/middleware/sessionChecker.js b/middleware/sessionChecker.js index 2d753ec..398334a 100644 --- a/middleware/sessionChecker.js +++ b/middleware/sessionChecker.js @@ -1,9 +1,22 @@ const { getSessionId, getUserId, getProfileId } = require('../utils/sessionUtils'); const { client_logger } = require('../utils/analyticsLogger'); -const { cookiesOptions } = require('../config/cookiesOptions'); +const { getCookiesOptions } = require('../config/cookiesOptions'); const MongoDB = require("../mongoDB.js"); const { ObjectId } = require("mongodb"); +const shouldReturnJson = (req) => { + const accept = String(req?.headers?.accept || "").toLowerCase(); + const contentType = String(req?.headers?.["content-type"] || "").toLowerCase(); + return !!req?.headers?.origin || accept.includes("application/json") || contentType.includes("application/json"); +}; + +const rejectUnauthorized = (req, res) => { + if (shouldReturnJson(req)) { + return res.status(401).json({ status: "Unauthorized" }); + } + return res.redirect('/login'); +}; + const sessionChecker = async (req, res, next) => { try { const session_id = getSessionId(req); @@ -11,10 +24,10 @@ const sessionChecker = async (req, res, next) => { let profile_id = getProfileId(req); if (!session_id || !user_sid) { - return res.redirect('/login'); + return rejectUnauthorized(req, res); } if (!ObjectId.isValid(session_id) || !ObjectId.isValid(user_sid)) { - return res.redirect('/login'); + return rejectUnauthorized(req, res); } const DB = await MongoDB.getDB; @@ -24,15 +37,15 @@ const sessionChecker = async (req, res, next) => { if (!await DB.getProfileCache(profile_id)) { const latestProfile = await DB.latestProfile(user_sid); if (!latestProfile || !latestProfile._id) { - return res.redirect('/login'); + return rejectUnauthorized(req, res); } - res.cookie('profile_id', latestProfile._id, cookiesOptions); + res.cookie('profile_id', latestProfile._id, getCookiesOptions(req)); profile_id = latestProfile._id; } req.profileInfo = { _id: profile_id }; - if (!userInfo) return res.redirect('/login'); + if (!userInfo) return rejectUnauthorized(req, res); client_logger.capture({ distinctId: user_sid, @@ -42,7 +55,7 @@ const sessionChecker = async (req, res, next) => { next(); } catch (error) { console.error("Session checker error", error); - return res.redirect('/login'); + return rejectUnauthorized(req, res); } };