From 759baad77f43bb46fce5e010f362c101d143de57 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 16:46:36 +0000 Subject: [PATCH 1/4] feat!: isolate CONNECT headers per request (v1.0.0) Stop merging proxy CONNECT headers into origin response.headers and store them per connection so concurrent requests cannot mix CONNECT metadata. Adapters expose response.proxyHeaders instead. BREAKING CHANGE: read CONNECT headers from response.proxyHeaders, not response.headers. agent.lastProxyHeaders remains last-write-wins. Co-authored-by: ProxyMesh AI --- .../skills/javascript-proxy-headers/SKILL.md | 4 +- README.md | 31 ++-- docs/axios.md | 25 +-- docs/core-api.md | 26 +++- docs/getting-started.md | 29 ++-- docs/got.md | 11 +- docs/index.md | 3 +- docs/make-fetch-happen.md | 6 +- docs/needle.md | 8 +- docs/superagent.md | 13 +- docs/testing.md | 11 +- docs/typed-rest-client.md | 8 +- index.js | 5 + jsr.json | 2 +- lib/axios-proxy.js | 11 +- lib/core/proxy-headers-agent.js | 5 + lib/core/proxy-headers-store.js | 109 +++++++++++++ lib/got-proxy.js | 12 +- lib/make-fetch-happen-proxy.js | 9 +- lib/needle-proxy.js | 14 +- lib/node-fetch-proxy.js | 11 +- lib/superagent-proxy.js | 12 +- lib/typed-rest-client-proxy.js | 29 ++++ lib/undici-proxy.js | 2 + package.json | 2 +- test/test_connect_security.js | 147 ++++++++++++++++++ test/test_proxy_headers.js | 13 +- test/test_proxy_headers.ts | 13 +- test/types.test.ts | 2 + types/axios.d.ts | 5 + types/got.d.ts | 5 + types/index.d.ts | 16 +- types/needle.d.ts | 8 +- types/superagent.d.ts | 4 +- 34 files changed, 471 insertions(+), 140 deletions(-) create mode 100644 lib/core/proxy-headers-store.js diff --git a/.agents/skills/javascript-proxy-headers/SKILL.md b/.agents/skills/javascript-proxy-headers/SKILL.md index 1a252af..07dcf79 100644 --- a/.agents/skills/javascript-proxy-headers/SKILL.md +++ b/.agents/skills/javascript-proxy-headers/SKILL.md @@ -46,7 +46,7 @@ const client = createProxyAxios({ }); const response = await client.get('https://httpbin.org/ip'); -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ### node-fetch @@ -73,7 +73,7 @@ const client = createProxyGot({ }); const response = await client('https://httpbin.org/ip'); -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ### undici diff --git a/README.md b/README.md index 594c44c..55f1e1b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ Then install the HTTP client(s) you use (for example `axios`, `got`, `ky`, `wret > **Note:** This package has no runtime dependencies by default—install only the adapters you need. +## 1.0 breaking changes + +CONNECT response headers are **not** copied onto origin `response.headers`. Read them from the per-request `proxyHeaders` Map so concurrent requests stay isolated and hop-by-hop CONNECT headers cannot impersonate origin headers (`Set-Cookie`, `Location`, and similar). + +```javascript +// v0.x (removed) +response.headers['x-proxymesh-ip'] + +// v1.x +response.proxyHeaders.get('x-proxymesh-ip') +``` + +`agent.lastProxyHeaders` still exists as a last-write-wins snapshot. Prefer `response.proxyHeaders` (or `getProxyHeaders()`) under concurrency. + ## Quick Start ### axios @@ -54,8 +68,7 @@ const client = createProxyAxios({ const response = await client.get('https://httpbin.org/ip'); -// Proxy headers are merged into response.headers -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ### node-fetch @@ -83,7 +96,7 @@ const client = createProxyGot({ }); const response = await client('https://httpbin.org/ip'); -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ### undici @@ -160,8 +173,7 @@ const res = await proxyNeedleGet('https://httpbin.org/ip', { proxyHeaders: { 'X-ProxyMesh-Country': 'US' } }); -// CONNECT response headers merged onto res.headers where missing -console.log(res.headers['x-proxymesh-ip']); +console.log(res.proxyHeaders.get('x-proxymesh-ip')); ``` ### typed-rest-client @@ -177,8 +189,9 @@ const client = createProxyRestClient({ proxyHeaders: { 'X-ProxyMesh-Country': 'US' } }); -await client.get('https://httpbin.org/ip'); -console.log(client.proxyAgent.lastProxyHeaders?.get('x-proxymesh-ip')); +const response = await client.get('https://httpbin.org/ip'); +console.log(response.result); +console.log(response.proxyHeaders?.get('x-proxymesh-ip')); ``` ### Core Agent (Advanced) @@ -186,7 +199,7 @@ console.log(client.proxyAgent.lastProxyHeaders?.get('x-proxymesh-ip')); For direct control, use the core `ProxyHeadersAgent`: ```javascript -import { ProxyHeadersAgent } from 'javascript-proxy-headers'; +import { ProxyHeadersAgent, getProxyHeaders } from 'javascript-proxy-headers'; import https from 'https'; const agent = new ProxyHeadersAgent('http://proxy.example.com:8080', { @@ -197,7 +210,7 @@ const agent = new ProxyHeadersAgent('http://proxy.example.com:8080', { }); https.get('https://httpbin.org/ip', { agent }, (res) => { - // Handle response + console.log(getProxyHeaders(res)?.get('x-proxymesh-ip')); }); ``` diff --git a/docs/axios.md b/docs/axios.md index b293ef9..fc72057 100644 --- a/docs/axios.md +++ b/docs/axios.md @@ -29,8 +29,8 @@ const response = await client.get('https://httpbin.org/ip'); // Access response data console.log(response.data); -// Access proxy response headers (merged into response.headers) -console.log(response.headers['x-proxymesh-ip']); +// Access proxy CONNECT headers (not merged into origin response.headers) +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ## API Reference @@ -93,29 +93,16 @@ const response = await post('https://httpbin.org/post', { key: 'value' }, { ## Accessing Proxy Headers -Proxy response headers are automatically merged into `response.headers`: +CONNECT response headers are on `response.proxyHeaders` (a `Map`). They are **not** copied onto origin `response.headers`. ```javascript const response = await client.get('https://httpbin.org/ip'); -// Proxy headers are available in response.headers -const proxyIp = response.headers['x-proxymesh-ip']; -const proxyCountry = response.headers['x-proxymesh-country']; +const proxyIp = response.proxyHeaders.get('x-proxymesh-ip'); +const proxyCountry = response.proxyHeaders.get('x-proxymesh-country'); ``` -You can also access the underlying agent to get the last proxy headers: - -```javascript -const client = await createProxyAxios({ - proxy: 'http://proxy:8080', - proxyHeaders: { 'X-ProxyMesh-Country': 'US' } -}); - -await client.get('https://httpbin.org/ip'); - -// Access via the agent -console.log(client.proxyAgent.lastProxyHeaders); -``` +`client.proxyAgent.lastProxyHeaders` is a last-write-wins snapshot of the most recent CONNECT. Use `response.proxyHeaders` for concurrent requests. ## All Request Methods diff --git a/docs/core-api.md b/docs/core-api.md index 160ae2b..a4f55eb 100644 --- a/docs/core-api.md +++ b/docs/core-api.md @@ -32,7 +32,7 @@ new ProxyHeadersAgent(proxy, options) ### Example ```javascript -import { ProxyHeadersAgent } from 'javascript-proxy-headers'; +import { ProxyHeadersAgent, getProxyHeaders } from 'javascript-proxy-headers'; import https from 'https'; const agent = new ProxyHeadersAgent('http://proxy.example.com:8080', { @@ -56,7 +56,7 @@ const req = https.request({ res.on('data', chunk => body += chunk); res.on('end', () => { console.log(body); - console.log('Last proxy headers:', agent.lastProxyHeaders); + console.log('CONNECT headers:', getProxyHeaders(res)); }); }); @@ -72,7 +72,7 @@ req.end(); | `proxyAuth` | `string \| null` | Base64-encoded proxy auth | | `proxyProtocol` | `string` | Proxy URL protocol (`http:` or `https:`) | | `proxyHeaders` | `Object` | Headers to send to proxy | -| `lastProxyHeaders` | `Map \| null` | Headers from last CONNECT response | +| `lastProxyHeaders` | `Map \| null` | Most recent CONNECT headers (last-write-wins under concurrency) | ## ConnectError @@ -184,6 +184,20 @@ const response = parseConnectResponse(buffer); // } ``` +### getProxyHeaders(source) + +Return the CONNECT `Map` attached to a TLS socket, Node `IncomingMessage`, or HTTP client response. Use this instead of `agent.lastProxyHeaders` when requests may overlap. + +```javascript +import { ProxyHeadersAgent, getProxyHeaders } from 'javascript-proxy-headers'; +import https from 'https'; + +const agent = new ProxyHeadersAgent('http://proxy:8080'); +https.get('https://example.com/', { agent }, (res) => { + console.log(getProxyHeaders(res)?.get('x-proxymesh-ip')); +}); +``` + ## Using with Other Libraries The core agent can be used with any library that accepts an `https.Agent`: @@ -205,10 +219,10 @@ import { fetch, setGlobalDispatcher, Agent } from 'undici'; ### With needle -For normal use, prefer the [needle adapter](needle.md) (`proxyNeedleGet` / `createProxyNeedle`), which merges CONNECT headers onto the response. To wire the agent yourself: +For normal use, prefer the [needle adapter](needle.md) (`proxyNeedleGet` / `createProxyNeedle`), which exposes CONNECT headers on `res.proxyHeaders`. To wire the agent yourself: ```javascript -import { ProxyHeadersAgent } from 'javascript-proxy-headers'; +import { ProxyHeadersAgent, getProxyHeaders } from 'javascript-proxy-headers'; import needle from 'needle'; const agent = new ProxyHeadersAgent('http://proxy:8080', { @@ -217,7 +231,7 @@ const agent = new ProxyHeadersAgent('http://proxy:8080', { needle.get('https://httpbin.org/ip', { agent }, (err, resp) => { console.log(resp.body); - console.log(agent.lastProxyHeaders); + console.log(getProxyHeaders(resp)?.get('x-proxymesh-ip')); }); ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index b88ccb6..77f17f1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,6 +28,12 @@ npm install typed-rest-client Use [ky](ky.md) or [wretch](wretch.md) together with `node-fetch` (the adapters build on the same proxy-aware fetch as the node-fetch module). +## 1.0 breaking changes + +CONNECT headers are no longer merged into origin `response.headers`. Use `response.proxyHeaders.get('x-proxymesh-ip')` instead. That keeps CONNECT metadata off the origin response (so `Set-Cookie` / `Location` from the proxy hop cannot impersonate the target) and keeps concurrent requests isolated. + +`agent.lastProxyHeaders` is last-write-wins. Prefer per-response `proxyHeaders` or `getProxyHeaders()`. + ## Quick Examples ### axios @@ -42,7 +48,7 @@ const client = await createProxyAxios({ const response = await client.get('https://httpbin.org/ip'); console.log(response.data); -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ### node-fetch @@ -72,7 +78,7 @@ const client = await createProxyGot({ const response = await client('https://httpbin.org/ip'); console.log(response.body); -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ### undici @@ -105,7 +111,7 @@ const client = await createProxySuperagent({ const response = await client.get('https://httpbin.org/ip'); console.log(response.body); -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ### ky @@ -165,7 +171,7 @@ const res = await proxyNeedleGet('https://httpbin.org/ip', { }); console.log(res.body); -console.log(res.headers['x-proxymesh-ip']); +console.log(res.proxyHeaders.get('x-proxymesh-ip')); ``` ### typed-rest-client @@ -179,8 +185,8 @@ const client = createProxyRestClient({ proxyHeaders: { 'X-ProxyMesh-Country': 'US' }, }); -await client.get('https://httpbin.org/ip'); -console.log(client.proxyAgent.lastProxyHeaders?.get('x-proxymesh-ip')); +const response = await client.get('https://httpbin.org/ip'); +console.log(response.proxyHeaders?.get('x-proxymesh-ip')); ``` ## Understanding Proxy Headers @@ -209,15 +215,16 @@ Proxy response headers from the CONNECT request are captured and made available. | Library | Access Method | |---------|---------------| -| axios | `response.headers['header-name']` (merged) | +| axios | `response.proxyHeaders.get('header-name')` | | node-fetch | `response.proxyHeaders.get('header-name')` | -| got | `response.headers['header-name']` (merged) | +| got | `response.proxyHeaders.get('header-name')` | | undici | `proxyHeaders.get('header-name')` | -| superagent | `response.headers['header-name']` (merged) | +| superagent | `response.proxyHeaders.get('header-name')` | | ky / wretch | `response.proxyHeaders.get('header-name')` on the fetch `Response` | | make-fetch-happen | `response.proxyHeaders.get('header-name')` | -| needle | `res.headers['header-name']` (merged where not already set) | -| typed-rest-client | `client.proxyAgent.lastProxyHeaders.get('header-name')` | +| needle | `res.proxyHeaders.get('header-name')` | +| typed-rest-client | `response.proxyHeaders.get('header-name')` (or `getProxyHeaders(httpResponse.message)`) | +| core `https.Agent` | `getProxyHeaders(incomingMessage)` or `onProxyConnect` | ## Proxy Authentication diff --git a/docs/got.md b/docs/got.md index d933f93..6e39ee3 100644 --- a/docs/got.md +++ b/docs/got.md @@ -27,8 +27,8 @@ const response = await client('https://httpbin.org/ip'); // Access response data console.log(response.body); -// Access proxy response headers (merged into response.headers) -console.log(response.headers['x-proxymesh-ip']); +// Access proxy CONNECT headers (not merged into origin response.headers) +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ## API Reference @@ -89,15 +89,12 @@ const response = await proxyPost('https://httpbin.org/post', { ## Accessing Proxy Headers -Proxy response headers are merged into `response.headers` and also available via `response.proxyHeaders`: +CONNECT response headers are on `response.proxyHeaders` (a `Map`). They are **not** copied onto origin `response.headers`. ```javascript const response = await client('https://httpbin.org/ip'); -// Merged into response.headers -const proxyIp = response.headers['x-proxymesh-ip']; - -// Also available separately +const proxyIp = response.proxyHeaders.get('x-proxymesh-ip'); const proxyHeaders = response.proxyHeaders; // Map ``` diff --git a/docs/index.md b/docs/index.md index 3d95524..19b69bf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,8 +66,7 @@ const client = await createProxyAxios({ const response = await client.get('https://httpbin.org/ip'); -// Proxy headers are merged into response.headers -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` See the [Getting Started](getting-started.md) guide for more examples. diff --git a/docs/make-fetch-happen.md b/docs/make-fetch-happen.md index 512913c..37ddf91 100644 --- a/docs/make-fetch-happen.md +++ b/docs/make-fetch-happen.md @@ -30,7 +30,7 @@ console.log(response.proxyHeaders.get('x-proxymesh-ip')); ### createProxyMakeFetchHappen(options) -Builds `make-fetch-happen` with a `ProxyHeadersAgent`, then wraps the fetch so each response includes `proxyHeaders` from the last CONNECT. +Builds `make-fetch-happen` with a `ProxyHeadersAgent`, then wraps the fetch so each response includes `proxyHeaders` from **that request's** CONNECT. **Parameters:** @@ -44,7 +44,7 @@ Builds `make-fetch-happen` with a `ProxyHeadersAgent`, then wraps the fetch so e **Returns:** A `fetch` function with: - `.defaults(url, opts)` — same pattern as make-fetch-happen, still wrapped with `ProxyResponse` -- `.proxyAgent` — the `ProxyHeadersAgent` instance (for example `fetch.proxyAgent.lastProxyHeaders`) +- `.proxyAgent` — the `ProxyHeadersAgent` instance **Example:** @@ -67,7 +67,7 @@ console.log(res.proxyHeaders.get('x-proxymesh-ip')); ## Accessing Proxy Headers -Use `response.proxyHeaders.get('x-proxymesh-ip')` on the wrapped response, or read `fetch.proxyAgent.lastProxyHeaders` after a request. +Use `response.proxyHeaders.get('x-proxymesh-ip')` on the wrapped response. `fetch.proxyAgent.lastProxyHeaders` is last-write-wins and is not safe under concurrency. ## Synchronous Factory diff --git a/docs/needle.md b/docs/needle.md index afb0390..8bffa44 100644 --- a/docs/needle.md +++ b/docs/needle.md @@ -1,6 +1,6 @@ # needle -[needle](https://github.com/tomas/needle) is a lean HTTP client for Node. This package routes HTTPS through `ProxyHeadersAgent` and merges CONNECT response headers into the needle response where the same keys are not already set. +[needle](https://github.com/tomas/needle) is a lean HTTP client for Node. This package routes HTTPS through `ProxyHeadersAgent` and exposes CONNECT response headers on `res.proxyHeaders`. ## Getting Started @@ -21,9 +21,7 @@ const res = await proxyNeedleGet('https://httpbin.org/ip', { }); console.log(res.body); -// CONNECT headers merged into res.headers when missing -console.log(res.headers['x-proxymesh-ip']); -console.log(res.proxyAgent.lastProxyHeaders); +console.log(res.proxyHeaders.get('x-proxymesh-ip')); ``` ## API Reference @@ -67,7 +65,7 @@ const res = await get('https://httpbin.org/ip'); ## Accessing Proxy Headers -Prefer `res.headers['x-proxymesh-ip']` after merge, or `res.proxyAgent.lastProxyHeaders` for the raw `Map` from the last CONNECT. +Use `res.proxyHeaders.get('x-proxymesh-ip')`. CONNECT headers are not merged into origin `res.headers`. `res.proxyAgent.lastProxyHeaders` is a last-write-wins snapshot of the shared agent. ## Core Agent diff --git a/docs/superagent.md b/docs/superagent.md index 23f64cc..aa36bad 100644 --- a/docs/superagent.md +++ b/docs/superagent.md @@ -26,9 +26,7 @@ const response = await client.get('https://httpbin.org/ip'); // Access response data console.log(response.body); - -// Access proxy response headers (merged into response.headers) -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ## API Reference @@ -91,20 +89,17 @@ const response = await superagent })); console.log(response.body); -console.log(response.headers['x-proxymesh-ip']); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ## Accessing Proxy Headers -Proxy response headers are merged into `response.headers` and also available via `response.proxyHeaders`: +CONNECT response headers are on `response.proxyHeaders` (a `Map`). They are **not** copied onto origin `response.headers`. ```javascript const response = await client.get('https://httpbin.org/ip'); -// Merged into response.headers -const proxyIp = response.headers['x-proxymesh-ip']; - -// Also available separately (Map) +const proxyIp = response.proxyHeaders.get('x-proxymesh-ip'); const proxyHeaders = response.proxyHeaders; ``` diff --git a/docs/testing.md b/docs/testing.md index 70a0934..44aefa9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -190,9 +190,7 @@ const client = await createProxyAxios({ }); ``` -### Check Last Proxy Headers - -After a request, check the agent's `lastProxyHeaders`: +### Check Per-Response Proxy Headers ```javascript const client = await createProxyAxios({ @@ -200,11 +198,12 @@ const client = await createProxyAxios({ proxyHeaders: { 'X-ProxyMesh-Country': 'US' } }); -await client.get('https://httpbin.org/ip'); - -console.log('Last proxy headers:', client.proxyAgent.lastProxyHeaders); +const response = await client.get('https://httpbin.org/ip'); +console.log('CONNECT headers:', response.proxyHeaders); ``` +`client.proxyAgent.lastProxyHeaders` is only a last-write-wins snapshot. Use `response.proxyHeaders` when requests may overlap. + ## Common Issues ### "Header not found in response" diff --git a/docs/typed-rest-client.md b/docs/typed-rest-client.md index 7677650..72d2d57 100644 --- a/docs/typed-rest-client.md +++ b/docs/typed-rest-client.md @@ -24,9 +24,7 @@ const client = createProxyRestClient({ }); const response = await client.get('https://httpbin.org/ip'); -// RestClient API: response has statusCode, result, etc. - -console.log(client.proxyAgent.lastProxyHeaders?.get('x-proxymesh-ip')); +console.log(response.proxyHeaders?.get('x-proxymesh-ip')); ``` ## API Reference @@ -45,7 +43,7 @@ console.log(client.proxyAgent.lastProxyHeaders?.get('x-proxymesh-ip')); | `options.proxyHeaders` | `Object` | CONNECT headers | | `options.onProxyConnect` | `Function` | CONNECT callback | -**Returns:** A `RestClient` instance with an extra `proxyAgent` property (`ProxyHeadersAgent`) for inspecting the last CONNECT response. +**Returns:** A `RestClient` instance with an extra `proxyAgent` property (`ProxyHeadersAgent`). Rest responses include `proxyHeaders` from that request's CONNECT. **Example:** @@ -66,7 +64,7 @@ console.log(client.proxyAgent.lastProxyHeaders); ## Accessing Proxy Headers -Use `client.proxyAgent.lastProxyHeaders` after a request (a `Map`). The typed-rest-client response objects do not merge proxy headers into application response headers the way axios does. +Prefer `response.proxyHeaders.get('header-name')` on the RestClient result. `client.proxyAgent.lastProxyHeaders` is a last-write-wins snapshot and is not safe if the client is used concurrently. CONNECT headers are not merged into origin response headers. ## Factory diff --git a/index.js b/index.js index be9f625..7b83e88 100644 --- a/index.js +++ b/index.js @@ -10,3 +10,8 @@ export { ProxyHeadersAgent, ConnectError } from './lib/core/proxy-headers-agent.js'; export { parseProxyUrl, parseTargetUrl, buildConnectRequest, validateHeaderName, validateHeaderValue } from './lib/core/utils.js'; export { parseConnectResponse, hasCompleteHeaders } from './lib/core/connect-parser.js'; +export { + attachProxyHeaders, + getProxyHeaders, + getProxyHeadersFromSocket, +} from './lib/core/proxy-headers-store.js'; diff --git a/jsr.json b/jsr.json index ddc35a3..5626820 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@proxymesh/javascript-proxy-headers", - "version": "0.2.4", + "version": "1.0.0", "license": "MIT", "exports": "./mod.ts" } diff --git a/lib/axios-proxy.js b/lib/axios-proxy.js index d3fefb3..14a0887 100644 --- a/lib/axios-proxy.js +++ b/lib/axios-proxy.js @@ -6,6 +6,7 @@ */ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; +import { getProxyHeaders } from './core/proxy-headers-store.js'; /** * Create an axios instance with proxy header support. @@ -24,7 +25,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; * }); * * const response = await client.get('https://httpbin.org/ip'); - * console.log(response.headers['x-proxymesh-ip']); + * console.log(response.proxyHeaders.get('x-proxymesh-ip')); */ export async function createProxyAxios(options) { const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, axiosOptions = {} } = options; @@ -50,13 +51,7 @@ export async function createProxyAxios(options) { }); instance.interceptors.response.use((response) => { - if (agent.lastProxyHeaders) { - for (const [key, value] of agent.lastProxyHeaders) { - if (!response.headers[key]) { - response.headers[key] = value; - } - } - } + response.proxyHeaders = getProxyHeaders(response) || new Map(); return response; }); diff --git a/lib/core/proxy-headers-agent.js b/lib/core/proxy-headers-agent.js index 126092b..6a6ddd2 100644 --- a/lib/core/proxy-headers-agent.js +++ b/lib/core/proxy-headers-agent.js @@ -9,6 +9,7 @@ import { Agent } from 'node:https'; import tls from 'node:tls'; import { parseProxyUrl, buildConnectRequest, createProxySocket, proxyReadyEvent } from './utils.js'; import { parseConnectResponse, hasCompleteHeaders, ConnectError } from './connect-parser.js'; +import { attachProxyHeaders } from './proxy-headers-store.js'; export class ProxyHeadersAgent extends Agent { /** @@ -122,6 +123,9 @@ export class ProxyHeadersAgent extends Agent { cleanup(); + attachProxyHeaders(proxySocket, response.headers); + // Last-write-wins snapshot for sequential callers. Concurrent + // requests must read per-socket headers via getProxyHeaders(). this.lastProxyHeaders = response.headers; if (this.onProxyConnect) { @@ -151,6 +155,7 @@ export class ProxyHeadersAgent extends Agent { servername: targetHost, ...this.tlsOptions, }); + attachProxyHeaders(tlsSocket, response.headers); tlsSocket.on('error', (err) => { callback(err); diff --git a/lib/core/proxy-headers-store.js b/lib/core/proxy-headers-store.js new file mode 100644 index 0000000..716b45f --- /dev/null +++ b/lib/core/proxy-headers-store.js @@ -0,0 +1,109 @@ +/** + * Per-connection CONNECT header storage. + * + * Headers are attached to the tunnel/TLS socket so concurrent requests on a + * shared agent cannot overwrite each other. AsyncLocalStorage is a fallback + * for fetch wrappers that do not expose the underlying socket. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +export const kProxyHeaders = Symbol.for('javascript-proxy-headers.proxyHeaders'); + +const proxyHeadersStorage = new AsyncLocalStorage(); + +/** + * Record CONNECT response headers on a socket and any active ALS context. + * @param {import('node:net').Socket|null|undefined} socket + * @param {Map|null|undefined} headers + */ +export function attachProxyHeaders(socket, headers) { + if (headers && socket) { + socket[kProxyHeaders] = headers; + } + const store = proxyHeadersStorage.getStore(); + if (store && headers) { + store.headers = headers; + } +} + +/** + * Walk a socket (and TLS parent chain) for attached CONNECT headers. + * @param {object|null|undefined} socket + * @returns {Map|undefined} + */ +export function getProxyHeadersFromSocket(socket) { + const seen = new Set(); + let current = socket; + while (current && !seen.has(current)) { + seen.add(current); + if (current[kProxyHeaders] instanceof Map) { + return current[kProxyHeaders]; + } + current = current._parent || current.socket; + } + return undefined; +} + +/** + * Resolve CONNECT headers from a socket, IncomingMessage, or HTTP client response. + * @param {object|null|undefined} source + * @returns {Map|undefined} + */ +export function getProxyHeaders(source) { + if (!source) { + const store = proxyHeadersStorage.getStore(); + return store?.headers; + } + if (source instanceof Map) { + return source; + } + if (source[kProxyHeaders] instanceof Map) { + return source[kProxyHeaders]; + } + + const candidates = [ + source, + source.socket, + source.connection, + source.request, + source.request?.socket, + source.request?.connection, + source.request?.res, + source.request?.res?.socket, + source.req, + source.req?.socket, + source.req?.connection, + source.res, + source.res?.socket, + source.message, + source.message?.socket, + ]; + + for (const candidate of candidates) { + const found = getProxyHeadersFromSocket(candidate); + if (found) { + return found; + } + } + + return proxyHeadersStorage.getStore()?.headers; +} + +/** + * Run `fn` with an ALS store so CONNECT headers can be recovered without a socket. + * @template T + * @param {() => T} fn + * @returns {T} + */ +export function runWithProxyHeadersContext(fn) { + return proxyHeadersStorage.run({ headers: null }, fn); +} + +/** + * CONNECT headers captured in the current ALS store, if any. + * @returns {Map|null} + */ +export function takeProxyHeadersContext() { + return proxyHeadersStorage.getStore()?.headers ?? null; +} diff --git a/lib/got-proxy.js b/lib/got-proxy.js index e733d52..4dbf53c 100644 --- a/lib/got-proxy.js +++ b/lib/got-proxy.js @@ -6,6 +6,7 @@ */ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; +import { getProxyHeaders } from './core/proxy-headers-store.js'; /** * Create a got instance with proxy header support. @@ -24,7 +25,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; * }); * * const response = await client('https://httpbin.org/ip'); - * console.log(response.headers['x-proxymesh-ip']); + * console.log(response.proxyHeaders.get('x-proxymesh-ip')); */ export async function createProxyGot(options) { const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, gotOptions = {} } = options; @@ -50,14 +51,7 @@ export async function createProxyGot(options) { hooks: { afterResponse: [ (response) => { - if (agent.lastProxyHeaders) { - response.proxyHeaders = agent.lastProxyHeaders; - for (const [key, value] of agent.lastProxyHeaders) { - if (!response.headers[key]) { - response.headers[key] = value; - } - } - } + response.proxyHeaders = getProxyHeaders(response) || new Map(); return response; }, ...(gotOptions.hooks?.afterResponse || []), diff --git a/lib/make-fetch-happen-proxy.js b/lib/make-fetch-happen-proxy.js index 4fe8bcc..e0b6052 100644 --- a/lib/make-fetch-happen-proxy.js +++ b/lib/make-fetch-happen-proxy.js @@ -7,10 +7,17 @@ import makeFetchHappen from 'make-fetch-happen'; import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; import { ProxyResponse } from './core/proxy-response.js'; +import { runWithProxyHeadersContext, takeProxyHeadersContext } from './core/proxy-headers-store.js'; function wrapFetchWithProxyResponse(fetchImpl, agent) { const wrapped = (url, opts = {}) => - fetchImpl(url, opts).then((res) => new ProxyResponse(res, agent.lastProxyHeaders)); + runWithProxyHeadersContext(async () => { + const res = await fetchImpl(url, opts); + return new ProxyResponse( + res, + takeProxyHeadersContext() || agent.lastProxyHeaders, + ); + }); wrapped.defaults = (defaultUrl, defaultOptions = {}) => { const inner = fetchImpl.defaults(defaultUrl, defaultOptions); diff --git a/lib/needle-proxy.js b/lib/needle-proxy.js index e97f891..ae4d2c9 100644 --- a/lib/needle-proxy.js +++ b/lib/needle-proxy.js @@ -4,18 +4,10 @@ import { createRequire } from 'module'; import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; +import { getProxyHeaders } from './core/proxy-headers-store.js'; const require = createRequire(import.meta.url); -function mergeProxyHeadersIntoResponse(res, map) { - if (!res || !map) return; - for (const [key, value] of map) { - if (res.headers[key] == null) { - res.headers[key] = value; - } - } -} - /** * Perform a GET with proxy headers (promise-based). * @@ -56,7 +48,7 @@ export async function proxyNeedleGet(url, options = {}) { reject(err); return; } - mergeProxyHeadersIntoResponse(res, agent.lastProxyHeaders); + res.proxyHeaders = getProxyHeaders(res) || new Map(); res.proxyAgent = agent; resolve(res); }, @@ -103,7 +95,7 @@ export function createProxyNeedle(options) { reject(err); return; } - mergeProxyHeadersIntoResponse(res, agent.lastProxyHeaders); + res.proxyHeaders = getProxyHeaders(res) || new Map(); res.proxyAgent = agent; resolve(res); }, diff --git a/lib/node-fetch-proxy.js b/lib/node-fetch-proxy.js index 6547e2e..bdce0a3 100644 --- a/lib/node-fetch-proxy.js +++ b/lib/node-fetch-proxy.js @@ -7,6 +7,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; import { ProxyResponse } from './core/proxy-response.js'; +import { runWithProxyHeadersContext, takeProxyHeadersContext } from './core/proxy-headers-store.js'; /** * Fetch with proxy header support. @@ -62,9 +63,13 @@ export async function proxyFetch(url, options = {}) { }; } - const response = await fetch(requestUrl, init); - - return new ProxyResponse(response, agent.lastProxyHeaders); + return runWithProxyHeadersContext(async () => { + const response = await fetch(requestUrl, init); + return new ProxyResponse( + response, + takeProxyHeadersContext() || agent.lastProxyHeaders, + ); + }); } /** diff --git a/lib/superagent-proxy.js b/lib/superagent-proxy.js index 6b58397..b4f6428 100644 --- a/lib/superagent-proxy.js +++ b/lib/superagent-proxy.js @@ -6,6 +6,7 @@ */ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; +import { getProxyHeaders } from './core/proxy-headers-store.js'; /** * Create a superagent plugin for proxy header support. @@ -27,7 +28,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; * proxyHeaders: { 'X-ProxyMesh-Country': 'US' } * })); * - * console.log(response.headers['x-proxymesh-ip']); + * console.log(response.proxyHeaders.get('x-proxymesh-ip')); */ export function proxyPlugin(options) { const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions } = options; @@ -48,13 +49,8 @@ export function proxyPlugin(options) { const originalEnd = request.end.bind(request); request.end = function (callback) { return originalEnd((err, res) => { - if (!err && res && agent.lastProxyHeaders) { - res.proxyHeaders = agent.lastProxyHeaders; - for (const [key, value] of agent.lastProxyHeaders) { - if (!res.headers[key]) { - res.headers[key] = value; - } - } + if (res) { + res.proxyHeaders = getProxyHeaders(res) || new Map(); } if (callback) { callback(err, res); diff --git a/lib/typed-rest-client-proxy.js b/lib/typed-rest-client-proxy.js index 92019d2..941549f 100644 --- a/lib/typed-rest-client-proxy.js +++ b/lib/typed-rest-client-proxy.js @@ -6,6 +6,7 @@ import { createRequire } from 'module'; import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; +import { getProxyHeaders } from './core/proxy-headers-store.js'; const require = createRequire(import.meta.url); @@ -39,6 +40,20 @@ function createProxyHeadersHttpClientClass(HttpClient) { } return super._getAgent(parsedUrl); } + + request(verb, requestUrl, data, headers) { + const result = super.request(verb, requestUrl, data, headers); + const attach = (res) => { + if (res) { + res.proxyHeaders = getProxyHeaders(res.message) || new Map(); + } + return res; + }; + if (result && typeof result.then === 'function') { + return result.then(attach); + } + return attach(result); + } }; } @@ -67,6 +82,20 @@ function createProxyHeadersRestClientClass(RestClient, ProxyHeadersHttpClient) { ); this.proxyAgent = this.client.proxyAgent; } + + _processResponse(res, options) { + const processed = super._processResponse(res, options); + const attach = (out) => { + if (out) { + out.proxyHeaders = res?.proxyHeaders || getProxyHeaders(res?.message) || new Map(); + } + return out; + }; + if (processed && typeof processed.then === 'function') { + return processed.then(attach); + } + return attach(processed); + } }; } diff --git a/lib/undici-proxy.js b/lib/undici-proxy.js index d48eca5..45434ef 100644 --- a/lib/undici-proxy.js +++ b/lib/undici-proxy.js @@ -7,6 +7,7 @@ import { parseProxyUrl, buildConnectRequest, createProxySocket, proxyReadyEvent } from './core/utils.js'; import { parseConnectResponse, hasCompleteHeaders, ConnectError } from './core/connect-parser.js'; +import { attachProxyHeaders } from './core/proxy-headers-store.js'; /** * Create a tunnel through the proxy with custom headers. @@ -86,6 +87,7 @@ async function createProxyTunnel(options) { } cleanup(); + attachProxyHeaders(socket, response.headers); if (response.statusCode !== 200) { socket.destroy(); diff --git a/package.json b/package.json index 4c68b17..22d7156 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-proxy-headers", - "version": "0.2.4", + "version": "1.0.0", "description": "Extensions for JavaScript HTTP libraries to support sending and receiving custom proxy headers during HTTPS CONNECT tunneling", "type": "module", "main": "index.js", diff --git a/test/test_connect_security.js b/test/test_connect_security.js index 4293232..7192578 100644 --- a/test/test_connect_security.js +++ b/test/test_connect_security.js @@ -20,6 +20,8 @@ import { proxyReadyEvent, } from '../lib/core/utils.js'; import { ProxyHeadersAgent } from '../lib/core/proxy-headers-agent.js'; +import { createProxyAxios } from '../lib/axios-proxy.js'; +import { getProxyHeaders } from '../lib/core/proxy-headers-store.js'; test('buildConnectRequest rejects CRLF in target host', () => { assert.throws( @@ -233,3 +235,148 @@ function makeSelfSignedCert() { cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } + +function createMitmConnectProxy({ cert, key, headerFactory, delayMs = 0 }) { + let connectCount = 0; + const server = net.createServer((sock) => { + let buf = Buffer.alloc(0); + let handedOff = false; + sock.on('data', (d) => { + if (handedOff) return; + buf = Buffer.concat([buf, d]); + if (buf.indexOf('\r\n\r\n') === -1) return; + handedOff = true; + connectCount += 1; + const id = connectCount; + sock.write( + 'HTTP/1.1 200 Connection Established\r\n' + + headerFactory({ id }) + + '\r\n', + ); + const tlsSock = new tls.TLSSocket(sock, { isServer: true, cert, key }); + tlsSock.on('secure', async () => { + if (delayMs) await delay(delayMs); + let httpBuf = Buffer.alloc(0); + const tryRespond = () => { + if (!httpBuf.includes('\r\n\r\n')) return; + const reqLine = httpBuf.toString('utf8').split('\r\n')[0]; + const body = JSON.stringify({ ok: true, id, reqLine }); + tlsSock.write( + 'HTTP/1.1 200 OK\r\n' + + 'Content-Type: application/json\r\n' + + `Content-Length: ${Buffer.byteLength(body)}\r\n` + + 'Connection: close\r\n' + + '\r\n' + + body, + ); + tlsSock.end(); + }; + tlsSock.on('data', (chunk) => { + httpBuf = Buffer.concat([httpBuf, chunk]); + tryRespond(); + }); + }); + }); + sock.on('error', () => {}); + }); + return server; +} + +test('axios does not merge CONNECT Set-Cookie into origin response.headers', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const proxy = createMitmConnectProxy({ + cert, + key, + headerFactory: () => + 'X-ProxyMesh-IP: 203.0.113.9\r\nSet-Cookie: session=attacker-injected\r\nLocation: https://evil.example/\r\n', + }); + await listen(proxy); + try { + const { port } = proxy.address(); + const client = await createProxyAxios({ + proxy: `http://127.0.0.1:${port}`, + }); + client.proxyAgent.tlsOptions = { rejectUnauthorized: false }; + const response = await client.get('https://127.0.0.1/'); + assert.equal(response.proxyHeaders.get('x-proxymesh-ip'), '203.0.113.9'); + assert.equal(response.proxyHeaders.get('set-cookie'), 'session=attacker-injected'); + assert.equal(response.headers['set-cookie'], undefined); + assert.equal(response.headers.location, undefined); + assert.match(String(response.headers['content-type']), /application\/json/); + } finally { + proxy.close(); + cleanup(); + } +}); + +test('concurrent axios requests keep per-response CONNECT headers', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const proxy = createMitmConnectProxy({ + cert, + key, + delayMs: 200, + headerFactory: ({ id }) => `X-ProxyMesh-IP: 198.51.100.${id}\r\nX-Race-Id: ${id}\r\n`, + }); + await listen(proxy); + try { + const { port } = proxy.address(); + const client = await createProxyAxios({ + proxy: `http://127.0.0.1:${port}`, + }); + client.proxyAgent.tlsOptions = { rejectUnauthorized: false }; + client.proxyAgent.maxSockets = 10; + const results = await Promise.all( + ['/a', '/b', '/c', '/d'].map(async (path) => { + const response = await client.get(`https://127.0.0.1${path}`); + return { + body: response.data, + raceId: response.proxyHeaders.get('x-race-id'), + ip: response.proxyHeaders.get('x-proxymesh-ip'), + }; + }), + ); + for (const row of results) { + assert.equal(row.ip, `198.51.100.${row.body.id}`); + assert.equal(row.raceId, String(row.body.id)); + } + } finally { + proxy.close(); + cleanup(); + } +}); + +test('getProxyHeaders reads CONNECT headers from the TLS socket', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const proxy = createMitmConnectProxy({ + cert, + key, + headerFactory: () => 'X-ProxyMesh-IP: 192.0.2.8\r\n', + }); + await listen(proxy); + try { + const { port } = proxy.address(); + const agent = new ProxyHeadersAgent(`http://127.0.0.1:${port}`, { + tlsOptions: { rejectUnauthorized: false }, + }); + const incoming = await new Promise((resolve, reject) => { + const req = https.request({ + hostname: '127.0.0.1', + port: 443, + path: '/', + method: 'GET', + agent, + rejectUnauthorized: false, + }, (res) => { + res.resume(); + res.on('end', () => resolve(res)); + }); + req.on('error', reject); + req.end(); + }); + const headers = getProxyHeaders(incoming); + assert.equal(headers.get('x-proxymesh-ip'), '192.0.2.8'); + } finally { + proxy.close(); + cleanup(); + } +}); diff --git a/test/test_proxy_headers.js b/test/test_proxy_headers.js index 90324a9..8c67bad 100644 --- a/test/test_proxy_headers.js +++ b/test/test_proxy_headers.js @@ -209,7 +209,7 @@ const AVAILABLE_TESTS = { const response = await client.get(config.testUrl, { validateStatus: () => true, }); - const headerValue = checkHeader(response.headers, config.proxyHeader); + const headerValue = checkHeader(response.proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult('axios', false, null, sentErr); @@ -259,7 +259,7 @@ const AVAILABLE_TESTS = { }); const response = await client(config.testUrl); - const headerValue = checkHeader(response.headers, config.proxyHeader); + const headerValue = checkHeader(response.proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult('got', false, null, sentErr); @@ -308,7 +308,7 @@ const AVAILABLE_TESTS = { }); const response = await client.get(config.testUrl).ok(() => true); - const headerValue = checkHeader(response.headers, config.proxyHeader); + const headerValue = checkHeader(response.proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult('superagent', false, null, sentErr); @@ -415,7 +415,7 @@ const AVAILABLE_TESTS = { proxyHeaders: config.proxyHeadersToSend, }); - const headerValue = checkHeader(res.headers, config.proxyHeader); + const headerValue = checkHeader(res.proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult('needle', false, null, sentErr); @@ -442,7 +442,10 @@ const AVAILABLE_TESTS = { }); const result = await client.get(config.testUrl); - const headerValue = checkHeader(client.proxyAgent.lastProxyHeaders, config.proxyHeader); + const headerValue = checkHeader( + result.proxyHeaders || client.proxyAgent.lastProxyHeaders, + config.proxyHeader, + ); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult('typed-rest-client', false, null, sentErr); diff --git a/test/test_proxy_headers.ts b/test/test_proxy_headers.ts index 56c825e..b437f00 100644 --- a/test/test_proxy_headers.ts +++ b/test/test_proxy_headers.ts @@ -164,7 +164,7 @@ const AVAILABLE_TESTS: Record = { }); const response = await client.get(config.testUrl, { validateStatus: () => true }); - const headerValue = checkHeader(response.headers as Record, config.proxyHeader); + const headerValue = checkHeader((response as { proxyHeaders?: Map }).proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult("axios", false, null, sentErr); @@ -209,7 +209,7 @@ const AVAILABLE_TESTS: Record = { }); const response = await client(config.testUrl); - const headerValue = checkHeader(response.headers as Record, config.proxyHeader); + const headerValue = checkHeader((response as { proxyHeaders?: Map }).proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult("got", false, null, sentErr); @@ -253,7 +253,7 @@ const AVAILABLE_TESTS: Record = { }); const response = await client.get(config.testUrl).ok(() => true); - const headerValue = checkHeader(response.headers as Record, config.proxyHeader); + const headerValue = checkHeader((response as { proxyHeaders?: Map }).proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult("superagent", false, null, sentErr); @@ -355,7 +355,7 @@ const AVAILABLE_TESTS: Record = { proxy: config.proxyUrl!, proxyHeaders: config.proxyHeadersToSend, }); - const headerValue = checkHeader(res.headers as Record, config.proxyHeader); + const headerValue = checkHeader((res as { proxyHeaders?: Map }).proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult("needle", false, null, sentErr); if (headerValue) return new TestResult("needle", true, headerValue, null, res.statusCode); @@ -381,7 +381,10 @@ const AVAILABLE_TESTS: Record = { proxyHeaders: config.proxyHeadersToSend, }); const result = await client.get(config.testUrl); - const headerValue = checkHeader(client.proxyAgent.lastProxyHeaders as Map, config.proxyHeader); + const headerValue = checkHeader( + (result.proxyHeaders || 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, result.statusCode); diff --git a/test/types.test.ts b/test/types.test.ts index 5d53e81..33c624c 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -3,6 +3,7 @@ import { parseProxyUrl, parseTargetUrl, buildConnectRequest, + getProxyHeaders, } from "javascript-proxy-headers"; import { createProxyAxios, type ProxyAxiosInstance, type CreateProxyAxiosOptions } from "javascript-proxy-headers/axios"; import { createProxyFetch, type ProxyResponse } from "javascript-proxy-headers/node-fetch"; @@ -39,6 +40,7 @@ async function typecheck() { const axiosPromise: Promise = createProxyAxios(axiosOptions); const axiosInstance = await axiosPromise; axiosInstance.proxyAgent.lastProxyHeaders; + void getProxyHeaders; const proxyFetch = createProxyFetch({ proxy: "http://proxy.example.com:8080", diff --git a/types/axios.d.ts b/types/axios.d.ts index e822550..bc6a163 100644 --- a/types/axios.d.ts +++ b/types/axios.d.ts @@ -14,6 +14,11 @@ export interface CreateProxyAxiosOptions { axiosOptions?: object; } +export interface AxiosResponseWithProxyHeaders extends AxiosResponse { + /** CONNECT response headers for this request (not merged into origin headers) */ + proxyHeaders: Map; +} + export interface ProxyAxiosInstance extends AxiosInstance { proxyAgent: ProxyHeadersAgent; } diff --git a/types/got.d.ts b/types/got.d.ts index 59fd6ef..7110f99 100644 --- a/types/got.d.ts +++ b/types/got.d.ts @@ -14,6 +14,11 @@ export interface CreateProxyGotOptions { gotOptions?: object; } +export interface GotResponseWithProxyHeaders extends GotResponse { + /** CONNECT response headers for this request (not merged into origin headers) */ + proxyHeaders: Map; +} + export interface ProxyGotInstance extends Got { proxyAgent: ProxyHeadersAgent; } diff --git a/types/index.d.ts b/types/index.d.ts index 4ac0bd0..26d15c3 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -36,7 +36,11 @@ export class ProxyHeadersAgent extends Agent { readonly proxyTlsOptions: object; /** Headers to send to proxy */ readonly proxyHeaders: Record; - /** Headers from last CONNECT response */ + /** + * Headers from the most recently completed CONNECT. + * Last-write-wins if the agent is used concurrently — use + * `getProxyHeaders()` or adapter `response.proxyHeaders` instead. + */ lastProxyHeaders: Map | null; } @@ -87,3 +91,13 @@ export function buildConnectRequest( ): string; export function parseConnectResponse(data: Buffer | string): ConnectResponse | null; export function hasCompleteHeaders(buffer: Buffer): boolean; +export function attachProxyHeaders( + socket: object | null | undefined, + headers: Map | null | undefined, +): void; +export function getProxyHeadersFromSocket( + socket: object | null | undefined, +): Map | undefined; +export function getProxyHeaders( + source?: object | null, +): Map | undefined; diff --git a/types/needle.d.ts b/types/needle.d.ts index 2df484e..ff77fd0 100644 --- a/types/needle.d.ts +++ b/types/needle.d.ts @@ -6,7 +6,13 @@ export interface ProxyNeedleOptions { needleOptions?: Record; } -export function proxyNeedleGet(url: string, options: ProxyNeedleOptions): Promise; +export interface NeedleResponseWithProxyHeaders { + proxyHeaders: Map; + proxyAgent: import('./index.js').ProxyHeadersAgent; + [key: string]: unknown; +} + +export function proxyNeedleGet(url: string, options: ProxyNeedleOptions): Promise; export interface CreateProxyNeedleOptions { proxy: string; diff --git a/types/superagent.d.ts b/types/superagent.d.ts index d4c9ed4..7211500 100644 --- a/types/superagent.d.ts +++ b/types/superagent.d.ts @@ -13,8 +13,8 @@ export interface ProxyPluginOptions { } export interface ProxyResponse extends Response { - /** Headers from proxy CONNECT response */ - proxyHeaders?: Map; + /** CONNECT response headers for this request (not merged into origin headers) */ + proxyHeaders: Map; } export function proxyPlugin( From ea6c831b05742f3aa8f82206ff5d6fbad71f7c7a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 16:52:23 +0000 Subject: [PATCH 2/4] chore(release): set version to 0.3.0 Keep the CONNECT-header isolation as a 0.x breaking change instead of 1.0.0. Co-authored-by: ProxyMesh AI --- README.md | 6 +++--- docs/getting-started.md | 2 +- jsr.json | 2 +- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 55f1e1b..8f062c6 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,15 @@ Then install the HTTP client(s) you use (for example `axios`, `got`, `ky`, `wret > **Note:** This package has no runtime dependencies by default—install only the adapters you need. -## 1.0 breaking changes +## 0.3.0 breaking changes CONNECT response headers are **not** copied onto origin `response.headers`. Read them from the per-request `proxyHeaders` Map so concurrent requests stay isolated and hop-by-hop CONNECT headers cannot impersonate origin headers (`Set-Cookie`, `Location`, and similar). ```javascript -// v0.x (removed) +// ≤0.2.x (removed) response.headers['x-proxymesh-ip'] -// v1.x +// 0.3.x response.proxyHeaders.get('x-proxymesh-ip') ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 77f17f1..c7cea8b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,7 +28,7 @@ npm install typed-rest-client Use [ky](ky.md) or [wretch](wretch.md) together with `node-fetch` (the adapters build on the same proxy-aware fetch as the node-fetch module). -## 1.0 breaking changes +## 0.3.0 breaking changes CONNECT headers are no longer merged into origin `response.headers`. Use `response.proxyHeaders.get('x-proxymesh-ip')` instead. That keeps CONNECT metadata off the origin response (so `Set-Cookie` / `Location` from the proxy hop cannot impersonate the target) and keeps concurrent requests isolated. diff --git a/jsr.json b/jsr.json index 5626820..2f61b7d 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@proxymesh/javascript-proxy-headers", - "version": "1.0.0", + "version": "0.3.0", "license": "MIT", "exports": "./mod.ts" } diff --git a/package.json b/package.json index 22d7156..f379b4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-proxy-headers", - "version": "1.0.0", + "version": "0.3.0", "description": "Extensions for JavaScript HTTP libraries to support sending and receiving custom proxy headers during HTTPS CONNECT tunneling", "type": "module", "main": "index.js", From 21047c2a99bada4206a1227b41ebe41b796c6eb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 17:39:22 +0000 Subject: [PATCH 3/4] fix: isolate RestClient, keep-alive fetch, and axios error CONNECT headers Override typed-rest-client processResponse so RestClient results get per-request proxyHeaders. Sync ALS from reused sockets so make-fetch-happen keep-alive does not fall back to lastProxyHeaders. Attach proxyHeaders on axios error.response for non-2xx. Co-authored-by: ProxyMesh AI --- docs/axios.md | 2 +- docs/typed-rest-client.md | 4 +- lib/axios-proxy.js | 16 +++- lib/core/proxy-headers-agent.js | 24 +++++- lib/core/proxy-headers-store.js | 2 + lib/make-fetch-happen-proxy.js | 4 +- lib/node-fetch-proxy.js | 4 +- lib/typed-rest-client-proxy.js | 44 +++++++--- test/test_connect_security.js | 139 ++++++++++++++++++++++++++++++-- test/test_proxy_headers.js | 6 +- test/test_proxy_headers.ts | 6 +- 11 files changed, 216 insertions(+), 35 deletions(-) diff --git a/docs/axios.md b/docs/axios.md index fc72057..f40ddfd 100644 --- a/docs/axios.md +++ b/docs/axios.md @@ -102,7 +102,7 @@ const proxyIp = response.proxyHeaders.get('x-proxymesh-ip'); const proxyCountry = response.proxyHeaders.get('x-proxymesh-country'); ``` -`client.proxyAgent.lastProxyHeaders` is a last-write-wins snapshot of the most recent CONNECT. Use `response.proxyHeaders` for concurrent requests. +`client.proxyAgent.lastProxyHeaders` is a last-write-wins snapshot of the most recent CONNECT. Use `response.proxyHeaders` for concurrent requests. Non-2xx responses attach the same Map on `error.response.proxyHeaders`. ## All Request Methods diff --git a/docs/typed-rest-client.md b/docs/typed-rest-client.md index 72d2d57..f7cd3a9 100644 --- a/docs/typed-rest-client.md +++ b/docs/typed-rest-client.md @@ -58,8 +58,8 @@ const client = createProxyRestClient({ onProxyConnect: (h) => console.log(h.get('x-proxymesh-ip')), }); -await client.get('/v1/resource'); -console.log(client.proxyAgent.lastProxyHeaders); +const response = await client.get('/v1/resource'); +console.log(response.proxyHeaders.get('x-proxymesh-ip')); ``` ## Accessing Proxy Headers diff --git a/lib/axios-proxy.js b/lib/axios-proxy.js index 14a0887..f73a7f8 100644 --- a/lib/axios-proxy.js +++ b/lib/axios-proxy.js @@ -50,10 +50,18 @@ export async function createProxyAxios(options) { proxy: false, }); - instance.interceptors.response.use((response) => { - response.proxyHeaders = getProxyHeaders(response) || new Map(); - return response; - }); + instance.interceptors.response.use( + (response) => { + response.proxyHeaders = getProxyHeaders(response) || new Map(); + return response; + }, + (error) => { + if (error.response) { + error.response.proxyHeaders = getProxyHeaders(error.response) || new Map(); + } + return Promise.reject(error); + }, + ); instance.proxyAgent = agent; diff --git a/lib/core/proxy-headers-agent.js b/lib/core/proxy-headers-agent.js index 6a6ddd2..1bbad55 100644 --- a/lib/core/proxy-headers-agent.js +++ b/lib/core/proxy-headers-agent.js @@ -9,7 +9,7 @@ import { Agent } from 'node:https'; import tls from 'node:tls'; import { parseProxyUrl, buildConnectRequest, createProxySocket, proxyReadyEvent } from './utils.js'; import { parseConnectResponse, hasCompleteHeaders, ConnectError } from './connect-parser.js'; -import { attachProxyHeaders } from './proxy-headers-store.js'; +import { attachProxyHeaders, getProxyHeadersFromSocket } from './proxy-headers-store.js'; export class ProxyHeadersAgent extends Agent { /** @@ -41,6 +41,28 @@ export class ProxyHeadersAgent extends Agent { this.lastProxyHeaders = null; } + /** + * When a keep-alive socket is reused there is no new CONNECT, so ALS would + * stay empty. Copy per-socket CONNECT headers into the active ALS store. + * @param {import('node:http').ClientRequest} req + */ + addRequest(req, ...args) { + const syncAlsFromSocket = (socket) => { + const headers = getProxyHeadersFromSocket(socket); + if (headers) { + attachProxyHeaders(socket, headers); + } + }; + if (req && typeof req.prependListener === 'function') { + req.prependListener('socket', syncAlsFromSocket); + } + const result = super.addRequest(req, ...args); + if (req?.socket) { + syncAlsFromSocket(req.socket); + } + return result; + } + /** * Create a connection through the proxy. * @param {Object} options - Connection options diff --git a/lib/core/proxy-headers-store.js b/lib/core/proxy-headers-store.js index 716b45f..c4de05d 100644 --- a/lib/core/proxy-headers-store.js +++ b/lib/core/proxy-headers-store.js @@ -66,6 +66,8 @@ export function getProxyHeaders(source) { source, source.socket, source.connection, + source.body, + source.body?.socket, source.request, source.request?.socket, source.request?.connection, diff --git a/lib/make-fetch-happen-proxy.js b/lib/make-fetch-happen-proxy.js index e0b6052..86aabd6 100644 --- a/lib/make-fetch-happen-proxy.js +++ b/lib/make-fetch-happen-proxy.js @@ -7,7 +7,7 @@ import makeFetchHappen from 'make-fetch-happen'; import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; import { ProxyResponse } from './core/proxy-response.js'; -import { runWithProxyHeadersContext, takeProxyHeadersContext } from './core/proxy-headers-store.js'; +import { getProxyHeaders, runWithProxyHeadersContext, takeProxyHeadersContext } from './core/proxy-headers-store.js'; function wrapFetchWithProxyResponse(fetchImpl, agent) { const wrapped = (url, opts = {}) => @@ -15,7 +15,7 @@ function wrapFetchWithProxyResponse(fetchImpl, agent) { const res = await fetchImpl(url, opts); return new ProxyResponse( res, - takeProxyHeadersContext() || agent.lastProxyHeaders, + getProxyHeaders(res) || takeProxyHeadersContext() || agent.lastProxyHeaders, ); }); diff --git a/lib/node-fetch-proxy.js b/lib/node-fetch-proxy.js index bdce0a3..4541ae0 100644 --- a/lib/node-fetch-proxy.js +++ b/lib/node-fetch-proxy.js @@ -7,7 +7,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; import { ProxyResponse } from './core/proxy-response.js'; -import { runWithProxyHeadersContext, takeProxyHeadersContext } from './core/proxy-headers-store.js'; +import { getProxyHeaders, runWithProxyHeadersContext, takeProxyHeadersContext } from './core/proxy-headers-store.js'; /** * Fetch with proxy header support. @@ -67,7 +67,7 @@ export async function proxyFetch(url, options = {}) { const response = await fetch(requestUrl, init); return new ProxyResponse( response, - takeProxyHeadersContext() || agent.lastProxyHeaders, + getProxyHeaders(response) || takeProxyHeadersContext() || agent.lastProxyHeaders, ); }); } diff --git a/lib/typed-rest-client-proxy.js b/lib/typed-rest-client-proxy.js index 941549f..0534862 100644 --- a/lib/typed-rest-client-proxy.js +++ b/lib/typed-rest-client-proxy.js @@ -10,6 +10,32 @@ import { getProxyHeaders } from './core/proxy-headers-store.js'; const require = createRequire(import.meta.url); +/** + * Copy CONNECT headers onto a RestClient result (or rejected error). + * typed-rest-client v2/v3 call `processResponse`, which builds a fresh object. + * @param {Promise|object} processed + * @param {object} res HttpClientResponse + */ +function attachRestProxyHeaders(processed, res) { + const headers = res?.proxyHeaders || getProxyHeaders(res?.message) || new Map(); + const attach = (out) => { + if (out) { + out.proxyHeaders = headers; + } + return out; + }; + const attachErr = (err) => { + if (err && typeof err === 'object') { + err.proxyHeaders = headers; + } + return Promise.reject(err); + }; + if (processed && typeof processed.then === 'function') { + return processed.then(attach, attachErr); + } + return attach(processed); +} + /** * @param {import('typed-rest-client/HttpClient').HttpClient} Base */ @@ -83,18 +109,16 @@ function createProxyHeadersRestClientClass(RestClient, ProxyHeadersHttpClient) { this.proxyAgent = this.client.proxyAgent; } + processResponse(res, options) { + return attachRestProxyHeaders(super.processResponse(res, options), res); + } + + // typed-rest-client v1 used _processResponse; keep a shim if present. _processResponse(res, options) { - const processed = super._processResponse(res, options); - const attach = (out) => { - if (out) { - out.proxyHeaders = res?.proxyHeaders || getProxyHeaders(res?.message) || new Map(); - } - return out; - }; - if (processed && typeof processed.then === 'function') { - return processed.then(attach); + if (typeof super._processResponse === 'function') { + return attachRestProxyHeaders(super._processResponse(res, options), res); } - return attach(processed); + return this.processResponse(res, options); } }; } diff --git a/test/test_connect_security.js b/test/test_connect_security.js index 7192578..bd3b659 100644 --- a/test/test_connect_security.js +++ b/test/test_connect_security.js @@ -21,6 +21,8 @@ import { } from '../lib/core/utils.js'; import { ProxyHeadersAgent } from '../lib/core/proxy-headers-agent.js'; import { createProxyAxios } from '../lib/axios-proxy.js'; +import { createProxyMakeFetchHappen } from '../lib/make-fetch-happen-proxy.js'; +import { createProxyRestClient } from '../lib/typed-rest-client-proxy.js'; import { getProxyHeaders } from '../lib/core/proxy-headers-store.js'; test('buildConnectRequest rejects CRLF in target host', () => { @@ -236,7 +238,15 @@ function makeSelfSignedCert() { }; } -function createMitmConnectProxy({ cert, key, headerFactory, delayMs = 0 }) { +function createMitmConnectProxy({ + cert, + key, + headerFactory, + delayMs = 0, + keepAlive = false, + originStatus = 200, + originStatusText = originStatus === 200 ? 'OK' : 'Error', +}) { let connectCount = 0; const server = net.createServer((sock) => { let buf = Buffer.alloc(0); @@ -258,18 +268,27 @@ function createMitmConnectProxy({ cert, key, headerFactory, delayMs = 0 }) { if (delayMs) await delay(delayMs); let httpBuf = Buffer.alloc(0); const tryRespond = () => { - if (!httpBuf.includes('\r\n\r\n')) return; - const reqLine = httpBuf.toString('utf8').split('\r\n')[0]; - const body = JSON.stringify({ ok: true, id, reqLine }); + const sep = httpBuf.indexOf('\r\n\r\n'); + if (sep === -1) return; + const reqLine = httpBuf.subarray(0, sep).toString('utf8').split('\r\n')[0]; + httpBuf = httpBuf.subarray(sep + 4); + const body = JSON.stringify({ ok: originStatus === 200, id, reqLine }); + const connHdr = keepAlive ? 'Connection: keep-alive\r\n' : 'Connection: close\r\n'; tlsSock.write( - 'HTTP/1.1 200 OK\r\n' + + `HTTP/1.1 ${originStatus} ${originStatusText}\r\n` + 'Content-Type: application/json\r\n' + `Content-Length: ${Buffer.byteLength(body)}\r\n` + - 'Connection: close\r\n' + + connHdr + '\r\n' + body, ); - tlsSock.end(); + if (!keepAlive) { + tlsSock.end(); + return; + } + if (httpBuf.includes('\r\n\r\n')) { + tryRespond(); + } }; tlsSock.on('data', (chunk) => { httpBuf = Buffer.concat([httpBuf, chunk]); @@ -345,6 +364,112 @@ test('concurrent axios requests keep per-response CONNECT headers', async () => } }); +test('axios attaches proxyHeaders on non-2xx error.response', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const proxy = createMitmConnectProxy({ + cert, + key, + originStatus: 500, + headerFactory: () => + 'X-ProxyMesh-IP: 203.0.113.50\r\nSet-Cookie: session=attacker-injected\r\n', + }); + await listen(proxy); + try { + const { port } = proxy.address(); + const client = await createProxyAxios({ + proxy: `http://127.0.0.1:${port}`, + }); + client.proxyAgent.tlsOptions = { rejectUnauthorized: false }; + await assert.rejects( + () => client.get('https://127.0.0.1/'), + (err) => { + assert.equal(err.response.status, 500); + assert.equal(err.response.proxyHeaders.get('x-proxymesh-ip'), '203.0.113.50'); + assert.equal(err.response.headers['set-cookie'], undefined); + return true; + }, + ); + } finally { + proxy.close(); + cleanup(); + } +}); + +test('typed-rest-client RestClient.get sets result.proxyHeaders', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const proxy = createMitmConnectProxy({ + cert, + key, + headerFactory: () => 'X-ProxyMesh-IP: 198.51.100.71\r\nX-Race-Id: rest\r\n', + }); + await listen(proxy); + try { + const { port } = proxy.address(); + const client = createProxyRestClient({ + userAgent: 'javascript-proxy-headers-test', + proxy: `http://127.0.0.1:${port}`, + }); + client.proxyAgent.tlsOptions = { rejectUnauthorized: false }; + const result = await client.get('https://127.0.0.1/'); + assert.equal(result.statusCode, 200); + assert.ok(result.proxyHeaders instanceof Map); + assert.equal(result.proxyHeaders.get('x-proxymesh-ip'), '198.51.100.71'); + assert.equal(result.proxyHeaders.get('x-race-id'), 'rest'); + assert.equal(result.headers['set-cookie'], undefined); + assert.equal(result.result.ok, true); + } finally { + proxy.close(); + cleanup(); + } +}); + +test('make-fetch-happen keep-alive reuse does not pick lastProxyHeaders', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const proxy = createMitmConnectProxy({ + cert, + key, + delayMs: 80, + keepAlive: true, + headerFactory: ({ id }) => `X-ProxyMesh-IP: 198.51.100.${id}\r\nX-Race-Id: ${id}\r\n`, + }); + await listen(proxy); + let fetch; + try { + const { port } = proxy.address(); + fetch = createProxyMakeFetchHappen({ + proxy: `http://127.0.0.1:${port}`, + }); + fetch.proxyAgent.tlsOptions = { rejectUnauthorized: false }; + fetch.proxyAgent.keepAlive = true; + fetch.proxyAgent.maxSockets = 10; + fetch.proxyAgent.maxFreeSockets = 10; + + const [first, second] = await Promise.all([ + fetch('https://127.0.0.1/a'), + fetch('https://127.0.0.1/b'), + ]); + const firstBody = await first.json(); + const secondBody = await second.json(); + assert.equal(first.proxyHeaders.get('x-race-id'), String(firstBody.id)); + assert.equal(second.proxyHeaders.get('x-race-id'), String(secondBody.id)); + + const other = await fetch('https://127.0.0.2/other'); + const otherBody = await other.json(); + assert.equal(other.proxyHeaders.get('x-race-id'), String(otherBody.id)); + assert.equal(fetch.proxyAgent.lastProxyHeaders.get('x-race-id'), String(otherBody.id)); + + const reused = await fetch('https://127.0.0.1/reuse'); + const reusedBody = await reused.json(); + assert.ok(reusedBody.id === firstBody.id || reusedBody.id === secondBody.id); + assert.equal(reused.proxyHeaders.get('x-race-id'), String(reusedBody.id)); + assert.notEqual(reused.proxyHeaders.get('x-race-id'), String(otherBody.id)); + } finally { + fetch?.proxyAgent.destroy(); + proxy.close(); + cleanup(); + } +}); + test('getProxyHeaders reads CONNECT headers from the TLS socket', async () => { const { cert, key, cleanup } = makeSelfSignedCert(); const proxy = createMitmConnectProxy({ diff --git a/test/test_proxy_headers.js b/test/test_proxy_headers.js index 8c67bad..b12d62e 100644 --- a/test/test_proxy_headers.js +++ b/test/test_proxy_headers.js @@ -443,7 +443,7 @@ const AVAILABLE_TESTS = { const result = await client.get(config.testUrl); const headerValue = checkHeader( - result.proxyHeaders || client.proxyAgent.lastProxyHeaders, + result.proxyHeaders, config.proxyHeader, ); @@ -456,8 +456,8 @@ 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); + if (err && err.proxyHeaders) { + const headerValue = checkHeader(err.proxyHeaders, config.proxyHeader); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult('typed-rest-client', false, null, sentErr); if (headerValue) { diff --git a/test/test_proxy_headers.ts b/test/test_proxy_headers.ts index b437f00..4ff1f03 100644 --- a/test/test_proxy_headers.ts +++ b/test/test_proxy_headers.ts @@ -382,7 +382,7 @@ const AVAILABLE_TESTS: Record = { }); const result = await client.get(config.testUrl); const headerValue = checkHeader( - (result.proxyHeaders || client.proxyAgent.lastProxyHeaders) as Map, + result.proxyHeaders as Map, config.proxyHeader, ); const sentErr = validateSentHeaderValue(config, headerValue); @@ -396,8 +396,8 @@ const AVAILABLE_TESTS: Record = { result.statusCode, ); } catch (err: any) { - if (client && client.proxyAgent && client.proxyAgent.lastProxyHeaders) { - const headerValue = checkHeader(client.proxyAgent.lastProxyHeaders as Map, config.proxyHeader); + if (err && err.proxyHeaders) { + const headerValue = checkHeader(err.proxyHeaders 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); From 7ae8b1e22f74c7fd986bd1e5af77125acc2c7282 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 18:02:00 +0000 Subject: [PATCH 4/4] fix: do not stamp make-fetch-happen cache hits with lastProxyHeaders Cache hits never CONNECT, so ALS and the Response have no tunnel socket. Remember CONNECT headers from the live fetch by URL and restore them on hit instead of falling back to the shared agent's last-write-wins snapshot. Co-authored-by: ProxyMesh AI --- docs/make-fetch-happen.md | 2 ++ lib/make-fetch-happen-proxy.js | 45 ++++++++++++++++++++++++++---- test/test_connect_security.js | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/docs/make-fetch-happen.md b/docs/make-fetch-happen.md index 37ddf91..be78159 100644 --- a/docs/make-fetch-happen.md +++ b/docs/make-fetch-happen.md @@ -69,6 +69,8 @@ console.log(res.proxyHeaders.get('x-proxymesh-ip')); Use `response.proxyHeaders.get('x-proxymesh-ip')` on the wrapped response. `fetch.proxyAgent.lastProxyHeaders` is last-write-wins and is not safe under concurrency. +Cached responses (`cachePath`) restore the CONNECT headers from the original network fetch for this fetch instance. They do not reuse `lastProxyHeaders` from a later request. After a process restart the in-memory map is empty, so a disk cache hit may have an empty `proxyHeaders` Map rather than another request's CONNECT metadata. + ## Synchronous Factory Unlike some adapters, `createProxyMakeFetchHappen` is **not** async: it returns the wrapped fetch immediately. diff --git a/lib/make-fetch-happen-proxy.js b/lib/make-fetch-happen-proxy.js index 86aabd6..3842109 100644 --- a/lib/make-fetch-happen-proxy.js +++ b/lib/make-fetch-happen-proxy.js @@ -9,19 +9,52 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js'; import { ProxyResponse } from './core/proxy-response.js'; import { getProxyHeaders, runWithProxyHeadersContext, takeProxyHeadersContext } from './core/proxy-headers-store.js'; -function wrapFetchWithProxyResponse(fetchImpl, agent) { +function requestUrlKey(url, res) { + if (typeof url === 'string') { + return url; + } + if (url instanceof URL) { + return url.href; + } + if (url && typeof url.url === 'string') { + return url.url; + } + return res?.url || ''; +} + +/** + * Cache hits never CONNECT, so ALS and the Response have no tunnel socket. + * Remember CONNECT headers from the live fetch and restore them by URL. + * Never fall back to lastProxyHeaders (last-write-wins across the shared agent). + * @param {Function} fetchImpl + * @param {ProxyHeadersAgent} agent + * @param {Map>} [cachedProxyHeaders] + */ +function wrapFetchWithProxyResponse(fetchImpl, agent, cachedProxyHeaders = new Map()) { const wrapped = (url, opts = {}) => runWithProxyHeadersContext(async () => { const res = await fetchImpl(url, opts); - return new ProxyResponse( - res, - getProxyHeaders(res) || takeProxyHeadersContext() || agent.lastProxyHeaders, - ); + const live = getProxyHeaders(res) || takeProxyHeadersContext(); + if (live instanceof Map) { + const key = requestUrlKey(url, res); + if (key) { + cachedProxyHeaders.set(key, live); + } + if (res?.url) { + cachedProxyHeaders.set(res.url, live); + } + return new ProxyResponse(res, live); + } + const stored = + cachedProxyHeaders.get(res?.url) || + cachedProxyHeaders.get(requestUrlKey(url, res)) || + new Map(); + return new ProxyResponse(res, stored); }); wrapped.defaults = (defaultUrl, defaultOptions = {}) => { const inner = fetchImpl.defaults(defaultUrl, defaultOptions); - return wrapFetchWithProxyResponse(inner, agent); + return wrapFetchWithProxyResponse(inner, agent, cachedProxyHeaders); }; wrapped.proxyAgent = agent; diff --git a/test/test_connect_security.js b/test/test_connect_security.js index bd3b659..0427994 100644 --- a/test/test_connect_security.js +++ b/test/test_connect_security.js @@ -246,6 +246,7 @@ function createMitmConnectProxy({ keepAlive = false, originStatus = 200, originStatusText = originStatus === 200 ? 'OK' : 'Error', + originCacheControl = null, }) { let connectCount = 0; const server = net.createServer((sock) => { @@ -274,10 +275,14 @@ function createMitmConnectProxy({ httpBuf = httpBuf.subarray(sep + 4); const body = JSON.stringify({ ok: originStatus === 200, id, reqLine }); const connHdr = keepAlive ? 'Connection: keep-alive\r\n' : 'Connection: close\r\n'; + const cacheHdr = originCacheControl + ? `Cache-Control: ${originCacheControl}\r\n` + : ''; tlsSock.write( `HTTP/1.1 ${originStatus} ${originStatusText}\r\n` + 'Content-Type: application/json\r\n' + `Content-Length: ${Buffer.byteLength(body)}\r\n` + + cacheHdr + connHdr + '\r\n' + body, @@ -470,6 +475,51 @@ test('make-fetch-happen keep-alive reuse does not pick lastProxyHeaders', async } }); +test('make-fetch-happen cache hit does not pick lastProxyHeaders', async () => { + const { cert, key, cleanup } = makeSelfSignedCert(); + const cacheDir = mkdtempSync(join(tmpdir(), 'jph-mfh-cache-')); + const proxy = createMitmConnectProxy({ + cert, + key, + originCacheControl: 'public, max-age=3600', + headerFactory: ({ id }) => `X-ProxyMesh-IP: 198.51.100.${id}\r\nX-Race-Id: ${id}\r\n`, + }); + await listen(proxy); + let fetch; + try { + const { port } = proxy.address(); + fetch = createProxyMakeFetchHappen({ + proxy: `http://127.0.0.1:${port}`, + cachePath: cacheDir, + cache: 'force-cache', + retry: false, + }); + fetch.proxyAgent.tlsOptions = { rejectUnauthorized: false }; + + const first = await fetch('https://127.0.0.1/cached'); + const firstBody = await first.json(); + assert.equal(first.proxyHeaders.get('x-race-id'), String(firstBody.id)); + + const other = await fetch('https://127.0.0.2/other'); + const otherBody = await other.json(); + assert.equal(other.proxyHeaders.get('x-race-id'), String(otherBody.id)); + assert.notEqual(String(otherBody.id), String(firstBody.id)); + assert.equal(fetch.proxyAgent.lastProxyHeaders.get('x-race-id'), String(otherBody.id)); + + const cached = await fetch('https://127.0.0.1/cached'); + const cachedBody = await cached.json(); + assert.equal(cached.headers.get('x-local-cache-status'), 'hit'); + assert.equal(cachedBody.id, firstBody.id); + assert.equal(cached.proxyHeaders.get('x-race-id'), String(firstBody.id)); + assert.notEqual(cached.proxyHeaders.get('x-race-id'), String(otherBody.id)); + } finally { + fetch?.proxyAgent.destroy(); + proxy.close(); + cleanup(); + rmSync(cacheDir, { recursive: true, force: true }); + } +}); + test('getProxyHeaders reads CONNECT headers from the TLS socket', async () => { const { cert, key, cleanup } = makeSelfSignedCert(); const proxy = createMitmConnectProxy({