diff --git a/index.js b/index.js index 8de00e9..be9f625 100644 --- a/index.js +++ b/index.js @@ -8,5 +8,5 @@ */ export { ProxyHeadersAgent, ConnectError } from './lib/core/proxy-headers-agent.js'; -export { parseProxyUrl, parseTargetUrl, buildConnectRequest } from './lib/core/utils.js'; +export { parseProxyUrl, parseTargetUrl, buildConnectRequest, validateHeaderName, validateHeaderValue } from './lib/core/utils.js'; export { parseConnectResponse, hasCompleteHeaders } from './lib/core/connect-parser.js'; diff --git a/lib/core/utils.js b/lib/core/utils.js index 01e714b..fdadf1e 100644 --- a/lib/core/utils.js +++ b/lib/core/utils.js @@ -2,6 +2,40 @@ * Utility functions for proxy header handling. */ +const INVALID_HEADER_CHAR = /[\r\n\0]/; + +/** + * Validate that a header name does not contain characters that could + * enable CRLF injection in raw HTTP protocol strings. + * @param {string} name - Header name + * @throws {TypeError} If the name contains CR, LF, or NUL + */ +export function validateHeaderName(name) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Header name must be a non-empty string'); + } + if (INVALID_HEADER_CHAR.test(name)) { + throw new TypeError( + `Invalid character in header name: ${JSON.stringify(name.slice(0, 50))}` + ); + } +} + +/** + * Validate that a header value does not contain characters that could + * enable CRLF injection in raw HTTP protocol strings. + * @param {string} value - Header value + * @throws {TypeError} If the value contains CR, LF, or NUL + */ +export function validateHeaderValue(value) { + const str = String(value); + if (INVALID_HEADER_CHAR.test(str)) { + throw new TypeError( + `Invalid character in header value: ${JSON.stringify(str.slice(0, 50))}` + ); + } +} + /** * Parse a proxy URL into components. * @param {string|URL} proxyUrl - The proxy URL @@ -60,6 +94,8 @@ export function buildConnectRequest(targetHost, targetPort, proxyAuth, proxyHead ? [...proxyHeaders.entries()] : Object.entries(proxyHeaders || {}); for (const [key, value] of entries) { + validateHeaderName(key); + validateHeaderValue(value); lines.push(`${key}: ${value}`); } diff --git a/test/test_proxy_headers.js b/test/test_proxy_headers.js index ff6f01c..90324a9 100644 --- a/test/test_proxy_headers.js +++ b/test/test_proxy_headers.js @@ -369,6 +369,14 @@ const AVAILABLE_TESTS = { `Header '${config.proxyHeader}' not found in proxy response`, response.status); } catch (err) { + if (err.response && err.response.proxyHeaders) { + const headerValue = checkHeader(err.response.proxyHeaders, config.proxyHeader); + const sentErr = validateSentHeaderValue(config, headerValue); + if (sentErr) return new TestResult('wretch', false, null, sentErr); + if (headerValue) { + return new TestResult('wretch', true, headerValue, null, err.response.status); + } + } return new TestResult('wretch', false, null, err.message); } }, @@ -423,10 +431,11 @@ const AVAILABLE_TESTS = { }, async 'typed-rest-client'(config) { + let client; try { const { createProxyRestClient } = await import('../lib/typed-rest-client-proxy.js'); - const client = createProxyRestClient({ + client = createProxyRestClient({ userAgent: 'javascript-proxy-headers-test', proxy: config.proxyUrl, proxyHeaders: config.proxyHeadersToSend, @@ -444,6 +453,14 @@ const AVAILABLE_TESTS = { `Header '${config.proxyHeader}' not found in proxy response`, result.statusCode); } catch (err) { + if (client && client.proxyAgent && client.proxyAgent.lastProxyHeaders) { + const headerValue = checkHeader(client.proxyAgent.lastProxyHeaders, config.proxyHeader); + const sentErr = validateSentHeaderValue(config, headerValue); + if (sentErr) return new TestResult('typed-rest-client', false, null, sentErr); + if (headerValue) { + return new TestResult('typed-rest-client', true, headerValue, null, err.statusCode); + } + } return new TestResult('typed-rest-client', false, null, err.message); } }, @@ -542,6 +559,7 @@ async function runTests(testNames, config, verbose) { console.log(); const results = []; + const maxRetries = 4; for (const name of testNames) { const testFn = AVAILABLE_TESTS[name]; @@ -552,7 +570,15 @@ async function runTests(testNames, config, verbose) { } process.stdout.write(`Testing ${name}... `); - const result = await testFn(config); + let result = await testFn(config); + + for (let attempt = 1; !result.success && attempt <= maxRetries; attempt++) { + if (!isTransientError(result)) break; + process.stdout.write(`retry ${attempt}... `); + await sleep(2000 * attempt); + result = await testFn(config); + } + console.log(result.success ? 'OK' : 'FAILED'); results.push(result); } @@ -560,6 +586,23 @@ async function runTests(testNames, config, verbose) { return results; } +function isTransientError(result) { + if (result.success) return false; + const msg = (result.error || '').toLowerCase(); + return msg.includes('503') || + msg.includes('502') || + msg.includes('socket hang up') || + msg.includes('econnreset') || + msg.includes('econnrefused') || + msg.includes('etimedout') || + msg.includes('service temporarily unavailable') || + msg.includes('service unavailable'); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + function printResults(results, verbose) { console.log(); console.log('='.repeat(60)); diff --git a/test/test_proxy_headers.ts b/test/test_proxy_headers.ts index b44f319..56c825e 100644 --- a/test/test_proxy_headers.ts +++ b/test/test_proxy_headers.ts @@ -313,7 +313,13 @@ const AVAILABLE_TESTS: Record = { `Header '${config.proxyHeader}' not found in proxy response`, response.status, ); - } catch (err) { + } catch (err: any) { + if (err.response && err.response.proxyHeaders) { + const headerValue = checkHeader(err.response.proxyHeaders as Map, config.proxyHeader); + const sentErr = validateSentHeaderValue(config, headerValue); + if (sentErr) return new TestResult("wretch", false, null, sentErr); + if (headerValue) return new TestResult("wretch", true, headerValue, null, err.response.status); + } return new TestResult("wretch", false, null, (err as Error).message); } }, @@ -366,9 +372,10 @@ const AVAILABLE_TESTS: Record = { }, async "typed-rest-client"(config) { + let client: any; try { const { createProxyRestClient } = await import("../lib/typed-rest-client-proxy.js"); - const client = createProxyRestClient({ + client = createProxyRestClient({ userAgent: "javascript-proxy-headers-test", proxy: config.proxyUrl!, proxyHeaders: config.proxyHeadersToSend, @@ -385,7 +392,13 @@ const AVAILABLE_TESTS: Record = { `Header '${config.proxyHeader}' not found in proxy response`, result.statusCode, ); - } catch (err) { + } catch (err: any) { + if (client && client.proxyAgent && client.proxyAgent.lastProxyHeaders) { + const headerValue = checkHeader(client.proxyAgent.lastProxyHeaders as Map, config.proxyHeader); + const sentErr = validateSentHeaderValue(config, headerValue); + if (sentErr) return new TestResult("typed-rest-client", false, null, sentErr); + if (headerValue) return new TestResult("typed-rest-client", true, headerValue, null, err.statusCode); + } return new TestResult("typed-rest-client", false, null, (err as Error).message); } }, @@ -452,6 +465,8 @@ async function runTests(testNames: string[], config: TestConfig, verbose: boolea console.log(); const results: TestResult[] = []; + const maxRetries = 4; + for (const name of testNames) { const testFn = AVAILABLE_TESTS[name]; if (!testFn) { @@ -461,13 +476,38 @@ async function runTests(testNames: string[], config: TestConfig, verbose: boolea continue; } process.stdout.write(`Testing ${name}... `); - const result = await testFn(config); + let result = await testFn(config); + + for (let attempt = 1; !result.success && attempt <= maxRetries; attempt++) { + if (!isTransientError(result)) break; + process.stdout.write(`retry ${attempt}... `); + await sleep(2000 * attempt); + result = await testFn(config); + } + console.log(result.success ? "OK" : "FAILED"); results.push(result); } return results; } +function isTransientError(result: TestResult): boolean { + if (result.success) return false; + const msg = (result.error || "").toLowerCase(); + return msg.includes("503") || + msg.includes("502") || + msg.includes("socket hang up") || + msg.includes("econnreset") || + msg.includes("econnrefused") || + msg.includes("etimedout") || + msg.includes("service temporarily unavailable") || + msg.includes("service unavailable"); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + function printResults(results: TestResult[], verbose: boolean) { console.log(); console.log("=".repeat(60));