/**
* Parses and normalizes HTTP requests for ZPT navigation API
*/
export default class RequestParser {
constructor(options = {}) {
this.config = {
maxBodySize: options.maxBodySize || 10 * 1024 * 1024, // 10MB
allowedContentTypes: options.allowedContentTypes || [
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data'
],
strictMode: options.strictMode !== false,
parseQueryParams: options.parseQueryParams !== false,
parseHeaders: options.parseHeaders !== false,
extractClientInfo: options.extractClientInfo !== false,
...options
};
this.initializeValidators();
}
/**
* Initialize request validators
*/
initializeValidators() {
this.validators = {
contentType: this.validateContentType.bind(this),
bodySize: this.validateBodySize.bind(this),
method: this.validateMethod.bind(this),
path: this.validatePath.bind(this),
headers: this.validateHeaders.bind(this)
};
this.supportedMethods = ['GET', 'POST', 'OPTIONS', 'HEAD'];
this.requiredHeaders = ['content-type', 'user-agent'];
}
/**
* Main parsing method - extracts and normalizes request data
* @param {Object} req - HTTP request object
* @returns {Promise<Object>} Parsed request data
*/
async parse(req) {
const startTime = Date.now();
try {
// Extract basic request information
const basicInfo = this.extractBasicInfo(req);
// Validate request structure
await this.validateRequest(req, basicInfo);
// Parse request components
const parsedComponents = await this.parseComponents(req, basicInfo);
// Extract client information
const clientInfo = this.config.extractClientInfo ?
this.extractClientInfo(req) : {};
// Build complete parsed request
const parsedRequest = {
...basicInfo,
...parsedComponents,
...clientInfo,
metadata: {
parseTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
parser: 'RequestParser-1.0.0'
}
};
return parsedRequest;
} catch (error) {
throw new Error(`Request parsing failed: ${error.message}`);
}
}
/**
* Extract basic request information
*/
extractBasicInfo(req) {
return {
method: (req.method || 'GET').toUpperCase(),
path: this.normalizePath(req.url || req.path || '/'),
protocol: req.protocol || (req.connection?.encrypted ? 'https' : 'http'),
httpVersion: req.httpVersion || '1.1',
timestamp: new Date().toISOString()
};
}
/**
* Validate complete request structure
*/
async validateRequest(req, basicInfo) {
const validationResults = [];
// Run all validators
for (const [name, validator] of Object.entries(this.validators)) {
try {
const result = await validator(req, basicInfo);
if (!result.valid) {
validationResults.push({
validator: name,
error: result.error,
critical: result.critical !== false
});
}
} catch (error) {
validationResults.push({
validator: name,
error: error.message,
critical: true
});
}
}
// Check for critical validation failures
const criticalErrors = validationResults.filter(r => r.critical);
if (criticalErrors.length > 0) {
const errorMessage = criticalErrors.map(e => `${e.validator}: ${e.error}`).join('; ');
throw new Error(`Critical validation errors: ${errorMessage}`);
}
// Log non-critical warnings
const warnings = validationResults.filter(r => !r.critical);
if (warnings.length > 0) {
console.warn('Request validation warnings:', warnings);
}
}
/**
* Parse all request components
*/
async parseComponents(req, basicInfo) {
const components = {};
// Parse headers
if (this.config.parseHeaders) {
components.headers = this.parseHeaders(req);
}
// Parse query parameters
if (this.config.parseQueryParams) {
components.query = this.parseQueryParams(basicInfo.path);
}
// Parse request body
if (this.shouldParseBody(basicInfo.method)) {
components.body = await this.parseBody(req, components.headers);
}
// Parse cookies
if (components.headers?.cookie) {
components.cookies = this.parseCookies(components.headers.cookie);
}
return components;
}
/**
* Parse request headers
*/
parseHeaders(req) {
const headers = {};
// Normalize header names to lowercase
for (const [key, value] of Object.entries(req.headers || {})) {
headers[key.toLowerCase()] = value;
}
// Extract special headers
const specialHeaders = {
contentType: headers['content-type'],
contentLength: headers['content-length'] ? parseInt(headers['content-length']) : undefined,
userAgent: headers['user-agent'],
authorization: headers['authorization'],
accept: headers['accept'],
acceptEncoding: headers['accept-encoding'],
acceptLanguage: headers['accept-language'],
origin: headers['origin'],
referer: headers['referer'],
xForwardedFor: headers['x-forwarded-for'],
xRealIp: headers['x-real-ip']
};
return {
raw: headers,
...specialHeaders
};
}
/**
* Parse query parameters from URL
*/
parseQueryParams(path) {
const queryIndex = path.indexOf('?');
if (queryIndex === -1) return {};
const queryString = path.substring(queryIndex + 1);
const params = {};
queryString.split('&').forEach(param => {
const [key, value] = param.split('=').map(decodeURIComponent);
if (key) {
if (params[key]) {
// Handle multiple values for same parameter
if (Array.isArray(params[key])) {
params[key].push(value || '');
} else {
params[key] = [params[key], value || ''];
}
} else {
params[key] = value || '';
}
}
});
return params;
}
/**
* Parse request body based on content type
*/
async parseBody(req, headers) {
const contentType = headers?.contentType || '';
const contentLength = headers?.contentLength || 0;
// Validate body size
if (contentLength > this.config.maxBodySize) {
throw new Error(`Request body too large: ${contentLength} > ${this.config.maxBodySize}`);
}
// Read raw body data
const rawBody = await this.readRawBody(req);
// Parse based on content type
if (contentType.includes('application/json')) {
return this.parseJsonBody(rawBody);
} else if (contentType.includes('application/x-www-form-urlencoded')) {
return this.parseFormBody(rawBody);
} else if (contentType.includes('multipart/form-data')) {
return this.parseMultipartBody(rawBody, contentType);
} else if (contentType.includes('text/')) {
return { text: rawBody.toString('utf8') };
} else {
// Return raw data for unknown content types
return { raw: rawBody };
}
}
/**
* Read raw body data from request stream
*/
async readRawBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let totalSize = 0;
req.on('data', chunk => {
totalSize += chunk.length;
if (totalSize > this.config.maxBodySize) {
reject(new Error(`Request body too large: ${totalSize} > ${this.config.maxBodySize}`));
return;
}
chunks.push(chunk);
});
req.on('end', () => {
resolve(Buffer.concat(chunks));
});
req.on('error', error => {
reject(new Error(`Failed to read request body: ${error.message}`));
});
// Timeout for reading body
const timeout = setTimeout(() => {
reject(new Error('Request body read timeout'));
}, 30000); // 30 seconds
req.on('end', () => clearTimeout(timeout));
req.on('error', () => clearTimeout(timeout));
});
}
/**
* Parse JSON body
*/
parseJsonBody(rawBody) {
try {
const jsonString = rawBody.toString('utf8');
const parsed = JSON.parse(jsonString);
return {
json: parsed,
raw: jsonString,
size: rawBody.length
};
} catch (error) {
throw new Error(`Invalid JSON body: ${error.message}`);
}
}
/**
* Parse form-encoded body
*/
parseFormBody(rawBody) {
try {
const formString = rawBody.toString('utf8');
const params = {};
formString.split('&').forEach(param => {
const [key, value] = param.split('=').map(decodeURIComponent);
if (key) {
params[key] = value || '';
}
});
return {
form: params,
raw: formString,
size: rawBody.length
};
} catch (error) {
throw new Error(`Invalid form body: ${error.message}`);
}
}
/**
* Parse multipart form data (simplified implementation)
*/
parseMultipartBody(rawBody, contentType) {
// Extract boundary from content type
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
if (!boundaryMatch) {
throw new Error('Missing boundary in multipart content type');
}
const boundary = '--' + boundaryMatch[1];
const bodyString = rawBody.toString('utf8');
const parts = bodyString.split(boundary).filter(part => part.trim().length > 0);
const fields = {};
const files = [];
parts.forEach(part => {
if (part.includes('Content-Disposition')) {
const { name, filename, content } = this.parseMultipartPart(part);
if (filename) {
// File upload
files.push({ name, filename, content });
} else {
// Form field
fields[name] = content;
}
}
});
return {
multipart: { fields, files },
raw: bodyString,
size: rawBody.length
};
}
/**
* Parse individual multipart part
*/
parseMultipartPart(part) {
const lines = part.split('\r\n');
let headersParsed = false;
let name, filename;
const contentLines = [];
for (const line of lines) {
if (!headersParsed) {
if (line.trim() === '') {
headersParsed = true;
continue;
}
// Parse Content-Disposition header
if (line.includes('Content-Disposition')) {
const nameMatch = line.match(/name="([^"]+)"/);
const filenameMatch = line.match(/filename="([^"]+)"/);
if (nameMatch) name = nameMatch[1];
if (filenameMatch) filename = filenameMatch[1];
}
} else {
contentLines.push(line);
}
}
return {
name,
filename,
content: contentLines.join('\r\n').trim()
};
}
/**
* Parse cookies from header
*/
parseCookies(cookieHeader) {
const cookies = {};
cookieHeader.split(';').forEach(cookie => {
const [key, value] = cookie.split('=').map(s => s.trim());
if (key && value) {
cookies[key] = decodeURIComponent(value);
}
});
return cookies;
}
/**
* Extract client information
*/
extractClientInfo(req) {
const socket = req.socket || req.connection;
return {
clientIp: this.extractClientIp(req),
userAgent: req.headers?.['user-agent'],
acceptLanguage: req.headers?.['accept-language'],
origin: req.headers?.origin,
referer: req.headers?.referer,
connectionInfo: {
remoteAddress: socket?.remoteAddress,
remotePort: socket?.remotePort,
localAddress: socket?.localAddress,
localPort: socket?.localPort,
encrypted: !!socket?.encrypted
}
};
}
/**
* Extract real client IP address
*/
extractClientIp(req) {
// Check various headers for real IP
const xForwardedFor = req.headers?.['x-forwarded-for'];
const xRealIp = req.headers?.['x-real-ip'];
const cfConnectingIp = req.headers?.['cf-connecting-ip'];
const socketIp = req.socket?.remoteAddress || req.connection?.remoteAddress;
if (xForwardedFor) {
// X-Forwarded-For can contain multiple IPs, take the first one
return xForwardedFor.split(',')[0].trim();
}
return xRealIp || cfConnectingIp || socketIp || 'unknown';
}
/**
* Request validators
*/
validateContentType(req, basicInfo) {
if (basicInfo.method === 'GET' || basicInfo.method === 'HEAD') {
return { valid: true };
}
const contentType = req.headers?.['content-type'];
if (!contentType) {
return {
valid: false,
error: 'Missing Content-Type header',
critical: this.config.strictMode
};
}
const isAllowed = this.config.allowedContentTypes.some(allowed =>
contentType.toLowerCase().includes(allowed.toLowerCase())
);
if (!isAllowed) {
return {
valid: false,
error: `Unsupported Content-Type: ${contentType}`,
critical: this.config.strictMode
};
}
return { valid: true };
}
validateBodySize(req, basicInfo) {
const contentLength = parseInt(req.headers?.['content-length'] || '0');
if (contentLength > this.config.maxBodySize) {
return {
valid: false,
error: `Content-Length exceeds maximum: ${contentLength} > ${this.config.maxBodySize}`,
critical: true
};
}
return { valid: true };
}
validateMethod(req, basicInfo) {
if (!this.supportedMethods.includes(basicInfo.method)) {
return {
valid: false,
error: `Unsupported HTTP method: ${basicInfo.method}`,
critical: true
};
}
return { valid: true };
}
validatePath(req, basicInfo) {
const path = basicInfo.path;
// Basic path validation
if (!path || path.length === 0) {
return {
valid: false,
error: 'Empty request path',
critical: true
};
}
if (path.length > 2048) {
return {
valid: false,
error: 'Request path too long',
critical: true
};
}
// Check for suspicious patterns
const suspiciousPatterns = ['../', '\\', '<script', 'javascript:'];
const hasSuspiciousContent = suspiciousPatterns.some(pattern =>
path.toLowerCase().includes(pattern)
);
if (hasSuspiciousContent) {
return {
valid: false,
error: 'Suspicious content in request path',
critical: true
};
}
return { valid: true };
}
validateHeaders(req, basicInfo) {
const headers = req.headers || {};
const issues = [];
// Check required headers in strict mode
if (this.config.strictMode) {
this.requiredHeaders.forEach(header => {
if (!headers[header] && !headers[header.toLowerCase()]) {
issues.push(`Missing required header: ${header}`);
}
});
}
// Validate header values
if (headers['user-agent'] && headers['user-agent'].length > 512) {
issues.push('User-Agent header too long');
}
if (headers['origin'] && !this.isValidOrigin(headers['origin'])) {
issues.push('Invalid Origin header format');
}
if (issues.length > 0) {
return {
valid: false,
error: issues.join('; '),
critical: this.config.strictMode
};
}
return { valid: true };
}
/**
* Utility methods
*/
normalizePath(path) {
// Remove query string for path normalization
const cleanPath = path.split('?')[0];
// Ensure path starts with /
return cleanPath.startsWith('/') ? cleanPath : '/' + cleanPath;
}
shouldParseBody(method) {
return ['POST', 'PUT', 'PATCH'].includes(method);
}
isValidOrigin(origin) {
try {
new URL(origin);
return true;
} catch {
return false;
}
}
/**
* Get parser configuration and stats
*/
getParserInfo() {
return {
config: this.config,
supportedMethods: this.supportedMethods,
allowedContentTypes: this.config.allowedContentTypes,
validators: Object.keys(this.validators)
};
}
/**
* Update parser configuration
*/
updateConfig(newConfig) {
Object.assign(this.config, newConfig);
}
/**
* Validate parser configuration
*/
validateConfig() {
const issues = [];
if (!this.config.maxBodySize || this.config.maxBodySize <= 0) {
issues.push('Invalid maxBodySize configuration');
}
if (!Array.isArray(this.config.allowedContentTypes) || this.config.allowedContentTypes.length === 0) {
issues.push('Invalid allowedContentTypes configuration');
}
return {
valid: issues.length === 0,
issues
};
}
}