@headwall/trusted-network-providers
Version:
Trusted network hosts and address ranges.
282 lines (244 loc) • 8.69 kB
JavaScript
/**
* secure-http-client.js
*
* Centralized HTTP client with security best practices:
* - Strict HTTPS certificate validation
* - Request timeouts
* - Retry logic for transient failures
* - SHA-256 checksum verification
* - Error handling
*/
const superagent = require('superagent');
const https = require('https');
const crypto = require('crypto');
/**
* Configuration for secure HTTP requests
*/
const DEFAULT_CONFIG = {
timeout: 30000, // 30 seconds
retries: 2,
retryDelay: 1000, // 1 second between retries
strictSSL: true, // Enforce certificate validation
};
/**
* HTTPS agent with strict certificate validation
*/
const httpsAgent = new https.Agent({
rejectUnauthorized: true, // Reject invalid certificates
minVersion: 'TLSv1.2', // Minimum TLS version
});
/**
* Calculate SHA-256 hash of data
* @param {string|Buffer} data - Data to hash
* @returns {string} - Hex-encoded SHA-256 hash
*/
function calculateSHA256(data) {
return crypto.createHash('sha256').update(data).digest('hex');
}
/**
* Verify checksum of response data
* @param {string} data - Raw response data
* @param {string} expectedChecksum - Expected SHA-256 hash
* @param {string} url - URL for error messages
* @throws {Error} - If checksum doesn't match
*/
function verifyChecksum(data, expectedChecksum, url) {
const actualChecksum = calculateSHA256(data);
if (actualChecksum !== expectedChecksum) {
throw new Error(
`Checksum verification failed for ${url}. ` + `Expected: ${expectedChecksum}, Got: ${actualChecksum}`
);
}
}
/**
* Fetch JSON data from a URL with security best practices
*
* @param {string} url - The URL to fetch from (must be HTTPS)
* @param {object} options - Optional configuration overrides
* @param {number} options.timeout - Request timeout in milliseconds
* @param {number} options.retries - Number of retry attempts
* @param {boolean} options.strictSSL - Whether to enforce SSL validation
* @param {string} options.expectedChecksum - Optional SHA-256 checksum to verify
* @param {boolean} options.verifyStructure - Callback to verify JSON structure instead of checksum
* @returns {Promise<object>} - The parsed JSON response
* @throws {Error} - If the request fails, URL is not HTTPS, or checksum doesn't match
*/
async function fetchJSON(url, options = {}) {
const config = { ...DEFAULT_CONFIG, ...options };
// Security check: Only allow HTTPS
if (!url.startsWith('https://')) {
throw new Error(`Insecure URL rejected: ${url}. Only HTTPS URLs are allowed.`);
}
let lastError;
for (let attempt = 0; attempt <= config.retries; attempt++) {
try {
const response = await superagent
.get(url)
.agent(httpsAgent)
.accept('json')
.timeout({
response: config.timeout,
deadline: config.timeout + 5000,
})
.retry(0) // We handle retries manually for better control
.buffer(true); // Ensure we can access raw text for checksum
// Validate response
if (!response.body) {
throw new Error('Empty response body received');
}
// Checksum verification if provided
if (config.expectedChecksum) {
verifyChecksum(response.text, config.expectedChecksum, url);
}
// Structure verification if provided (for data that changes frequently)
if (config.verifyStructure && typeof config.verifyStructure === 'function') {
const isValid = config.verifyStructure(response.body);
if (!isValid) {
throw new Error(`Structure verification failed for ${url}`);
}
}
return response.body;
} catch (error) {
lastError = error;
// Don't retry on certain errors
if (error.status === 404 || error.status === 403 || error.status === 401) {
throw new Error(`HTTP ${error.status} error for ${url}: ${error.message}`);
}
// Certificate validation errors should not be retried
if (
error.code === 'CERT_HAS_EXPIRED' ||
error.code === 'CERT_UNTRUSTED' ||
error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT'
) {
throw new Error(`SSL certificate validation failed for ${url}: ${error.message}`);
}
// If this isn't the last attempt, wait before retrying
if (attempt < config.retries) {
await sleep(config.retryDelay * (attempt + 1)); // Exponential backoff
continue;
}
}
}
// All retries exhausted
throw new Error(`Failed to fetch ${url} after ${config.retries + 1} attempts: ${lastError.message}`);
}
/**
* Fetch text data from a URL with security best practices
*
* @param {string} url - The URL to fetch from (must be HTTPS)
* @param {object} options - Optional configuration overrides
* @returns {Promise<string>} - The response text
* @throws {Error} - If the request fails or URL is not HTTPS
*/
async function fetchText(url, options = {}) {
const config = { ...DEFAULT_CONFIG, ...options };
// Security check: Only allow HTTPS
if (!url.startsWith('https://')) {
throw new Error(`Insecure URL rejected: ${url}. Only HTTPS URLs are allowed.`);
}
let lastError;
for (let attempt = 0; attempt <= config.retries; attempt++) {
try {
const response = await superagent
.get(url)
.agent(httpsAgent)
.accept('text/plain')
.timeout({
response: config.timeout,
deadline: config.timeout + 5000,
})
.retry(0);
return response.text;
} catch (error) {
lastError = error;
// Don't retry on certain errors
if (error.status === 404 || error.status === 403 || error.status === 401) {
throw new Error(`HTTP ${error.status} error for ${url}: ${error.message}`);
}
// Certificate validation errors should not be retried
if (
error.code === 'CERT_HAS_EXPIRED' ||
error.code === 'CERT_UNTRUSTED' ||
error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT'
) {
throw new Error(`SSL certificate validation failed for ${url}: ${error.message}`);
}
// If this isn't the last attempt, wait before retrying
if (attempt < config.retries) {
await sleep(config.retryDelay * (attempt + 1));
continue;
}
}
}
// All retries exhausted
throw new Error(`Failed to fetch ${url} after ${config.retries + 1} attempts: ${lastError.message}`);
}
/**
* Fetch XML data from a URL with security best practices
*
* @param {string} url - The URL to fetch from (must be HTTPS)
* @param {object} options - Optional configuration overrides
* @returns {Promise<Buffer>} - The response body as a buffer (for XML parsing)
* @throws {Error} - If the request fails or URL is not HTTPS
*/
async function fetchXML(url, options = {}) {
const config = { ...DEFAULT_CONFIG, ...options };
// Security check: Only allow HTTPS
if (!url.startsWith('https://')) {
throw new Error(`Insecure URL rejected: ${url}. Only HTTPS URLs are allowed.`);
}
let lastError;
for (let attempt = 0; attempt <= config.retries; attempt++) {
try {
const response = await superagent
.get(url)
.agent(httpsAgent)
.accept('xml')
.timeout({
response: config.timeout,
deadline: config.timeout + 5000,
})
.retry(0)
.buffer(true)
.parse(superagent.parse['application/xml']);
return response.body;
} catch (error) {
lastError = error;
// Don't retry on certain errors
if (error.status === 404 || error.status === 403 || error.status === 401) {
throw new Error(`HTTP ${error.status} error for ${url}: ${error.message}`);
}
// Certificate validation errors should not be retried
if (
error.code === 'CERT_HAS_EXPIRED' ||
error.code === 'CERT_UNTRUSTED' ||
error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT'
) {
throw new Error(`SSL certificate validation failed for ${url}: ${error.message}`);
}
// If this isn't the last attempt, wait before retrying
if (attempt < config.retries) {
await sleep(config.retryDelay * (attempt + 1));
continue;
}
}
}
// All retries exhausted
throw new Error(`Failed to fetch ${url} after ${config.retries + 1} attempts: ${lastError.message}`);
}
/**
* Sleep utility for retry delays
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = {
fetchJSON,
fetchText,
fetchXML,
calculateSHA256,
verifyChecksum,
DEFAULT_CONFIG,
};