fix: harden auth inputs, throttling, and debug exposure

This commit is contained in:
Nico 2026-02-18 12:24:15 -08:00
parent 3469284e98
commit c3c0c33339
8 changed files with 147 additions and 28 deletions

View File

@ -9,8 +9,10 @@ const app = express();
app.use(requestIdMiddleware); app.use(requestIdMiddleware);
app.use(express.json()); app.use(express.json());
// Serve static files from public directory // Expose manual API test pages in non-production environments only.
app.use('/test', express.static(path.join(__dirname, 'public'))); if (process.env.NODE_ENV !== "production") {
app.use("/test", express.static(path.join(__dirname, "public")));
}
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "") const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
.split(",") .split(",")

View File

@ -9,13 +9,26 @@ const { logError } = require("../utils/logger");
exports.register = async (req, res) => { exports.register = async (req, res) => {
let { username, password, name } = req.body; let { username, password, name } = req.body;
if (
!username ||
!password ||
!name ||
typeof username !== "string" ||
typeof password !== "string" ||
typeof name !== "string"
) {
return sendError(res, 400, "Username, password, and name are required");
}
username = username.toLowerCase(); username = username.toLowerCase();
console.log(`Registration attempt for ${name} => username:${username}`); if (password.length < 8) {
return sendError(res, 400, "Password must be at least 8 characters");
}
try { try {
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name); const user = await User.createUser(username, hash, name);
console.log(`User registered: ${username}`);
res.json({ message: "User registered", user }); res.json({ message: "User registered", user });
} catch (err) { } catch (err) {
@ -27,22 +40,35 @@ exports.register = async (req, res) => {
exports.login = async (req, res) => { exports.login = async (req, res) => {
let { username, password } = req.body; let { username, password } = req.body;
if (
!username ||
!password ||
typeof username !== "string" ||
typeof password !== "string"
) {
return sendError(res, 400, "Username and password are required");
}
username = username.toLowerCase(); username = username.toLowerCase();
const user = await User.findByUsername(username); const user = await User.findByUsername(username);
if (!user) { if (!user) {
console.log(`Login attempt with unknown user: ${username}`); return sendError(res, 401, "Invalid credentials");
return sendError(res, 401, "User not found");
} }
const valid = await bcrypt.compare(password, user.password); const valid = await bcrypt.compare(password, user.password);
if (!valid) { if (!valid) {
console.log(`Invalid login attempt for user ${username}`);
return sendError(res, 401, "Invalid credentials"); return sendError(res, 401, "Invalid credentials");
} }
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
logError(req, "auth.login.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
return sendError(res, 500, "Authentication is unavailable");
}
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, role: user.role }, { id: user.id, role: user.role },
process.env.JWT_SECRET, jwtSecret,
{ expiresIn: "1 year" } { expiresIn: "1 year" }
); );

View File

@ -4,7 +4,6 @@ const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger"); const { logError } = require("../utils/logger");
exports.test = async (req, res) => { exports.test = async (req, res) => {
console.log("User route is working");
res.json({ message: "User route is working" }); res.json({ message: "User route is working" });
}; };
@ -17,8 +16,6 @@ exports.getAllUsers = async (req, res) => {
exports.updateUserRole = async (req, res) => { exports.updateUserRole = async (req, res) => {
try { try {
const { id, role } = req.body; const { id, role } = req.body;
console.log(`Updating user ${id} to role ${role}`);
if (!Object.values(User.ROLES).includes(role)) if (!Object.values(User.ROLES).includes(role))
return sendError(res, 400, "Invalid role"); return sendError(res, 400, "Invalid role");

View File

@ -10,8 +10,14 @@ async function auth(req, res, next) {
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
if (token) { if (token) {
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
return sendError(res, 500, "Authentication is unavailable");
}
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, jwtSecret);
req.user = decoded; // id + role req.user = decoded; // id + role
return next(); return next();
} catch (err) { } catch (err) {

View File

@ -0,0 +1,58 @@
const { sendError } = require("../utils/http");
const buckets = new Map();
function pruneExpired(now) {
for (const [key, value] of buckets.entries()) {
if (value.resetAt <= now) {
buckets.delete(key);
}
}
}
function getClientIp(req) {
const forwardedFor = req.headers["x-forwarded-for"];
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
return forwardedFor.split(",")[0].trim();
}
return req.ip || req.socket?.remoteAddress || "unknown";
}
function createRateLimit({ keyPrefix, windowMs, max, message }) {
return (req, res, next) => {
const now = Date.now();
if (buckets.size > 5000) {
pruneExpired(now);
}
const key = `${keyPrefix}:${getClientIp(req)}`;
const existing = buckets.get(key);
const bucket =
!existing || existing.resetAt <= now
? { count: 0, resetAt: now + windowMs }
: existing;
bucket.count += 1;
buckets.set(key, bucket);
if (bucket.count > max) {
const retryAfterSeconds = Math.max(
1,
Math.ceil((bucket.resetAt - now) / 1000)
);
res.setHeader("Retry-After", String(retryAfterSeconds));
return sendError(
res,
429,
message || "Too many requests. Please try again later."
);
}
return next();
};
}
module.exports = {
createRateLimit,
};

View File

@ -1,9 +1,24 @@
const router = require("express").Router(); const router = require("express").Router();
const controller = require("../controllers/auth.controller"); const controller = require("../controllers/auth.controller");
const User = require("../models/user.model"); const User = require("../models/user.model");
const { createRateLimit } = require("../middleware/rate-limit");
router.post("/register", controller.register); const loginRateLimit = createRateLimit({
router.post("/login", controller.login); keyPrefix: "auth:login",
windowMs: 15 * 60 * 1000,
max: 25,
message: "Too many login attempts. Please try again later.",
});
const registerRateLimit = createRateLimit({
keyPrefix: "auth:register",
windowMs: 15 * 60 * 1000,
max: 10,
message: "Too many registration attempts. Please try again later.",
});
router.post("/register", registerRateLimit, controller.register);
router.post("/login", loginRateLimit, controller.login);
router.post("/logout", controller.logout); router.post("/logout", controller.logout);
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
res.status(200).json({ res.status(200).json({

View File

@ -3,9 +3,19 @@ const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac"); const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller"); const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model"); const { ROLES } = require("../models/user.model");
const { createRateLimit } = require("../middleware/rate-limit");
router.get("/exists", usersController.checkIfUserExists); const userExistsRateLimit = createRateLimit({
router.get("/test", usersController.test); keyPrefix: "users:exists",
windowMs: 15 * 60 * 1000,
max: 60,
message: "Too many availability checks. Please try again later.",
});
router.get("/exists", userExistsRateLimit, usersController.checkIfUserExists);
if (process.env.NODE_ENV !== "production") {
router.get("/test", usersController.test);
}
// Current user profile routes (authenticated) // Current user profile routes (authenticated)
router.get("/me", auth, usersController.getCurrentUser); router.get("/me", auth, usersController.getCurrentUser);

View File

@ -9,7 +9,12 @@ function parseCookieHeader(cookieHeader) {
const key = segment.slice(0, index).trim(); const key = segment.slice(0, index).trim();
const value = segment.slice(index + 1).trim(); const value = segment.slice(index + 1).trim();
if (!key) continue; if (!key) continue;
try {
cookies[key] = decodeURIComponent(value); cookies[key] = decodeURIComponent(value);
} catch (_) {
// Ignore malformed cookie values instead of throwing.
continue;
}
} }
return cookies; return cookies;