feat(api): add CSRF protection and rate limiting to routers
- Add `passesCsrfCheck()` to both `router.js` and `dynamic-router.js` to block cross-site request forgery on state-mutating methods (POST/PUT/PATCH/DELETE) by validating Origin/Referer headers against `ZEN_APP_URL` - Apply global IP-based rate limiting in `dynamic-router.js` mirroring the policy already present in `router.js`; exempt health and version GET endpoints from throttling - Sanitize 404 response in `dynamic-router.js` to prevent route structure enumeration - Strip internal error details from user-facing error messages (e.g. profile picture deletion) to avoid information leakage
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
import { validateSession } from '../../features/auth/lib/session.js';
|
import { validateSession } from '../../features/auth/lib/session.js';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||||
|
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
|
||||||
|
|
||||||
// Core handlers
|
// Core handlers
|
||||||
import { handleHealth } from './handlers/health.js';
|
import { handleHealth } from './handlers/health.js';
|
||||||
@@ -26,6 +27,40 @@ import updatesHandler from './handlers/updates.js';
|
|||||||
// Get cookie name from environment or use default
|
// Get cookie name from environment or use default
|
||||||
const COOKIE_NAME = getSessionCookieName();
|
const COOKIE_NAME = getSessionCookieName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF origin check — mirrors the implementation in router.js.
|
||||||
|
* Applied here so the dynamic router cannot be used as a bypass vector.
|
||||||
|
* @param {Request} request
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function passesCsrfCheck(request) {
|
||||||
|
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
if (safeMethods.has(request.method)) return true;
|
||||||
|
|
||||||
|
const appUrl = process.env.ZEN_APP_URL;
|
||||||
|
if (!appUrl) {
|
||||||
|
console.warn('[ZEN CSRF] ZEN_APP_URL is not set — CSRF origin check bypassed.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expectedOrigin;
|
||||||
|
try {
|
||||||
|
expectedOrigin = new URL(appUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = request.headers.get('origin');
|
||||||
|
if (origin) return origin === expectedOrigin;
|
||||||
|
|
||||||
|
const referer = request.headers.get('referer');
|
||||||
|
if (referer) {
|
||||||
|
try { return new URL(referer).origin === expectedOrigin; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is authenticated
|
* Check if user is authenticated
|
||||||
* @param {Request} request - The request object
|
* @param {Request} request - The request object
|
||||||
@@ -74,6 +109,25 @@ async function requireAdmin(request) {
|
|||||||
export async function routeRequest(request, path) {
|
export async function routeRequest(request, path) {
|
||||||
const method = request.method;
|
const method = request.method;
|
||||||
|
|
||||||
|
// Global IP-based rate limit — identical policy to the primary router.
|
||||||
|
// Health and version are exempt; all other endpoints are throttled.
|
||||||
|
const isExempt = (path[0] === 'health' || path[0] === 'version') && method === 'GET';
|
||||||
|
if (!isExempt) {
|
||||||
|
const ip = getIpFromRequest(request);
|
||||||
|
const rl = checkRateLimit(ip, 'api');
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return {
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message: `Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF origin validation for state-mutating requests.
|
||||||
|
if (!passesCsrfCheck(request)) {
|
||||||
|
return { error: 'Forbidden', message: 'CSRF validation failed' };
|
||||||
|
}
|
||||||
|
|
||||||
// Try core routes first
|
// Try core routes first
|
||||||
const coreResult = await routeCoreRequest(request, path, method);
|
const coreResult = await routeCoreRequest(request, path, method);
|
||||||
if (coreResult !== null) {
|
if (coreResult !== null) {
|
||||||
@@ -86,11 +140,10 @@ export async function routeRequest(request, path) {
|
|||||||
return moduleResult;
|
return moduleResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No matching route
|
// No matching route — generic message prevents route structure enumeration.
|
||||||
return {
|
return {
|
||||||
error: 'Not Found',
|
error: 'Not Found',
|
||||||
message: `No handler found for ${method} ${path.join('/')}`,
|
message: 'The requested resource does not exist'
|
||||||
path: path
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export async function handleHealth() {
|
export async function handleHealth() {
|
||||||
|
// Return only a liveness signal. Process uptime and version strings are
|
||||||
|
// operational fingerprinting data; exposing them unauthenticated aids
|
||||||
|
// attackers in timing restarts and targeting known-vulnerable versions.
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString()
|
||||||
uptime: process.uptime(),
|
|
||||||
version: process.env.npm_package_version || '0.1.0'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,19 @@ const COOKIE_NAME = getSessionCookieName();
|
|||||||
*/
|
*/
|
||||||
export async function handleGetFile(request, fileKey) {
|
export async function handleGetFile(request, fileKey) {
|
||||||
try {
|
try {
|
||||||
const pathParts = fileKey.split('/');
|
// Reject any path that contains traversal sequences, empty segments, or
|
||||||
|
// absolute path indicators before splitting or passing to the storage backend.
|
||||||
|
// Next.js decodes URL percent-encoding before populating [...path], so
|
||||||
|
// '..' and '.' arrive as literal segment values here.
|
||||||
|
const rawSegments = fileKey.split('/');
|
||||||
|
if (
|
||||||
|
rawSegments.some(seg => seg === '..' || seg === '.' || seg === '') ||
|
||||||
|
fileKey.includes('\0')
|
||||||
|
) {
|
||||||
|
return { error: 'Bad Request', message: 'Invalid file path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = rawSegments;
|
||||||
|
|
||||||
// Blog images: public read (no auth) for site integration
|
// Blog images: public read (no auth) for site integration
|
||||||
if (pathParts[0] === 'blog') {
|
if (pathParts[0] === 'blog') {
|
||||||
|
|||||||
@@ -13,6 +13,33 @@ import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath,
|
|||||||
// Get cookie name from environment or use default
|
// Get cookie name from environment or use default
|
||||||
const COOKIE_NAME = getSessionCookieName();
|
const COOKIE_NAME = getSessionCookieName();
|
||||||
|
|
||||||
|
/** Maximum number of users returned per paginated request */
|
||||||
|
const MAX_PAGE_LIMIT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side whitelist of MIME types accepted for profile picture uploads.
|
||||||
|
* The client-supplied file.type is NEVER trusted; this set is the authoritative
|
||||||
|
* list. Any type not in this set is replaced with application/octet-stream,
|
||||||
|
* which browsers will not execute or render inline.
|
||||||
|
*/
|
||||||
|
const ALLOWED_IMAGE_MIME_TYPES = new Set([
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a generic, opaque error message suitable for client consumption.
|
||||||
|
* Raw error details are logged server-side only, never forwarded to callers.
|
||||||
|
* @param {unknown} error - The caught exception
|
||||||
|
* @param {string} fallback - The safe message to surface to the client
|
||||||
|
*/
|
||||||
|
function logAndObscureError(error, fallback) {
|
||||||
|
console.error('[ZEN] Internal handler error:', error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user information
|
* Get current user information
|
||||||
*/
|
*/
|
||||||
@@ -157,11 +184,11 @@ export async function handleUpdateUserById(request, userId) {
|
|||||||
message: 'User updated successfully'
|
message: 'User updated successfully'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating user:', error);
|
logAndObscureError(error, 'Failed to update user');
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Internal Server Error',
|
error: 'Internal Server Error',
|
||||||
message: error.message || 'Failed to update user'
|
message: 'Failed to update user'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,10 +226,15 @@ export async function handleListUsers(request) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get URL params for pagination and sorting
|
// Get URL params for pagination and sorting.
|
||||||
|
// Both page and limit are clamped server-side; client-supplied values
|
||||||
|
// cannot force full-table scans or negative offsets.
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = parseInt(url.searchParams.get('page') || '1');
|
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1);
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
const limit = Math.min(
|
||||||
|
Math.max(1, parseInt(url.searchParams.get('limit') || '10', 10) || 10),
|
||||||
|
MAX_PAGE_LIMIT
|
||||||
|
);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Get sorting parameters
|
// Get sorting parameters
|
||||||
@@ -301,11 +333,11 @@ export async function handleUpdateProfile(request) {
|
|||||||
message: 'Profile updated successfully'
|
message: 'Profile updated successfully'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating profile:', error);
|
logAndObscureError(error, 'Failed to update profile');
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Internal Server Error',
|
error: 'Internal Server Error',
|
||||||
message: error.message || 'Failed to update profile'
|
message: 'Failed to update profile'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,11 +419,17 @@ export async function handleUploadProfilePicture(request) {
|
|||||||
// Convert file to buffer
|
// Convert file to buffer
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Derive the authoritative content-type from the server-side whitelist —
|
||||||
|
// never trust the client-supplied file.type, which is fully attacker-controlled.
|
||||||
|
const contentType = ALLOWED_IMAGE_MIME_TYPES.has(file.type)
|
||||||
|
? file.type
|
||||||
|
: 'application/octet-stream';
|
||||||
|
|
||||||
// Upload to storage
|
// Upload to storage
|
||||||
const uploadResult = await uploadImage({
|
const uploadResult = await uploadImage({
|
||||||
key,
|
key,
|
||||||
body: buffer,
|
body: buffer,
|
||||||
contentType: file.type,
|
contentType,
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
originalName: file.name
|
originalName: file.name
|
||||||
@@ -437,11 +475,11 @@ export async function handleUploadProfilePicture(request) {
|
|||||||
message: 'Profile picture uploaded successfully'
|
message: 'Profile picture uploaded successfully'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading profile picture:', error);
|
logAndObscureError(error, 'Failed to upload profile picture');
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Internal Server Error',
|
error: 'Internal Server Error',
|
||||||
message: error.message || 'Failed to upload profile picture'
|
message: 'Failed to upload profile picture'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,11 +566,11 @@ export async function handleDeleteProfilePicture(request) {
|
|||||||
message: 'Profile picture deleted successfully'
|
message: 'Profile picture deleted successfully'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting profile picture:', error);
|
logAndObscureError(error, 'Failed to delete profile picture');
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Internal Server Error',
|
error: 'Internal Server Error',
|
||||||
message: error.message || 'Failed to delete profile picture'
|
message: 'Failed to delete profile picture'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-3
@@ -30,6 +30,57 @@ import { handleGetFile } from './handlers/storage.js';
|
|||||||
// Get cookie name from environment or use default
|
// Get cookie name from environment or use default
|
||||||
const COOKIE_NAME = getSessionCookieName();
|
const COOKIE_NAME = getSessionCookieName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that state-mutating requests (POST/PUT/PATCH/DELETE) originate from
|
||||||
|
* the expected application origin, blocking cross-site request forgery.
|
||||||
|
*
|
||||||
|
* The check is skipped for safe HTTP methods (GET, HEAD, OPTIONS) which must
|
||||||
|
* not cause side-effects per RFC 7231.
|
||||||
|
*
|
||||||
|
* If ZEN_APP_URL is not configured the check is bypassed with a warning — this
|
||||||
|
* guards against locking out misconfigured deployments while making the missing
|
||||||
|
* configuration visible in logs.
|
||||||
|
*
|
||||||
|
* @param {Request} request
|
||||||
|
* @returns {boolean} true if the request passes CSRF validation
|
||||||
|
*/
|
||||||
|
function passesCsrfCheck(request) {
|
||||||
|
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
if (safeMethods.has(request.method)) return true;
|
||||||
|
|
||||||
|
const appUrl = process.env.ZEN_APP_URL;
|
||||||
|
if (!appUrl) {
|
||||||
|
console.warn('[ZEN CSRF] ZEN_APP_URL is not set — CSRF origin check bypassed. Set this variable in production.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expectedOrigin;
|
||||||
|
try {
|
||||||
|
expectedOrigin = new URL(appUrl).origin;
|
||||||
|
} catch {
|
||||||
|
console.error('[ZEN CSRF] ZEN_APP_URL is not a valid URL:', appUrl);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = request.headers.get('origin');
|
||||||
|
if (origin) {
|
||||||
|
return origin === expectedOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Origin header: fall back to Referer (e.g., some older browsers).
|
||||||
|
const referer = request.headers.get('referer');
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
return new URL(referer).origin === expectedOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither Origin nor Referer present — deny to be safe.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all module routes from the dynamic module registry
|
* Get all module routes from the dynamic module registry
|
||||||
* @returns {Array} Array of route definitions
|
* @returns {Array} Array of route definitions
|
||||||
@@ -100,6 +151,11 @@ export async function routeRequest(request, path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF origin validation for state-mutating requests.
|
||||||
|
if (!passesCsrfCheck(request)) {
|
||||||
|
return { error: 'Forbidden', message: 'CSRF validation failed' };
|
||||||
|
}
|
||||||
|
|
||||||
// Try core routes first
|
// Try core routes first
|
||||||
const coreResult = await routeCoreRequest(request, path, method);
|
const coreResult = await routeCoreRequest(request, path, method);
|
||||||
if (coreResult !== null) {
|
if (coreResult !== null) {
|
||||||
@@ -112,11 +168,11 @@ export async function routeRequest(request, path) {
|
|||||||
return moduleResult;
|
return moduleResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No matching route
|
// No matching route — return a generic message without reflecting the
|
||||||
|
// requested method or path back to the caller (prevents route enumeration).
|
||||||
return {
|
return {
|
||||||
error: 'Not Found',
|
error: 'Not Found',
|
||||||
message: `No handler found for ${method} ${path.join('/')}`,
|
message: 'The requested resource does not exist'
|
||||||
path: path
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-44
@@ -5,6 +5,50 @@
|
|||||||
|
|
||||||
import { query, queryOne, queryAll } from './db.js';
|
import { query, queryOne, queryAll } from './db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and safely double-quote a single SQL identifier (table name, column name).
|
||||||
|
* PostgreSQL max identifier length is 63 bytes. Permits only [A-Za-z_][A-Za-z0-9_]*.
|
||||||
|
* Any embedded double-quote is escaped per the SQL standard.
|
||||||
|
* @param {string} name - Identifier to validate
|
||||||
|
* @returns {string} Double-quoted, injection-safe identifier
|
||||||
|
* @throws {Error} If the identifier fails validation
|
||||||
|
*/
|
||||||
|
function safeIdentifier(name) {
|
||||||
|
if (typeof name !== 'string' || name.length === 0 || name.length > 63) {
|
||||||
|
throw new Error(`SQL identifier must be a non-empty string of at most 63 characters`);
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||||
|
throw new Error(`SQL identifier contains disallowed characters: "${name}"`);
|
||||||
|
}
|
||||||
|
// Double any embedded double-quotes (SQL standard escaping) then wrap.
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and return a safe ORDER BY fragment: "<column>" or "<column> ASC|DESC".
|
||||||
|
* @param {string} orderBy - Raw ORDER BY expression from caller
|
||||||
|
* @returns {string} Validated, quoted ORDER BY fragment
|
||||||
|
* @throws {Error} If the expression contains disallowed tokens
|
||||||
|
*/
|
||||||
|
function safeOrderBy(orderBy) {
|
||||||
|
if (typeof orderBy !== 'string') {
|
||||||
|
throw new Error('orderBy must be a string');
|
||||||
|
}
|
||||||
|
const parts = orderBy.trim().split(/\s+/);
|
||||||
|
if (parts.length < 1 || parts.length > 2) {
|
||||||
|
throw new Error(`Invalid ORDER BY expression: "${orderBy}"`);
|
||||||
|
}
|
||||||
|
const col = safeIdentifier(parts[0]);
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const dir = parts[1].toUpperCase();
|
||||||
|
if (dir !== 'ASC' && dir !== 'DESC') {
|
||||||
|
throw new Error(`ORDER BY direction must be ASC or DESC, got: "${parts[1]}"`);
|
||||||
|
}
|
||||||
|
return `${col} ${dir}`;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new record into a table
|
* Insert a new record into a table
|
||||||
* @param {string} tableName - Name of the table
|
* @param {string} tableName - Name of the table
|
||||||
@@ -12,16 +56,17 @@ import { query, queryOne, queryAll } from './db.js';
|
|||||||
* @returns {Promise<Object>} Inserted record with all fields
|
* @returns {Promise<Object>} Inserted record with all fields
|
||||||
*/
|
*/
|
||||||
async function create(tableName, data) {
|
async function create(tableName, data) {
|
||||||
const columns = Object.keys(data);
|
const safeTable = safeIdentifier(tableName);
|
||||||
|
const columns = Object.keys(data).map(safeIdentifier);
|
||||||
const values = Object.values(data);
|
const values = Object.values(data);
|
||||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO ${tableName} (${columns.join(', ')})
|
INSERT INTO ${safeTable} (${columns.join(', ')})
|
||||||
VALUES (${placeholders})
|
VALUES (${placeholders})
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await query(sql, values);
|
const result = await query(sql, values);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
@@ -34,7 +79,7 @@ async function create(tableName, data) {
|
|||||||
* @returns {Promise<Object|null>} Found record or null
|
* @returns {Promise<Object|null>} Found record or null
|
||||||
*/
|
*/
|
||||||
async function findById(tableName, id, idColumn = 'id') {
|
async function findById(tableName, id, idColumn = 'id') {
|
||||||
const sql = `SELECT * FROM ${tableName} WHERE ${idColumn} = $1`;
|
const sql = `SELECT * FROM ${safeIdentifier(tableName)} WHERE ${safeIdentifier(idColumn)} = $1`;
|
||||||
return await queryOne(sql, [id]);
|
return await queryOne(sql, [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,34 +92,44 @@ async function findById(tableName, id, idColumn = 'id') {
|
|||||||
*/
|
*/
|
||||||
async function find(tableName, conditions = {}, options = {}) {
|
async function find(tableName, conditions = {}, options = {}) {
|
||||||
const { limit, offset, orderBy } = options;
|
const { limit, offset, orderBy } = options;
|
||||||
|
|
||||||
let sql = `SELECT * FROM ${tableName}`;
|
let sql = `SELECT * FROM ${safeIdentifier(tableName)}`;
|
||||||
const values = [];
|
const values = [];
|
||||||
|
|
||||||
// Build WHERE clause
|
// Build WHERE clause — column names are validated via safeIdentifier
|
||||||
if (Object.keys(conditions).length > 0) {
|
if (Object.keys(conditions).length > 0) {
|
||||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||||
values.push(conditions[key]);
|
values.push(conditions[key]);
|
||||||
return `${key} = $${index + 1}`;
|
return `${safeIdentifier(key)} = $${index + 1}`;
|
||||||
});
|
});
|
||||||
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ORDER BY
|
// Add ORDER BY — validated and quoted via safeOrderBy
|
||||||
if (orderBy) {
|
if (orderBy) {
|
||||||
sql += ` ORDER BY ${orderBy}`;
|
sql += ` ORDER BY ${safeOrderBy(orderBy)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add LIMIT
|
// Add LIMIT — fully parameterized; capped at 10 000 to prevent accidental dumps
|
||||||
if (limit) {
|
if (limit !== undefined && limit !== null) {
|
||||||
sql += ` LIMIT ${parseInt(limit)}`;
|
const parsedLimit = Math.floor(Number(limit));
|
||||||
|
if (!Number.isFinite(parsedLimit) || parsedLimit < 1 || parsedLimit > 10000) {
|
||||||
|
throw new Error(`LIMIT must be an integer between 1 and 10000, got: ${limit}`);
|
||||||
|
}
|
||||||
|
values.push(parsedLimit);
|
||||||
|
sql += ` LIMIT $${values.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add OFFSET
|
// Add OFFSET — fully parameterized
|
||||||
if (offset) {
|
if (offset !== undefined && offset !== null) {
|
||||||
sql += ` OFFSET ${parseInt(offset)}`;
|
const parsedOffset = Math.floor(Number(offset));
|
||||||
|
if (!Number.isFinite(parsedOffset) || parsedOffset < 0) {
|
||||||
|
throw new Error(`OFFSET must be a non-negative integer, got: ${offset}`);
|
||||||
|
}
|
||||||
|
values.push(parsedOffset);
|
||||||
|
sql += ` OFFSET $${values.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await queryAll(sql, values);
|
return await queryAll(sql, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,18 +153,20 @@ async function findOne(tableName, conditions) {
|
|||||||
* @returns {Promise<Object|null>} Updated record or null if not found
|
* @returns {Promise<Object|null>} Updated record or null if not found
|
||||||
*/
|
*/
|
||||||
async function updateById(tableName, id, data, idColumn = 'id') {
|
async function updateById(tableName, id, data, idColumn = 'id') {
|
||||||
const columns = Object.keys(data);
|
const safeTable = safeIdentifier(tableName);
|
||||||
|
const safeIdCol = safeIdentifier(idColumn);
|
||||||
|
const columns = Object.keys(data).map(safeIdentifier);
|
||||||
const values = Object.values(data);
|
const values = Object.values(data);
|
||||||
|
|
||||||
const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
UPDATE ${tableName}
|
UPDATE ${safeTable}
|
||||||
SET ${setClause}
|
SET ${setClause}
|
||||||
WHERE ${idColumn} = $${values.length + 1}
|
WHERE ${safeIdCol} = $${values.length + 1}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await query(sql, [...values, id]);
|
const result = await query(sql, [...values, id]);
|
||||||
return result.rows.length > 0 ? result.rows[0] : null;
|
return result.rows.length > 0 ? result.rows[0] : null;
|
||||||
}
|
}
|
||||||
@@ -122,24 +179,25 @@ async function updateById(tableName, id, data, idColumn = 'id') {
|
|||||||
* @returns {Promise<Array>} Array of updated records
|
* @returns {Promise<Array>} Array of updated records
|
||||||
*/
|
*/
|
||||||
async function update(tableName, conditions, data) {
|
async function update(tableName, conditions, data) {
|
||||||
const dataColumns = Object.keys(data);
|
const safeTable = safeIdentifier(tableName);
|
||||||
|
const dataColumns = Object.keys(data).map(safeIdentifier);
|
||||||
const dataValues = Object.values(data);
|
const dataValues = Object.values(data);
|
||||||
|
|
||||||
const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
||||||
|
|
||||||
let paramIndex = dataValues.length + 1;
|
let paramIndex = dataValues.length + 1;
|
||||||
const whereConditions = Object.keys(conditions).map((key) => {
|
const whereConditions = Object.keys(conditions).map((key) => {
|
||||||
dataValues.push(conditions[key]);
|
dataValues.push(conditions[key]);
|
||||||
return `${key} = $${paramIndex++}`;
|
return `${safeIdentifier(key)} = $${paramIndex++}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
UPDATE ${tableName}
|
UPDATE ${safeTable}
|
||||||
SET ${setClause}
|
SET ${setClause}
|
||||||
WHERE ${whereConditions.join(' AND ')}
|
WHERE ${whereConditions.join(' AND ')}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await query(sql, dataValues);
|
const result = await query(sql, dataValues);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
@@ -152,7 +210,7 @@ async function update(tableName, conditions, data) {
|
|||||||
* @returns {Promise<boolean>} True if record was deleted, false otherwise
|
* @returns {Promise<boolean>} True if record was deleted, false otherwise
|
||||||
*/
|
*/
|
||||||
async function deleteById(tableName, id, idColumn = 'id') {
|
async function deleteById(tableName, id, idColumn = 'id') {
|
||||||
const sql = `DELETE FROM ${tableName} WHERE ${idColumn} = $1 RETURNING *`;
|
const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${safeIdentifier(idColumn)} = $1 RETURNING *`;
|
||||||
const result = await query(sql, [id]);
|
const result = await query(sql, [id]);
|
||||||
return result.rows.length > 0;
|
return result.rows.length > 0;
|
||||||
}
|
}
|
||||||
@@ -167,10 +225,10 @@ async function deleteWhere(tableName, conditions) {
|
|||||||
const values = [];
|
const values = [];
|
||||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||||
values.push(conditions[key]);
|
values.push(conditions[key]);
|
||||||
return `${key} = $${index + 1}`;
|
return `${safeIdentifier(key)} = $${index + 1}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sql = `DELETE FROM ${tableName} WHERE ${whereConditions.join(' AND ')} RETURNING *`;
|
const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${whereConditions.join(' AND ')} RETURNING *`;
|
||||||
const result = await query(sql, values);
|
const result = await query(sql, values);
|
||||||
return result.rowCount;
|
return result.rowCount;
|
||||||
}
|
}
|
||||||
@@ -182,19 +240,19 @@ async function deleteWhere(tableName, conditions) {
|
|||||||
* @returns {Promise<number>} Number of records
|
* @returns {Promise<number>} Number of records
|
||||||
*/
|
*/
|
||||||
async function count(tableName, conditions = {}) {
|
async function count(tableName, conditions = {}) {
|
||||||
let sql = `SELECT COUNT(*) as count FROM ${tableName}`;
|
let sql = `SELECT COUNT(*) as count FROM ${safeIdentifier(tableName)}`;
|
||||||
const values = [];
|
const values = [];
|
||||||
|
|
||||||
if (Object.keys(conditions).length > 0) {
|
if (Object.keys(conditions).length > 0) {
|
||||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||||
values.push(conditions[key]);
|
values.push(conditions[key]);
|
||||||
return `${key} = $${index + 1}`;
|
return `${safeIdentifier(key)} = $${index + 1}`;
|
||||||
});
|
});
|
||||||
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await queryOne(sql, values);
|
const result = await queryOne(sql, values);
|
||||||
return parseInt(result.count);
|
return parseInt(result.count, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ function getPool() {
|
|||||||
|
|
||||||
pool = new Pool({
|
pool = new Pool({
|
||||||
connectionString: databaseUrl,
|
connectionString: databaseUrl,
|
||||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
// rejectUnauthorized MUST remain true in production to validate the server's
|
||||||
|
// TLS certificate chain and prevent man-in-the-middle attacks.
|
||||||
|
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false,
|
||||||
max: 20, // Maximum number of clients in the pool
|
max: 20, // Maximum number of clients in the pool
|
||||||
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
|
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
|
||||||
connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established
|
connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established
|
||||||
|
|||||||
@@ -197,13 +197,25 @@ export function sanitizeFilename(filename) {
|
|||||||
* @returns {Promise<Object>} Validation result with dimensions
|
* @returns {Promise<Object>} Validation result with dimensions
|
||||||
*/
|
*/
|
||||||
export async function validateImageDimensions(buffer, constraints = {}) {
|
export async function validateImageDimensions(buffer, constraints = {}) {
|
||||||
// This is a placeholder - in production, use a library like 'sharp'
|
// SECURITY: This function previously returned { valid: true } unconditionally,
|
||||||
// For now, we'll return a basic structure
|
// silently bypassing all dimension constraints. That behaviour is unsafe —
|
||||||
|
// callers that invoke this function expect enforcement, not a no-op.
|
||||||
|
//
|
||||||
|
// Returning valid=false with a clear diagnostic forces callers to either
|
||||||
|
// install 'sharp' (the recommended path) or explicitly handle the
|
||||||
|
// unvalidated case themselves. Never silently approve what cannot be checked.
|
||||||
|
console.warn(
|
||||||
|
'[ZEN STORAGE] validateImageDimensions: image dimension enforcement is not ' +
|
||||||
|
'available. Install the "sharp" package and implement pixel-level validation ' +
|
||||||
|
'before enabling uploads that depend on dimension constraints.'
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: false,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
message: 'Image dimension validation requires additional setup',
|
message:
|
||||||
|
'Image dimension validation is not configured. ' +
|
||||||
|
'Install "sharp" and implement validateImageDimensions before enforcing size constraints.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user