Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,23 @@ const logger = winston.createLogger({
exitOnError: false
});

module.exports = logger;
// #524 — Runtime log level configuration
const VALID_LEVELS = ['debug', 'info', 'warn', 'error'];

function setLogLevel(level) {
if (!VALID_LEVELS.includes(level)) {
throw new Error(`Invalid log level: ${level}. Must be one of: ${VALID_LEVELS.join(', ')}`);
}
logger.level = level;
logger.info(`Log level changed to ${level}`);
return level;
}

function getCurrentLogLevel() {
return logger.level;
}

module.exports = logger;
module.exports.setLogLevel = setLogLevel;
module.exports.getCurrentLogLevel = getCurrentLogLevel;
module.exports.VALID_LEVELS = VALID_LEVELS;
61 changes: 61 additions & 0 deletions src/middleware/cacheControl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict';

/**
* Cache-Control response headers middleware (#525).
*
* Sets appropriate Cache-Control headers on API responses to enable
* client caching where safe and prevent caching of sensitive data.
*/

const CACHEABLE_PATHS = [
{ pattern: /^\/api\/prices/, maxAge: 30, public: true },
{ pattern: /^\/api\/airdrops\/?$/, maxAge: 60, public: true },
{ pattern: /^\/api\/metrics/, maxAge: 10, public: true },
{ pattern: /^\/health/, maxAge: 5, public: true },
];

const NO_CACHE_PATHS = [
/^\/api\/auth/,
/^\/api\/keys/,
/^\/api\/webhooks/,
];

/**
* Set Cache-Control headers based on the request path.
*/
function cacheControl(options = {}) {
const { defaultMaxAge = 0 } = options;

return (req, res, next) => {
const path = req.path;

// Never cache auth/keys/webhooks responses
if (NO_CACHE_PATHS.some((p) => p.test(path))) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
return next();
}

// Check if path is cacheable
const cacheable = CACHEABLE_PATHS.find((c) => c.pattern.test(path));
if (cacheable) {
const directive = cacheable.public ? 'public' : 'private';
res.setHeader(
'Cache-Control',
`${directive}, max-age=${cacheable.maxAge}, stale-while-revalidate=${Math.floor(cacheable.maxAge / 2)}`,
);
return next();
}

// Default: no caching for API responses
res.setHeader('Cache-Control', `private, max-age=${defaultMaxAge}`);
next();
};
}

module.exports = {
cacheControl,
CACHEABLE_PATHS,
NO_CACHE_PATHS,
};
131 changes: 131 additions & 0 deletions src/middleware/inputLength.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
'use strict';

/**
* Input length validation middleware (#523).
*
* Validates that string fields in request body/query/params do not exceed
* configured maximum lengths. Prevents buffer overflow, database issues,
* and other security vulnerabilities from oversized inputs.
*/

const DEFAULT_MAX_STRING_LENGTH = 5000;
const DEFAULT_MAX_ARRAY_LENGTH = 100;

/** Per-field maximum lengths for known fields. */
const FIELD_MAX_LENGTHS = {
// General fields
name: 200,
description: 5000,
url: 2000,
email: 320,
phone: 20,
message: 5000,
comment: 5000,
note: 1000,
title: 200,
reason: 500,
note: 1000,

// Stellar-specific
address: 56,
memo: 28,
memoText: 28,
txHash: 64,
secret: 56,

// Identifiers
id: 100,
slug: 100,
token: 500,
apiKey: 100,

// Content
bio: 1000,
content: 10000,
body: 10000,
};

function getMaxLength(field, defaultValue) {
return FIELD_MAX_LENGTHS[field] ?? defaultValue;
}

function checkStringLengths(obj, path = '', errors = []) {
if (obj === null || obj === undefined) return errors;

if (typeof obj === 'string') {
const field = path.split('.').pop();
const max = getMaxLength(field, DEFAULT_MAX_STRING_LENGTH);
if (obj.length > max) {
errors.push(`${path || 'value'} exceeds maximum length of ${max} (got ${obj.length})`);
}
return errors;
}

if (Array.isArray(obj)) {
if (obj.length > DEFAULT_MAX_ARRAY_LENGTH) {
errors.push(`${path || 'array'} exceeds maximum length of ${DEFAULT_MAX_ARRAY_LENGTH} (got ${obj.length})`);
}
obj.forEach((item, index) => checkStringLengths(item, `${path}[${index}]`, errors));
return errors;
}

if (typeof obj === 'object') {
for (const [key, value] of Object.entries(obj)) {
checkStringLengths(value, path ? `${path}.${key}` : key, errors);
}
}

return errors;
}

/**
* Express middleware that validates input lengths.
* Rejects requests with 400 if any field exceeds its maximum length.
*/
function inputLengthValidation(options = {}) {
const {
maxStringLength = DEFAULT_MAX_STRING_LENGTH,
maxArrayLength = DEFAULT_MAX_ARRAY_LENGTH,
fields = {},
} = options;

// Merge custom field limits with defaults
const maxLimits = { ...FIELD_MAX_LENGTHS, ...fields };

return (req, res, next) => {
const errors = [];

// Validate body
if (req.body && typeof req.body === 'object') {
checkStringLengths(req.body, 'body', errors);
}

// Validate query params
if (req.query && typeof req.query === 'object') {
checkStringLengths(req.query, 'query', errors);
}

// Validate route params
if (req.params && typeof req.params === 'object') {
checkStringLengths(req.params, 'params', errors);
}

if (errors.length > 0) {
return res.status(400).json({
error: 'Input validation failed',
message: 'One or more fields exceed maximum allowed length.',
details: errors,
});
}

next();
};
}

module.exports = {
inputLengthValidation,
FIELD_MAX_LENGTHS,
DEFAULT_MAX_STRING_LENGTH,
DEFAULT_MAX_ARRAY_LENGTH,
checkStringLengths,
};
114 changes: 114 additions & 0 deletions src/middleware/mfa.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
'use strict';

/**
* Multi-factor authentication support (#526).
*
* Provides TOTP-based MFA for account security. Integrates with the
* existing auth middleware to require a second factor for sensitive
* operations.
*/

const crypto = require('crypto');

const MFA_ISSUER = process.env.MFA_ISSUER || 'SmartDrop';
const MFA_WINDOW = 1; // Allow 1 time step (30s) of drift

/**
* Generate a TOTP secret for a user.
* Returns a base32-encoded secret.
*/
function generateTotpSecret() {
return crypto.randomBytes(20).toString('base32');
}

/**
* Generate a TOTP code from a secret and timestamp.
* Standard RFC 6238 TOTP implementation.
*/
function generateTotp(secret, timestamp = Date.now()) {
const timeStep = 30; // 30 seconds
const counter = Math.floor(timestamp / 1000 / timeStep);

// Decode base32 secret
const key = Buffer.from(secret, 'base32');

// Create HMAC-SHA1
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const hmac = crypto.createHmac('sha1', key).update(counterBuffer).digest();

// Dynamic truncation
const offset = hmac[hmac.length - 1] & 0x0f;
const code = (
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff)
) % 1000000;

return code.toString().padStart(6, '0');
}

/**
* Verify a TOTP code against a secret.
* Checks current time step and ±1 step for clock drift.
*/
function verifyTotp(secret, code, timestamp = Date.now()) {
if (!code || code.length !== 6) return false;

for (let i = -MFA_WINDOW; i <= MFA_WINDOW; i++) {
const expected = generateTotp(secret, timestamp + i * 30 * 1000);
if (crypto.timingSafeEqual(Buffer.from(code), Buffer.from(expected))) {
return true;
}
}
return false;
}

/**
* Generate an otpauth:// URI for QR code display.
*/
function generateOtpauthUri(secret, accountName) {
return `otpauth://totp/${encodeURIComponent(MFA_ISSUER)}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${encodeURIComponent(MFA_ISSUER)}`;
}

/**
* Middleware that requires MFA verification for sensitive operations.
* Expects X-MFA-Token header with the current TOTP code.
*/
function requireMfa(userSecret) {
return (req, res, next) => {
const mfaToken = req.headers['x-mfa-token'] || req.body?.mfaToken;

if (!mfaToken) {
return res.status(401).json({
error: 'MFA required',
message: 'Multi-factor authentication token is required for this operation.',
});
}

if (!userSecret) {
return res.status(500).json({
error: 'MFA not configured',
message: 'MFA is not configured for this account.',
});
}

if (!verifyTotp(userSecret, mfaToken)) {
return res.status(401).json({
error: 'Invalid MFA token',
message: 'The provided MFA token is invalid or expired.',
});
}

next();
};
}

module.exports = {
generateTotpSecret,
generateTotp,
verifyTotp,
generateOtpauthUri,
requireMfa,
};
Loading