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..8f062c6 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. +## 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 +// ≤0.2.x (removed) +response.headers['x-proxymesh-ip'] + +// 0.3.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..f40ddfd 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. Non-2xx responses attach the same Map on `error.response.proxyHeaders`. ## 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..c7cea8b 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). +## 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. + +`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..be78159 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,9 @@ 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. + +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 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..f7cd3a9 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:** @@ -60,13 +58,13 @@ 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 -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..2f61b7d 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@proxymesh/javascript-proxy-headers", - "version": "0.2.4", + "version": "0.3.0", "license": "MIT", "exports": "./mod.ts" } diff --git a/lib/axios-proxy.js b/lib/axios-proxy.js index d3fefb3..f73a7f8 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; @@ -49,16 +50,18 @@ export async function createProxyAxios(options) { proxy: false, }); - instance.interceptors.response.use((response) => { - if (agent.lastProxyHeaders) { - for (const [key, value] of agent.lastProxyHeaders) { - if (!response.headers[key]) { - response.headers[key] = value; - } + 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 response; - }); + return Promise.reject(error); + }, + ); instance.proxyAgent = agent; diff --git a/lib/core/proxy-headers-agent.js b/lib/core/proxy-headers-agent.js index 126092b..1bbad55 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, getProxyHeadersFromSocket } from './proxy-headers-store.js'; export class ProxyHeadersAgent extends Agent { /** @@ -40,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 @@ -122,6 +145,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 +177,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..c4de05d --- /dev/null +++ b/lib/core/proxy-headers-store.js @@ -0,0 +1,111 @@ +/** + * 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.body, + source.body?.socket, + 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..3842109 100644 --- a/lib/make-fetch-happen-proxy.js +++ b/lib/make-fetch-happen-proxy.js @@ -7,14 +7,54 @@ import makeFetchHappen from 'make-fetch-happen'; 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 = {}) => - fetchImpl(url, opts).then((res) => new ProxyResponse(res, agent.lastProxyHeaders)); + runWithProxyHeadersContext(async () => { + const res = await fetchImpl(url, opts); + 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/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..4541ae0 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 { getProxyHeaders, 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, + getProxyHeaders(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..0534862 100644 --- a/lib/typed-rest-client-proxy.js +++ b/lib/typed-rest-client-proxy.js @@ -6,9 +6,36 @@ 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); +/** + * 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 */ @@ -39,6 +66,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 +108,18 @@ 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) { + if (typeof super._processResponse === 'function') { + return attachRestProxyHeaders(super._processResponse(res, options), res); + } + return this.processResponse(res, options); + } }; } 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..f379b4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-proxy-headers", - "version": "0.2.4", + "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", diff --git a/test/test_connect_security.js b/test/test_connect_security.js index 4293232..0427994 100644 --- a/test/test_connect_security.js +++ b/test/test_connect_security.js @@ -20,6 +20,10 @@ import { proxyReadyEvent, } 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', () => { assert.throws( @@ -233,3 +237,321 @@ function makeSelfSignedCert() { cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } + +function createMitmConnectProxy({ + cert, + key, + headerFactory, + delayMs = 0, + keepAlive = false, + originStatus = 200, + originStatusText = originStatus === 200 ? 'OK' : 'Error', + originCacheControl = null, +}) { + 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 = () => { + 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'; + 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, + ); + if (!keepAlive) { + tlsSock.end(); + return; + } + if (httpBuf.includes('\r\n\r\n')) { + tryRespond(); + } + }; + 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('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('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({ + 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..b12d62e 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, + config.proxyHeader, + ); const sentErr = validateSentHeaderValue(config, headerValue); if (sentErr) return new TestResult('typed-rest-client', false, null, sentErr); @@ -453,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 56c825e..4ff1f03 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 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); @@ -393,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); 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(