qgenutils
Version:
A security-first Node.js utility library providing authentication, HTTP operations, URL processing, validation, datetime formatting, and template rendering. Designed as a lightweight alternative to heavy npm packages with comprehensive error handling and
324 lines (303 loc) • 16.9 kB
JavaScript
/*
* HTTP Utility Module
*
* This module provides HTTP-related utilities for web applications, focusing on
* header management, content-length calculation, and response standardization.
*
* DESIGN PHILOSOPHY:
* - Security first: Remove potentially dangerous headers from proxied requests
* - Accuracy: Calculate content-length precisely to prevent HTTP issues
* - Consistency: Standardize JSON responses across the application
* - Resilience: Handle edge cases gracefully without breaking request flow
*
* PRIMARY USE CASES:
* - API proxying where headers need cleaning
* - Content-length calculation for HTTP compliance
* - Standardized error responses
* - Header validation and extraction
*/
const { qerrors } = require('qerrors'); // centralized error logging
const logger = require('./logger'); // structured logger
const { isValidObject } = require('./input-validation'); // input sanity checks
const { sendJsonResponse, sendAuthError, sendServerError } = require('./response-utils'); // unified response helpers
/*
* Headers Removal Configuration
*
* RATIONALE FOR HEADER REMOVAL:
* These headers are stripped from proxied requests because they:
* 1. Contain routing information that shouldn't be forwarded (host, x-target-url)
* 2. Are CDN/proxy-specific and would confuse upstream servers (cf-*, cdn-loop)
* 3. Contain sensitive infrastructure details that shouldn't leak (cf-ray, render-proxy-ttl)
* 4. Are connection-specific and invalid when forwarding (connection)
*
* SECURITY IMPLICATIONS:
* - Prevents header pollution attacks
* - Stops information disclosure about internal infrastructure
* - Ensures clean requests to upstream APIs
*
* MAINTENANCE NOTE:
* Add new headers here if they cause issues with upstream services or leak
* sensitive information about the proxy infrastructure.
*/
// Headers removed in all proxied requests for reuse
// This list blocks headers that could leak infrastructure details or allow injection attacks
const HEADERS_TO_REMOVE = [
'host', // Routing header that confuses upstream servers
'x-target-url', // Our internal routing header
'x-api-key', // May contain sensitive authentication data
'cdn-loop', // CDN-specific header that shouldn't be forwarded
'cf-connecting-ip', // Cloudflare-specific client IP header
'cf-ipcountry', // Cloudflare geolocation header
'cf-ray', // Cloudflare request tracing header (sensitive)
'cf-visitor', // Cloudflare visitor information
'render-proxy-ttl', // Render.com-specific proxy header
'connection' // HTTP/1.1 connection management header
];
Object.freeze(HEADERS_TO_REMOVE); // ensure list can't be mutated at runtime
/**
* Calculate the content length of a body in bytes
*
* RATIONALE: HTTP requires accurate Content-Length headers for proper request/response
* handling. Incorrect content-length can cause:
* - Request truncation or hanging
* - Server timeouts
* - HTTP/1.1 compliance issues
* - Proxy and load balancer problems
*
* IMPLEMENTATION DECISIONS:
* - Use Buffer.byteLength() for accurate UTF-8 byte counting (not character count)
* - Handle various input types (string, object, Buffer, null, empty) // added Buffer for binary support
* - Distinguish between undefined (error) and empty content (zero length)
* - Return string format as HTTP headers must be strings
*
* EDGE CASES HANDLED:
* - Undefined input (throws - indicates developer error)
* - Null input (returns '0' - valid empty body)
* - Empty string (returns '0' - valid empty body)
* - Empty object (returns '0' - valid empty JSON)
* - Buffer input (returns body.length - byte accurate) // new edge case
* - Unicode characters (accurate byte counting)
*
* WHY NOT String.length:
* String.length counts characters, not bytes. UTF-8 characters can be 1-4 bytes,
* so "é" has length 1 but byte length 2. HTTP requires byte count.
*
* @param {*} body - The body to calculate length for (string, object, null, etc.)
* @returns {string} The content length as a string (HTTP headers require string format)
* @throws {TypeError} If body is undefined (indicates coding error that should be fixed)
*/
function calculateContentLength(body) {
console.log(`calculateContentLength is running with ${body}`); logger.debug(`calculateContentLength is running with ${body}`); // Log input for debugging content issues
try {
// Handle undefined input as an error - indicates developer mistake
if (body === undefined) {
throw new TypeError('Body is undefined');
}
// Check for empty object specifically before other object handling
const emptyObj = typeof body === 'object' && body !== null && Object.keys(body).length === 0;
// Handle all valid "empty" body types - these should return '0'
if (body === null || body === '' || emptyObj) {
console.log(`calculateContentLength is returning 0`); logger.debug(`calculateContentLength is returning 0`); // Log zero-length determination
return '0';
}
// Handle string bodies - most common case for simple content
if (typeof body === 'string') {
const len = Buffer.byteLength(body, 'utf8'); // Accurate UTF-8 byte counting
console.log(`calculateContentLength is returning ${len}`); logger.debug(`calculateContentLength is returning ${len}`);
return len.toString();
}
// Handle Buffer bodies - binary payloads
if (Buffer.isBuffer(body)) { // check for Node Buffer
const len = body.length; // Buffer length equals byte size
console.log(`calculateContentLength is returning ${len}`); logger.debug(`calculateContentLength is returning ${len}`); // log for debugging
return len.toString(); // return as string per HTTP spec
}
// Handle object bodies - JSON APIs
if (typeof body === 'object') {
const jsonString = JSON.stringify(body); // Convert to wire format
const len = Buffer.byteLength(jsonString, 'utf8'); // Count bytes of JSON string
console.log(`calculateContentLength is returning ${len}`); logger.debug(`calculateContentLength is returning ${len}`);
return len.toString();
}
// Fallback for unknown types (numbers, booleans, etc.)
// These shouldn't occur in typical HTTP usage but we handle gracefully
console.log(`calculateContentLength is returning 0`); logger.debug(`calculateContentLength is returning 0`);
return '0';
} catch (error) {
// Log error with context for debugging
qerrors(error, 'calculateContentLength', { body });
throw error; // Re-throw so caller can handle invalid input appropriately
}
}
/**
* Build clean headers by removing unwanted headers and managing content-length
*
* PROCESS OVERVIEW:
* 1. Clone headers to avoid mutating the caller's object
* 2. Remove dangerous headers defined in HEADERS_TO_REMOVE for security
* 3. Recalculate content-length from the provided body when present
* 4. Strip content-length entirely for GET requests or empty bodies
*
* HEADERS_TO_REMOVE centralizes this security practice so updates propagate
* consistently across all calls to buildCleanHeaders
*
* RATIONALE: When proxying requests, headers from the original request often contain
* information that shouldn't be forwarded (routing data, CDN headers, etc.) or
* that becomes incorrect after request modification (content-length).
*
* IMPLEMENTATION STRATEGY:
* - Clone headers to avoid mutating original request object
* - Remove all blacklisted headers that could cause issues
* - Recalculate content-length only when necessary (has body)
* - Handle GET requests specially (should never have content-length)
*
* WHY RECALCULATE CONTENT-LENGTH:
* The original content-length might be incorrect if:
* - Request body was modified before proxying (data transformation)
* - Headers were added/removed that affect body serialization
* - Character encoding changed during processing (UTF-8 conversion)
* - Middleware modified the request object before forwarding
*
* GET REQUEST HANDLING:
* GET requests shouldn't have bodies or content-length headers per HTTP/1.1 spec (RFC 7231).
* Some clients send them anyway, so we actively remove them to prevent:
* - Upstream server confusion about message format
* - Proxy servers rejecting the request as malformed
* - HTTP/2 protocol violations that could cause connection drops
*
* @param {object} headers - Original headers object from request
* @param {string} method - HTTP method (GET, POST, PUT, etc.)
* @param {*} body - Request body content
* @returns {object} Cleaned headers object safe for forwarding
*/
function buildCleanHeaders(headers, method, body) {
// Normalize method to lower case or default to 'get' if invalid
let safeMethod = 'get'; // default for undefined or invalid method to avoid unexpected behavior
if (typeof method === 'string' && method.trim()) {
safeMethod = method.toLowerCase(); // enforce consistent method casing for comparisons
}
console.log(`buildCleanHeaders is running with ${safeMethod}`); logger.debug(`buildCleanHeaders is running with ${safeMethod}`); // Log normalized method for debugging header logic
try {
// Validate headers parameter - return original value for null/undefined
if (headers === null) {
return null; // explicit null signals header stripping by caller
}
if (headers === undefined) {
return undefined; // propagate undefined to indicate missing param
}
if (!isValidObject(headers)) {
return {}; // fall back to empty object to prevent runtime errors
}
// Clone headers to avoid mutating original request object
// Spreading creates shallow copy which is sufficient for header objects
const cleanHeaders = { ...headers }; // clone to avoid mutating caller's object
// Remove all blacklisted headers that shouldn't be forwarded
// Using forEach instead of filter to directly modify the object in-place
// This is more memory efficient than creating a new object with filtered properties
HEADERS_TO_REMOVE.forEach(header => {
delete cleanHeaders[header]; // strip potentially dangerous headers
});
// Handle content-length based on method and body presence
// GET or empty-body requests should never include content-length
// Empty bodies include empty objects and zero-length buffers
const emptyObj = typeof body === 'object' && body !== null && !Buffer.isBuffer(body) && Object.keys(body).length === 0; // identify empty object to avoid false content length
const emptyBuf = Buffer.isBuffer(body) && body.length === 0; // empty buffer also means no body content
if (emptyObj || emptyBuf) { delete cleanHeaders['content-length']; } // remove header when payload is empty to avoid misleading size
if (!body || safeMethod === 'get') { delete cleanHeaders['content-length']; } // strip length for GET or when body absent
// For non-GET requests with actual body content, set accurate content-length
// Skip when body is empty object or zero-length buffer
// This prevents setting content-length: 0 for requests that legitimately have no body
if (safeMethod !== 'get' && body && !emptyObj && !emptyBuf) {
// Handle string bodies (most common case)
if (typeof body === 'string' && body.length > 0) {
cleanHeaders['content-length'] = calculateContentLength(body); // set byte-accurate length for string payloads
}
// Handle object bodies (JSON APIs) - check if object has properties
else if (typeof body === 'object' && body !== null && !Buffer.isBuffer(body) && Object.keys(body).length > 0) {
cleanHeaders['content-length'] = calculateContentLength(body); // calculate JSON body length for APIs
}
// Handle Buffer bodies with actual content
else if (Buffer.isBuffer(body) && body.length > 0) {
cleanHeaders['content-length'] = calculateContentLength(body); // compute length for binary payloads
}
}
console.log(`buildCleanHeaders is returning ${JSON.stringify(cleanHeaders)}`); logger.debug(`buildCleanHeaders is returning ${JSON.stringify(cleanHeaders)}`); // trace sanitized header set
return cleanHeaders; // forward cleaned headers to caller
} catch (error) {
// Log error but return original headers as fallback
// This prevents complete request failure due to header cleaning issues
qerrors(error, 'buildCleanHeaders', { method: safeMethod }); // capture context for troubleshooting
return headers; // fallback keeps request usable even when cleaning fails
}
}
// sendJsonResponse is now imported from response-utils module
/**
* Extract and validate required HTTP headers
*
* RATIONALE: Many API operations require specific headers (authorization, content-type,
* custom API keys). This function centralizes the pattern of "get header or return error"
* to avoid repetitive code throughout the application.
*
* IMPLEMENTATION DECISIONS:
* - Use optional chaining (?.) to safely access headers even if undefined
* - Return null to indicate failure (allows caller to handle appropriately)
* - Send error response immediately (fail-fast approach)
* - Use provided error message for custom error descriptions
*
* ERROR HANDLING STRATEGY:
* When a required header is missing, we immediately send an error response
* rather than throwing an exception. This prevents the request from continuing
* with invalid state and provides clear feedback to the client.
*
* WHY OPTIONAL CHAINING:
* In some edge cases, req.headers might be undefined or malformed.
* Optional chaining prevents "Cannot read property of undefined" errors.
*
* @param {object} req - Express request object
* @param {object} res - Express response object
* @param {string} headerName - Name of the required header (case-insensitive)
* @param {number} statusCode - Status code to send if header is missing
* @param {string} errorMessage - Error message to send if header is missing
* @returns {string|null} The header value or null if missing/error
*/
function getRequiredHeader(req, res, headerName, statusCode, errorMessage) {
const normalizedName = typeof headerName === 'string' ? headerName.toLowerCase() : ''; // standardize name for consistent lookup
console.log(`getRequiredHeader is running with ${normalizedName}`); logger.debug(`getRequiredHeader is running with ${normalizedName}`); // log which header we expect for debugging
try {
// Safely access header value even if headers object is undefined
// Express normalizes header names to lowercase, so this handles case variations
const headerValue = req?.headers?.[normalizedName]; // optional chain avoids errors when headers undefined
if (!headerValue) {
// Send appropriate error response based on status code
if (statusCode === 401) {
sendAuthError(res, errorMessage); // missing auth header triggers auth failure
} else {
sendJsonResponse(res, statusCode, { error: errorMessage }); // generic error for other required headers
}
console.log(`getRequiredHeader is returning null due to missing header`); logger.debug(`getRequiredHeader is returning null due to missing header`); // trace failure path
return null; // signal to caller that header was missing
}
console.log(`getRequiredHeader is returning ${headerValue}`); logger.debug(`getRequiredHeader is returning ${headerValue}`); // trace value for debugging (avoid logging secrets in production)
return headerValue; // provide caller with sanitized header value
} catch (error) {
// Handle unexpected errors in header processing
qerrors(error, 'getRequiredHeader', { headerName: normalizedName, statusCode, errorMessage }); // log normalized name for consistency
sendServerError(res, 'Internal server error', error, 'getRequiredHeader'); // generic 500 with logging
return null; // signal failure to calling code
}
}
/*
* Module Export Strategy:
*
* We export both the functions and the HEADERS_TO_REMOVE constant because:
* 1. Functions are the primary interface for this module
* 2. HEADERS_TO_REMOVE might be useful for tests or configuration validation
* 3. Named exports make the API clear and allow selective importing
* 4. Constants help with maintainability and prevent magic strings
*/
module.exports = {
calculateContentLength, // expose body length calculation
buildCleanHeaders, // expose header cleanup
getRequiredHeader, // expose header extraction
HEADERS_TO_REMOVE // expose list of stripped headers
};