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(express.json());
// Serve static files from public directory
app.use('/test', express.static(path.join(__dirname, 'public')));
// Expose manual API test pages in non-production environments only.
if (process.env.NODE_ENV !== "production") {
app.use("/test", express.static(path.join(__dirname, "public")));
}
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
.split(",")

View File

@ -9,13 +9,26 @@ const { logError } = require("../utils/logger");
exports.register = async (req, res) => {
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();
console.log(`Registration attempt for ${name} => username:${username}`);
if (password.length < 8) {
return sendError(res, 400, "Password must be at least 8 characters");
}
try {
const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name);
console.log(`User registered: ${username}`);
res.json({ message: "User registered", user });
} catch (err) {
@ -27,22 +40,35 @@ exports.register = async (req, res) => {
exports.login = async (req, res) => {
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();
const user = await User.findByUsername(username);
if (!user) {
console.log(`Login attempt with unknown user: ${username}`);
return sendError(res, 401, "User not found");
return sendError(res, 401, "Invalid credentials");
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
console.log(`Invalid login attempt for user ${username}`);
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(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
jwtSecret,
{ expiresIn: "1 year" }
);

View File

@ -3,10 +3,9 @@ const bcrypt = require("bcryptjs");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
exports.test = async (req, res) => {
console.log("User route is working");
res.json({ message: "User route is working" });
};
exports.test = async (req, res) => {
res.json({ message: "User route is working" });
};
exports.getAllUsers = async (req, res) => {
const users = await User.getAllUsers();
@ -15,10 +14,8 @@ exports.getAllUsers = async (req, res) => {
exports.updateUserRole = async (req, res) => {
try {
const { id, role } = req.body;
console.log(`Updating user ${id} to role ${role}`);
try {
const { id, role } = req.body;
if (!Object.values(User.ROLES).includes(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;
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 {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, jwtSecret);
req.user = decoded; // id + role
return next();
} 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 controller = require("../controllers/auth.controller");
const User = require("../models/user.model");
router.post("/register", controller.register);
router.post("/login", controller.login);
const { createRateLimit } = require("../middleware/rate-limit");
const loginRateLimit = createRateLimit({
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("/", async (req, res) => {
res.status(200).json({

View File

@ -1,11 +1,21 @@
const router = require("express").Router();
const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model");
router.get("/exists", usersController.checkIfUserExists);
router.get("/test", usersController.test);
const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model");
const { createRateLimit } = require("../middleware/rate-limit");
const userExistsRateLimit = createRateLimit({
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)
router.get("/me", auth, usersController.getCurrentUser);

View File

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