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
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
36 changes: 36 additions & 0 deletions lib/core/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
}

Expand Down
47 changes: 45 additions & 2 deletions test/test_proxy_headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
},
Expand Down Expand Up @@ -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];
Expand All @@ -552,14 +570,39 @@ 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);
}

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));
Expand Down
48 changes: 44 additions & 4 deletions test/test_proxy_headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,13 @@ const AVAILABLE_TESTS: Record<string, TestFn> = {
`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<string, string>, 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);
}
},
Expand Down Expand Up @@ -366,9 +372,10 @@ const AVAILABLE_TESTS: Record<string, TestFn> = {
},

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,
Expand All @@ -385,7 +392,13 @@ const AVAILABLE_TESTS: Record<string, TestFn> = {
`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<string, string>, 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);
}
},
Expand Down Expand Up @@ -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) {
Expand All @@ -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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

function printResults(results: TestResult[], verbose: boolean) {
console.log();
console.log("=".repeat(60));
Expand Down