Skip to content

Commit 8e392f3

Browse files
committed
Make piped header copying opt-in and preserve explicit headers
1 parent 9bc8dfb commit 8e392f3

File tree

15 files changed

+1023
-80
lines changed

15 files changed

+1023
-80
lines changed

documentation/2-options.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -500,51 +500,51 @@ Therefore this option has no effect when using HTTP/2.
500500
### `copyPipedHeaders`
501501

502502
**Type: `boolean`**\
503-
**Default: `true`**
503+
**Default: `false`**
504504

505505
Automatically copy headers from piped streams.
506506

507507
When piping a request into a Got stream (e.g., `request.pipe(got.stream(url))`), this controls whether headers from the source stream are automatically merged into the Got request headers.
508508

509-
**Note:** Piped headers overwrite any explicitly set headers with the same name. To override this, either set `copyPipedHeaders` to `false` and manually copy safe headers, or use a `beforeRequest` hook to force specific header values after piping.
509+
**Note:** Explicitly set headers take precedence over piped headers. Piped headers are only copied when a header is not already explicitly set.
510510

511-
Useful for proxy scenarios, but you may want to disable this to filter out headers like `Host`, `Connection`, `Authorization`, etc.
511+
Useful for proxy scenarios when explicitly enabled, but you may still want to filter out headers like `Host`, `Connection`, `Authorization`, etc.
512512

513-
**Example: Disable automatic header copying and manually copy only safe headers**
513+
**Example: Opt in to automatic header copying for proxy scenarios**
514514

515515
```js
516516
import got from 'got';
517517
import {pipeline} from 'node:stream/promises';
518518

519519
server.get('/proxy', async (request, response) => {
520520
const gotStream = got.stream('https://example.com', {
521-
copyPipedHeaders: false,
521+
copyPipedHeaders: true,
522+
// Explicit headers win over piped headers
522523
headers: {
523-
'user-agent': request.headers['user-agent'],
524-
'accept': request.headers['accept'],
525-
// Explicitly NOT copying host, connection, authorization, etc.
524+
host: 'example.com',
526525
}
527526
});
528527

529528
await pipeline(request, gotStream, response);
530529
});
531530
```
532531

533-
**Example: Override piped headers using beforeRequest hook**
532+
**Example: Keep it disabled and manually copy only safe headers**
534533

535534
```js
536535
import got from 'got';
536+
import {pipeline} from 'node:stream/promises';
537537

538-
const gotStream = got.stream('https://example.com', {
539-
hooks: {
540-
beforeRequest: [
541-
options => {
542-
// Force specific header values after piping
543-
options.headers.host = 'example.com';
544-
delete options.headers.authorization;
545-
}
546-
]
547-
}
538+
server.get('/proxy', async (request, response) => {
539+
const gotStream = got.stream('https://example.com', {
540+
headers: {
541+
'user-agent': request.headers['user-agent'],
542+
'accept': request.headers['accept'],
543+
// Explicitly NOT copying host, connection, authorization, etc.
544+
}
545+
});
546+
547+
await pipeline(request, gotStream, response);
548548
});
549549
```
550550

documentation/migration-guides/request.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ Readability is very important to us, so we have different names for these option
9595
- No `removeRefererHeader` option.\
9696
You can remove the `referer` header in a [`beforeRequest` hook](../9-hooks.md#beforerequest).
9797
- No `followAllRedirects` option.
98+
- [`copyPipedHeaders`](../2-options.md#copypipedheaders) defaults to `false`.\
99+
Piped request headers are no longer copied automatically. Opt in with `copyPipedHeaders: true` for proxy scenarios.
100+
- With `copyPipedHeaders: true`, explicitly set headers win over piped headers.\
101+
Piped headers only fill headers that were not explicitly set.
98102

99103
Hooks are very powerful. [Read more](../9-hooks.md) to see what else you achieve using hooks.
100104

@@ -110,7 +114,7 @@ http.createServer((serverRequest, serverResponse) => {
110114
});
111115
```
112116

113-
The cool feature here is that Request can proxy headers with the stream, but Got can do that too!
117+
Request can proxy headers with the stream. Got can do that too, but it is opt-in:
114118

115119
```js
116120
import {pipeline as streamPipeline} from 'node:stream/promises';
@@ -119,7 +123,8 @@ import got from 'got';
119123
const server = http.createServer(async (serverRequest, serverResponse) => {
120124
if (serverRequest.url === '/doodle.png') {
121125
await streamPipeline(
122-
got.stream('https://example.com/doodle.png'),
126+
serverRequest,
127+
got.stream('https://example.com/doodle.png', {copyPipedHeaders: true}),
123128
serverResponse
124129
);
125130
}
@@ -128,7 +133,7 @@ const server = http.createServer(async (serverRequest, serverResponse) => {
128133
server.listen(8080);
129134
```
130135

131-
In terms of streams nothing has really changed.
136+
In terms of stream usage, nothing has really changed, but header proxying is opt-in via `copyPipedHeaders: true`.
132137

133138
#### Convenience methods
134139

source/core/errors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import is from '@sindresorhus/is';
2+
import stripUrlAuth from './utils/strip-url-auth.js';
23
import type {Timings} from './utils/timer.js';
34
import type Options from './options.js';
45
import type {TimeoutError as TimedOutTimeoutError} from './timed-out.js';
@@ -99,7 +100,7 @@ export class HTTPError<T = unknown> extends RequestError<T> {
99100
declare readonly timings: Timings;
100101

101102
constructor(response: PlainResponse) {
102-
super(`Request failed with status code ${response.statusCode} (${response.statusMessage!}): ${response.request.options.method} ${response.request.options.url!.toString()}`, {}, response.request);
103+
super(`Request failed with status code ${response.statusCode} (${response.statusMessage!}): ${response.request.options.method} ${stripUrlAuth(response.request.options.url!)}`, {}, response.request);
103104
}
104105
}
105106

source/core/index.ts

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import getBodySize from './utils/get-body-size.js';
2121
import proxyEvents from './utils/proxy-events.js';
2222
import timedOut, {TimeoutError as TimedOutTimeoutError} from './timed-out.js';
2323
import urlToOptions from './utils/url-to-options.js';
24+
import stripUrlAuth from './utils/strip-url-auth.js';
2425
import WeakableMap from './utils/weakable-map.js';
2526
import calculateRetryDelay from './calculate-retry-delay.js';
2627
import Options, {
@@ -201,6 +202,7 @@ const normalizeError = (error: unknown): Error => {
201202
type UrlType = ConstructorParameters<typeof Options>[0];
202203
type OptionsType = ConstructorParameters<typeof Options>[1];
203204
type DefaultsType = ConstructorParameters<typeof Options>[2];
205+
const getSanitizedUrl = (options?: Options): string => options?.url ? stripUrlAuth(options.url) : '';
204206

205207
export default class Request extends Duplex implements RequestEvents<Request> {
206208
// @ts-expect-error - Ignoring for now.
@@ -254,7 +256,15 @@ export default class Request extends Duplex implements RequestEvents<Request> {
254256

255257
this.on('pipe', (source: NodeJS.ReadableStream & {headers?: Record<string, string | string[] | undefined>}) => {
256258
if (this.options.copyPipedHeaders && source?.headers) {
257-
Object.assign(this.options.headers, source.headers);
259+
for (const [header, value] of Object.entries(source.headers)) {
260+
const normalizedHeader = header.toLowerCase();
261+
262+
if (!this.options.shouldCopyPipedHeader(normalizedHeader)) {
263+
continue;
264+
}
265+
266+
this.options.setPipedHeader(normalizedHeader, value);
267+
}
258268
}
259269
});
260270

@@ -280,7 +290,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
280290
// Publish request creation event
281291
publishRequestCreate({
282292
requestId: this._requestId,
283-
url: this.options.url?.toString() ?? '',
293+
url: getSanitizedUrl(this.options),
284294
method: this.options.method,
285295
});
286296
} catch (error: unknown) {
@@ -751,7 +761,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
751761

752762
private async _finalizeBody(): Promise<void> {
753763
const {options} = this;
754-
const {headers} = options;
764+
const headers = options.getInternalHeaders();
755765

756766
const isForm = !is.undefined(options.form);
757767
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -800,7 +810,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
800810
options.body = options.stringifyJson(json);
801811
}
802812

803-
const uploadBodySize = getBodySize(options.body, options.headers);
813+
const uploadBodySize = getBodySize(options.body, headers);
804814

805815
// See https://tools.ietf.org/html/rfc7230#section-3.3.2
806816
// A user agent SHOULD send a Content-Length in a request message when
@@ -816,8 +826,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
816826
}
817827
}
818828

819-
if (options.responseType === 'json' && !('accept' in options.headers)) {
820-
options.headers.accept = 'application/json';
829+
if (options.responseType === 'json' && !('accept' in headers)) {
830+
headers.accept = 'application/json';
821831
}
822832

823833
this._bodySize = Number(headers['content-length']) || undefined;
@@ -873,7 +883,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
873883
}
874884

875885
typedResponse.statusMessage ||= http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
876-
typedResponse.url = options.url!.toString();
886+
typedResponse.url = stripUrlAuth(options.url!);
877887
typedResponse.requestUrl = this.requestUrl!;
878888
typedResponse.redirectUrls = this.redirectUrls;
879889
typedResponse.request = this;
@@ -981,7 +991,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
981991
updatedOptions.json = undefined;
982992
updatedOptions.form = undefined;
983993

984-
delete updatedOptions.headers['content-length'];
994+
updatedOptions.deleteInternalHeader('content-length');
985995
// Only clear `_bodySize` when the redirect drops the request body.
986996
this._bodySize = undefined;
987997
}
@@ -998,21 +1008,24 @@ export default class Request extends Duplex implements RequestEvents<Request> {
9981008

9991009
// Redirecting to a different site, clear sensitive data.
10001010
// For UNIX sockets, different socket paths are also different origins.
1001-
const isDifferentOrigin = redirectUrl.hostname !== (url as URL).hostname
1011+
const isDifferentOrigin = redirectUrl.protocol !== (url as URL).protocol
1012+
|| redirectUrl.hostname !== (url as URL).hostname
10021013
|| redirectUrl.port !== (url as URL).port
10031014
|| getUnixSocketPath(url as URL) !== getUnixSocketPath(redirectUrl);
10041015

10051016
if (isDifferentOrigin) {
1006-
if ('host' in updatedOptions.headers) {
1007-
delete updatedOptions.headers.host;
1017+
const updatedHeaders = updatedOptions.getInternalHeaders();
1018+
1019+
if ('host' in updatedHeaders) {
1020+
updatedOptions.deleteInternalHeader('host');
10081021
}
10091022

1010-
if ('cookie' in updatedOptions.headers) {
1011-
delete updatedOptions.headers.cookie;
1023+
if ('cookie' in updatedHeaders) {
1024+
updatedOptions.deleteInternalHeader('cookie');
10121025
}
10131026

1014-
if ('authorization' in updatedOptions.headers) {
1015-
delete updatedOptions.headers.authorization;
1027+
if ('authorization' in updatedHeaders) {
1028+
updatedOptions.deleteInternalHeader('authorization');
10161029
}
10171030

10181031
if (updatedOptions.username || updatedOptions.password) {
@@ -1221,7 +1234,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
12211234
// Publish request start event
12221235
publishRequestStart({
12231236
requestId: this._requestId,
1224-
url: url?.toString() ?? '',
1237+
url: getSanitizedUrl(this.options),
12251238
method: options.method,
12261239
headers: options.headers,
12271240
});
@@ -1651,13 +1664,13 @@ export default class Request extends Duplex implements RequestEvents<Request> {
16511664

16521665
private async _makeRequest(): Promise<void> {
16531666
const {options} = this;
1654-
const {headers, username, password} = options;
1667+
const headers = options.getInternalHeaders();
1668+
const {username, password} = options;
16551669
const cookieJar = options.cookieJar as PromiseCookieJar | undefined;
16561670

16571671
for (const key in headers) {
16581672
if (is.undefined(headers[key])) {
1659-
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
1660-
delete headers[key];
1673+
options.deleteInternalHeader(key);
16611674
} else if (is.null(headers[key])) {
16621675
throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
16631676
}
@@ -1673,20 +1686,20 @@ export default class Request extends Duplex implements RequestEvents<Request> {
16731686
encodings.push('zstd');
16741687
}
16751688

1676-
headers['accept-encoding'] = encodings.join(', ');
1689+
options.setInternalHeader('accept-encoding', encodings.join(', '));
16771690
}
16781691

16791692
if (username || password) {
16801693
const credentials = Buffer.from(`${username}:${password}`).toString('base64');
1681-
headers.authorization = `Basic ${credentials}`;
1694+
options.setInternalHeader('authorization', `Basic ${credentials}`);
16821695
}
16831696

16841697
// Set cookies
16851698
if (cookieJar) {
16861699
const cookieString: string = await cookieJar.getCookieString(options.url!.toString());
16871700

16881701
if (is.nonEmptyString(cookieString)) {
1689-
headers.cookie = cookieString;
1702+
options.setInternalHeader('cookie', cookieString);
16901703
}
16911704
}
16921705

@@ -1790,7 +1803,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
17901803
// Publish error event
17911804
publishError({
17921805
requestId: this._requestId,
1793-
url: this.options?.url?.toString() ?? '',
1806+
url: getSanitizedUrl(this.options),
17941807
error,
17951808
timings: this.timings,
17961809
});

0 commit comments

Comments
 (0)