fix: harden API security against info leakage and MIME sniffing
- Sanitize route handler errors: only surface known auth messages
('Unauthorized', 'Admin access required'); log all other exceptions
server-side and return a generic 'Internal Server Error' to clients
- Derive profile picture content-type from validated file extension
instead of attacker-controlled file.type to prevent MIME spoofing
- Always emit explicit Content-Disposition headers on file responses;
serve known image types as 'inline', force download for all others
to prevent in-browser rendering of potentially dangerous content
- Add X-Content-Type-Options: nosniff and X-Frame-Options: DENY to
file response headers
This commit is contained in:
@@ -103,14 +103,25 @@ export async function loginAction(formData) {
|
||||
const password = formData.get('password');
|
||||
|
||||
const result = await login({ email, password });
|
||||
|
||||
// Return the token to be set by the client to avoid page refresh
|
||||
// The client will call setSessionCookie after displaying the success message
|
||||
|
||||
// Set the session cookie directly inside this server action so the token
|
||||
// never travels through JavaScript-readable response payload.
|
||||
// An HttpOnly cookie is the only safe transport for session tokens.
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(COOKIE_NAME, result.session.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
path: '/'
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Connexion réussie',
|
||||
user: result.user,
|
||||
sessionToken: result.session.token
|
||||
user: result.user
|
||||
// sessionToken intentionally omitted — it must never appear in a
|
||||
// JavaScript-accessible response body.
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { create, findOne, updateById, count } from '../../../core/database/crud.js';
|
||||
import { hashPassword, verifyPassword, generateId } from './password.js';
|
||||
import { createSession } from './session.js';
|
||||
import { createEmailVerification, createPasswordReset, deleteResetToken, sendPasswordChangedEmail } from './email.js';
|
||||
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, sendPasswordChangedEmail } from './email.js';
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
@@ -210,9 +210,14 @@ async function resetPassword(resetData) {
|
||||
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
|
||||
}
|
||||
|
||||
// Verify token is handled in the email module
|
||||
// For now, we'll assume token is valid if it exists in the database
|
||||
|
||||
// Authoritative token verification — this check must live here so that any
|
||||
// caller that imports resetPassword() directly (bypassing the server-action
|
||||
// layer) cannot reset a password with an arbitrary or omitted token.
|
||||
const tokenValid = await verifyResetToken(email, token);
|
||||
if (!tokenValid) {
|
||||
throw new Error('Jeton de réinitialisation invalide ou expiré');
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await findOne('zen_auth_users', { email });
|
||||
if (!user) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Handles email verification tokens and password reset tokens
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { create, findOne, deleteWhere } from '../../../core/database/crud.js';
|
||||
import { generateToken, generateId } from './password.js';
|
||||
import { sendAuthEmail } from '../../../core/email/index.js';
|
||||
@@ -55,19 +56,27 @@ async function verifyEmailToken(email, token) {
|
||||
});
|
||||
|
||||
if (!verification) return false;
|
||||
|
||||
// Verify token matches
|
||||
if (verification.token !== token) return false;
|
||||
|
||||
|
||||
// Timing-safe comparison — always operate on same-length buffers so that a
|
||||
// wrong-length guess yields no measurable timing difference from a wrong-value guess.
|
||||
const storedBuf = Buffer.from(verification.token, 'utf8');
|
||||
const providedBuf = Buffer.from(
|
||||
token.length === verification.token.length ? token : verification.token,
|
||||
'utf8'
|
||||
);
|
||||
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
|
||||
&& token.length === verification.token.length;
|
||||
if (!tokensMatch) return false;
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date(verification.expires_at) < new Date()) {
|
||||
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Delete the verification token after use
|
||||
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -118,16 +127,23 @@ async function verifyResetToken(email, token) {
|
||||
});
|
||||
|
||||
if (!reset) return false;
|
||||
|
||||
// Verify token matches
|
||||
if (reset.token !== token) return false;
|
||||
|
||||
|
||||
// Timing-safe comparison — same rationale as verifyEmailToken above.
|
||||
const storedBuf = Buffer.from(reset.token, 'utf8');
|
||||
const providedBuf = Buffer.from(
|
||||
token.length === reset.token.length ? token : reset.token,
|
||||
'utf8'
|
||||
);
|
||||
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
|
||||
&& token.length === reset.token.length;
|
||||
if (!tokensMatch) return false;
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date(reset.expires_at) < new Date()) {
|
||||
await deleteWhere('zen_auth_verifications', { id: reset.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,18 @@ async function hashPassword(password) {
|
||||
async function verifyPassword(password, hash) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, key] = hash.split(':');
|
||||
|
||||
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
resolve(key === derivedKey.toString('hex'));
|
||||
if (err) { reject(err); return; }
|
||||
try {
|
||||
const storedKey = Buffer.from(key, 'hex');
|
||||
// timingSafeEqual requires identical lengths; if the stored hash is
|
||||
// malformed the lengths will differ and we reject without leaking timing.
|
||||
if (storedKey.length !== derivedKey.length) { resolve(false); return; }
|
||||
resolve(crypto.timingSafeEqual(storedKey, derivedKey));
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
* Stores counters in a Map — resets on server restart, no DB required.
|
||||
*/
|
||||
|
||||
// Persist the store on globalThis so that module-cache invalidation (e.g. during
|
||||
// Next.js hot reload) does not silently reset all counters within the same process.
|
||||
// CRITICAL LIMITATION: this Map is process-local. In serverless or multi-worker
|
||||
// deployments every instance maintains its own store and rate limits do not
|
||||
// distribute across instances. For production deployments with multiple workers
|
||||
// replace this Map with a shared atomic store (e.g. Redis / Upstash).
|
||||
const STORE_KEY = Symbol.for('__ZEN_RATE_LIMIT_STORE__');
|
||||
if (!globalThis[STORE_KEY]) globalThis[STORE_KEY] = new Map();
|
||||
/** @type {Map<string, { count: number, windowStart: number, windowMs: number, blockedUntil: number|null }>} */
|
||||
const store = new Map();
|
||||
const store = globalThis[STORE_KEY];
|
||||
|
||||
// Purge expired entries every 10 minutes to avoid memory leak
|
||||
const cleanup = setInterval(() => {
|
||||
@@ -76,29 +84,63 @@ export function checkRateLimit(identifier, action) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the best-effort client IP from Next.js headers() (server actions).
|
||||
* Return true only when the string resembles a valid IPv4 or IPv6 address.
|
||||
* This prevents arbitrary attacker-supplied strings from being used as
|
||||
* rate-limit identifiers (which could allow bucket manipulation).
|
||||
* @param {string|null|undefined} ip
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidIp(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
// IPv4 — four decimal octets, each 0-255
|
||||
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
|
||||
return ip.split('.').every(octet => parseInt(octet, 10) <= 255);
|
||||
}
|
||||
// IPv6 — simplified structural check (colons + hex groups)
|
||||
return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client IP from Next.js headers() (server actions).
|
||||
*
|
||||
* X-Forwarded-For and X-Real-IP are only trusted when ZEN_TRUST_PROXY=true is
|
||||
* explicitly set, confirming a trusted reverse proxy populates those headers.
|
||||
* Without this flag the headers are fully attacker-controlled and MUST NOT be
|
||||
* used as rate-limit keys — an attacker would trivially rotate identifiers.
|
||||
*
|
||||
* Set ZEN_TRUST_PROXY=true only when a verified reverse proxy (e.g. Nginx,
|
||||
* Cloudflare, AWS ALB) strips and rewrites forwarded headers before they reach
|
||||
* this application.
|
||||
*
|
||||
* @param {import('next/headers').ReadonlyHeaders} headersList
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromHeaders(headersList) {
|
||||
return (
|
||||
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
headersList.get('x-real-ip') ||
|
||||
'unknown'
|
||||
);
|
||||
if (process.env.ZEN_TRUST_PROXY === 'true') {
|
||||
const forwarded = headersList.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
if (forwarded && isValidIp(forwarded)) return forwarded;
|
||||
const realIp = headersList.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
// Safe fallback — all requests share the 'unknown' bucket.
|
||||
// Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy.
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the best-effort client IP from a Next.js Request object (API routes).
|
||||
* Extract the client IP from a Next.js Request object (API routes).
|
||||
* See getIpFromHeaders for the full trust-proxy rationale.
|
||||
* @param {Request} request
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromRequest(request) {
|
||||
return (
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown'
|
||||
);
|
||||
if (process.env.ZEN_TRUST_PROXY === 'true') {
|
||||
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
if (forwarded && isValidIp(forwarded)) return forwarded;
|
||||
const realIp = request.headers.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user