got 15.0.1 → 15.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,12 +69,18 @@ export default function asPromise(firstRequest) {
69
69
  : (Object.hasOwn(updatedOptions, 'body') && updatedOptions.body !== undefined)
70
70
  || (Object.hasOwn(updatedOptions, 'json') && updatedOptions.json !== undefined)
71
71
  || (Object.hasOwn(updatedOptions, 'form') && updatedOptions.form !== undefined);
72
+ const clearsCookieJar = Object.hasOwn(updatedOptions, 'cookieJar') && updatedOptions.cookieJar === undefined;
72
73
  if (hasExplicitBody && !reusesRequestOptions) {
73
74
  options.clearBody();
74
75
  }
76
+ if (!reusesRequestOptions && clearsCookieJar) {
77
+ options.cookieJar = undefined;
78
+ }
75
79
  if (!reusesRequestOptions) {
76
80
  options.merge(updatedOptions);
81
+ options.syncCookieHeaderAfterMerge(previousState, updatedOptions.headers);
77
82
  }
83
+ options.clearUnchangedCookieHeader(previousState, reusesRequestOptions ? changedState : undefined);
78
84
  if (updatedOptions.url) {
79
85
  const nextUrl = reusesRequestOptions
80
86
  ? options.url
@@ -725,6 +725,40 @@ export default class Request extends Duplex {
725
725
  }, this));
726
726
  }
727
727
  });
728
+ let canFinalizeResponse = false;
729
+ const handleResponseEnd = () => {
730
+ if (!canFinalizeResponse
731
+ || !response.readableEnded) {
732
+ return;
733
+ }
734
+ canFinalizeResponse = false;
735
+ if (this._stopReading) {
736
+ return;
737
+ }
738
+ // Validate content-length if it was provided
739
+ // Per RFC 9112: "If the sender closes the connection before the indicated number
740
+ // of octets are received, the recipient MUST consider the message to be incomplete"
741
+ if (this._checkContentLengthMismatch()) {
742
+ return;
743
+ }
744
+ this._responseSize = this._downloadedSize;
745
+ this.emit('downloadProgress', this.downloadProgress);
746
+ // Publish response end event
747
+ publishResponseEnd({
748
+ requestId: this._requestId,
749
+ url: typedResponse.url,
750
+ statusCode,
751
+ bodySize: this._downloadedSize,
752
+ timings: this.timings,
753
+ });
754
+ this.push(null);
755
+ };
756
+ if (!shouldFollowRedirect) {
757
+ // `set-cookie` handling below awaits the cookie jar. A fast response can fully
758
+ // end during that await, so we need to observe `end` early without completing
759
+ // the outward stream until cookie handling has finished.
760
+ response.once('end', handleResponseEnd);
761
+ }
728
762
  const noPipeCookieJarRawBodyPromise = this._noPipe
729
763
  && is.object(options.cookieJar)
730
764
  && !isRedirect
@@ -828,6 +862,7 @@ export default class Request extends Duplex {
828
862
  }
829
863
  return changedState;
830
864
  });
865
+ updatedOptions.clearUnchangedCookieHeader(preHookState, changedState);
831
866
  // If a beforeRedirect hook changed the URL to a different origin,
832
867
  // strip sensitive headers that were preserved for the original origin.
833
868
  // When isDifferentOrigin was already true, headers were already stripped above.
@@ -836,15 +871,7 @@ export default class Request extends Duplex {
836
871
  const hookUrl = updatedOptions.url;
837
872
  if (!isSameOrigin(state.url, hookUrl)) {
838
873
  this._stripUnchangedCrossOriginState(updatedOptions, hookUrl, shouldDropBody, {
839
- headers: state.headers,
840
- username: state.username,
841
- password: state.password,
842
- body: state.body,
843
- json: state.json,
844
- form: state.form,
845
- bodySnapshot: state.bodySnapshot,
846
- jsonSnapshot: state.jsonSnapshot,
847
- formSnapshot: state.formSnapshot,
874
+ ...state,
848
875
  changedState,
849
876
  preserveUsername: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'username')
850
877
  || isCrossOriginCredentialChanged(state.url, hookUrl, 'username'),
@@ -870,6 +897,8 @@ export default class Request extends Duplex {
870
897
  }
871
898
  return;
872
899
  }
900
+ canFinalizeResponse = true;
901
+ handleResponseEnd();
873
902
  // `HTTPError`s always have `error.response.body` defined.
874
903
  // Therefore, we cannot retry if `options.throwHttpErrors` is false.
875
904
  // On the last retry, if `options.throwHttpErrors` is false, we would need to return the body,
@@ -894,32 +923,6 @@ export default class Request extends Duplex {
894
923
  }
895
924
  }
896
925
  }
897
- // Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
898
- let responseEndHandled = false;
899
- const handleResponseEnd = () => {
900
- if (responseEndHandled) {
901
- return;
902
- }
903
- responseEndHandled = true;
904
- // Validate content-length if it was provided
905
- // Per RFC 9112: "If the sender closes the connection before the indicated number
906
- // of octets are received, the recipient MUST consider the message to be incomplete"
907
- if (this._checkContentLengthMismatch()) {
908
- return;
909
- }
910
- this._responseSize = this._downloadedSize;
911
- this.emit('downloadProgress', this.downloadProgress);
912
- // Publish response end event
913
- publishResponseEnd({
914
- requestId: this._requestId,
915
- url: typedResponse.url,
916
- statusCode,
917
- bodySize: this._downloadedSize,
918
- timings: this.timings,
919
- });
920
- this.push(null);
921
- };
922
- response.once('end', handleResponseEnd);
923
926
  this.emit('downloadProgress', this.downloadProgress);
924
927
  response.on('readable', () => {
925
928
  if (this._triggerRead) {
@@ -1525,17 +1528,46 @@ export default class Request extends Duplex {
1525
1528
  }
1526
1529
  async _makeRequest() {
1527
1530
  const { options } = this;
1528
- const headers = options.getInternalHeaders();
1529
- const { username, password } = options;
1530
- const cookieJar = options.cookieJar;
1531
- for (const key in headers) {
1532
- if (is.undefined(headers[key])) {
1533
- options.deleteInternalHeader(key);
1531
+ const initialHeaders = options.getInternalHeaders();
1532
+ const explicitAuthorizationHeader = options.isHeaderExplicitlySet('authorization') ? initialHeaders.authorization : undefined;
1533
+ const explicitCookieHeader = options.isHeaderExplicitlySet('cookie') ? initialHeaders.cookie : undefined;
1534
+ const authorizationWasInitiallyOmitted = options.isHeaderExplicitlySet('authorization') && is.undefined(initialHeaders.authorization);
1535
+ const cookieWasInitiallyOmitted = options.isHeaderExplicitlySet('cookie') && is.undefined(initialHeaders.cookie);
1536
+ const shouldDeleteGeneratedHeader = (currentHeader, generatedHeader) => currentHeader === generatedHeader || is.undefined(currentHeader);
1537
+ const syncGeneratedHeader = (name, { currentHeader, explicitHeader, nextHeader, staleGeneratedHeader, }) => {
1538
+ if (!is.undefined(nextHeader)) {
1539
+ options.setInternalHeader(name, nextHeader);
1540
+ }
1541
+ else if (!is.undefined(explicitHeader) && currentHeader === staleGeneratedHeader) {
1542
+ options.setInternalHeader(name, explicitHeader);
1543
+ }
1544
+ else if (shouldDeleteGeneratedHeader(currentHeader, staleGeneratedHeader)) {
1545
+ options.deleteInternalHeader(name);
1534
1546
  }
1535
- else if (is.null(headers[key])) {
1536
- throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
1547
+ };
1548
+ const getAuthorizationHeader = (username, password, isExplicitlyOmitted) => !isExplicitlyOmitted && (username || password)
1549
+ ? `Basic ${stringToBase64(`${username}:${password}`)}`
1550
+ : undefined;
1551
+ const sanitizeHeaders = () => {
1552
+ const currentHeaders = options.getInternalHeaders();
1553
+ for (const key in currentHeaders) {
1554
+ if (is.undefined(currentHeaders[key])) {
1555
+ options.deleteInternalHeader(key);
1556
+ }
1557
+ else if (is.null(currentHeaders[key])) {
1558
+ throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
1559
+ }
1537
1560
  }
1538
- }
1561
+ return currentHeaders;
1562
+ };
1563
+ const getCookieHeader = async (cookieJar) => {
1564
+ if (!cookieJar) {
1565
+ return undefined;
1566
+ }
1567
+ const cookieString = await cookieJar.getCookieString(options.url.toString());
1568
+ return is.nonEmptyString(cookieString) ? cookieString : undefined;
1569
+ };
1570
+ const headers = sanitizeHeaders();
1539
1571
  if (options.decompress && is.undefined(headers['accept-encoding'])) {
1540
1572
  const encodings = ['gzip', 'deflate'];
1541
1573
  if (supportsBrotli) {
@@ -1546,34 +1578,93 @@ export default class Request extends Duplex {
1546
1578
  }
1547
1579
  options.setInternalHeader('accept-encoding', encodings.join(', '));
1548
1580
  }
1549
- if (username || password) {
1550
- const credentials = stringToBase64(`${username}:${password}`);
1551
- options.setInternalHeader('authorization', `Basic ${credentials}`);
1581
+ const { username, password } = options;
1582
+ const cookieJar = options.cookieJar;
1583
+ const generatedAuthorizationHeader = getAuthorizationHeader(username, password, authorizationWasInitiallyOmitted);
1584
+ let generatedCookieHeader;
1585
+ if (!is.undefined(generatedAuthorizationHeader)) {
1586
+ options.setInternalHeader('authorization', generatedAuthorizationHeader);
1552
1587
  }
1553
- // Set cookies
1554
- if (cookieJar) {
1555
- const cookieString = await cookieJar.getCookieString(options.url.toString());
1556
- if (is.nonEmptyString(cookieString)) {
1557
- options.setInternalHeader('cookie', cookieString);
1588
+ if (!cookieWasInitiallyOmitted) {
1589
+ generatedCookieHeader = await getCookieHeader(cookieJar);
1590
+ if (!is.undefined(generatedCookieHeader)) {
1591
+ options.setInternalHeader('cookie', generatedCookieHeader);
1558
1592
  }
1559
1593
  }
1560
1594
  let request;
1561
- for (const hook of options.hooks.beforeRequest) {
1562
- // eslint-disable-next-line no-await-in-loop
1563
- const result = await hook(options, { retryCount: this.retryCount });
1564
- if (!is.undefined(result)) {
1565
- // @ts-expect-error Skip the type mismatch to support abstract responses
1566
- request = () => result;
1567
- break;
1595
+ let shouldOmitRequestUrlCredentials = false;
1596
+ const changedState = await options.trackStateMutations(async (changedState) => {
1597
+ for (const hook of options.hooks.beforeRequest) {
1598
+ // eslint-disable-next-line no-await-in-loop
1599
+ const result = await hook(options, { retryCount: this.retryCount });
1600
+ if (!is.undefined(result)) {
1601
+ // @ts-expect-error Skip the type mismatch to support abstract responses
1602
+ request = () => result;
1603
+ break;
1604
+ }
1605
+ }
1606
+ return changedState;
1607
+ });
1608
+ if (request === undefined) {
1609
+ const currentHeaders = options.getInternalHeaders();
1610
+ // `headers.authorization = undefined` / `headers.cookie = undefined` is an
1611
+ // explicit opt-out. Respect that instead of regenerating values from URL
1612
+ // credentials or the cookie jar later in request setup.
1613
+ const isHeaderExplicitlyOmitted = (header) => options.isHeaderExplicitlySet(header) && is.undefined(currentHeaders[header]);
1614
+ const authorizationWasExplicitlyOmitted = isHeaderExplicitlyOmitted('authorization');
1615
+ const cookieWasExplicitlyOmitted = isHeaderExplicitlyOmitted('cookie');
1616
+ const currentAuthorizationHeader = currentHeaders.authorization;
1617
+ const currentCookieHeader = currentHeaders.cookie;
1618
+ sanitizeHeaders();
1619
+ if (!is.undefined(currentHeaders['transfer-encoding']) && !is.undefined(currentHeaders['content-length'])) {
1620
+ options.deleteInternalHeader('content-length');
1621
+ }
1622
+ if (authorizationWasExplicitlyOmitted) {
1623
+ shouldOmitRequestUrlCredentials = true;
1624
+ options.deleteInternalHeader('authorization');
1625
+ if (changedState.has('authorization') && is.undefined(explicitAuthorizationHeader) && !authorizationWasInitiallyOmitted) {
1626
+ delete options.headers.authorization;
1627
+ }
1628
+ }
1629
+ const authorizationHeader = getAuthorizationHeader(options.username, options.password, authorizationWasExplicitlyOmitted);
1630
+ const cookieJar = options.cookieJar;
1631
+ if (changedState.has('authorization')) {
1632
+ // A beforeRequest hook intentionally set the outgoing Authorization header.
1633
+ }
1634
+ else {
1635
+ syncGeneratedHeader('authorization', {
1636
+ currentHeader: currentAuthorizationHeader,
1637
+ explicitHeader: explicitAuthorizationHeader,
1638
+ nextHeader: authorizationHeader,
1639
+ staleGeneratedHeader: generatedAuthorizationHeader,
1640
+ });
1641
+ }
1642
+ if (cookieWasExplicitlyOmitted) {
1643
+ options.deleteInternalHeader('cookie');
1644
+ if (changedState.has('cookie') && is.undefined(explicitCookieHeader) && !cookieWasInitiallyOmitted) {
1645
+ delete options.headers.cookie;
1646
+ }
1647
+ }
1648
+ else if (changedState.has('cookie')) {
1649
+ // A beforeRequest hook intentionally set the outgoing Cookie header.
1650
+ }
1651
+ else {
1652
+ syncGeneratedHeader('cookie', {
1653
+ currentHeader: currentCookieHeader,
1654
+ explicitHeader: explicitCookieHeader,
1655
+ nextHeader: await getCookieHeader(cookieJar),
1656
+ staleGeneratedHeader: generatedCookieHeader,
1657
+ });
1568
1658
  }
1569
- }
1570
- if (!is.undefined(headers['transfer-encoding']) && !is.undefined(headers['content-length'])) {
1571
- // TODO: Throw instead of silently dropping `content-length` in the next major version.
1572
- options.deleteInternalHeader('content-length');
1573
1659
  }
1574
1660
  request ??= options.getRequestFunction();
1575
- const url = options.url;
1661
+ const url = shouldOmitRequestUrlCredentials
1662
+ ? new URL(stripUrlAuth(options.url))
1663
+ : options.url;
1576
1664
  this._requestOptions = options.createNativeRequestOptions();
1665
+ if (shouldOmitRequestUrlCredentials) {
1666
+ this._requestOptions.auth = undefined;
1667
+ }
1577
1668
  if (options.cache) {
1578
1669
  this._requestOptions._request = request;
1579
1670
  this._requestOptions.cache = options.cache;
@@ -31,6 +31,8 @@ export type Agents = {
31
31
  export type Headers = Record<string, string | string[] | undefined>;
32
32
  export type CrossOriginState = {
33
33
  headers: Headers;
34
+ hadCookieJar: boolean;
35
+ cookieWasExplicitlySet: boolean;
34
36
  username: string;
35
37
  password: string;
36
38
  body: unknown;
@@ -788,7 +790,7 @@ export declare function applyUrlOverride(options: Options, url: string | URL, {
788
790
  All parsing methods supported by Got.
789
791
  */
790
792
  export type ResponseType = 'json' | 'buffer' | 'text';
791
- type OptionsToSkip = 'searchParameters' | 'followRedirects' | 'auth' | 'toJSON' | 'merge' | 'isHeaderExplicitlySet' | 'shouldCopyPipedHeader' | 'setPipedHeader' | 'getInternalHeaders' | 'setInternalHeader' | 'deleteInternalHeader' | 'trackStateMutations' | 'clearBody' | 'stripUnchangedCrossOriginState' | 'stripSensitiveHeaders' | 'createNativeRequestOptions' | 'getRequestFunction' | 'freeze';
793
+ type OptionsToSkip = 'searchParameters' | 'followRedirects' | 'auth' | 'toJSON' | 'merge' | 'isHeaderExplicitlySet' | 'shouldCopyPipedHeader' | 'setPipedHeader' | 'getInternalHeaders' | 'setInternalHeader' | 'deleteInternalHeader' | 'trackStateMutations' | 'clearBody' | 'clearUnchangedCookieHeader' | 'restoreCookieHeader' | 'syncCookieHeaderAfterMerge' | 'stripUnchangedCrossOriginState' | 'stripSensitiveHeaders' | 'createNativeRequestOptions' | 'getRequestFunction' | 'freeze';
792
794
  export type InternalsType = Except<Options, OptionsToSkip>;
793
795
  export type OptionsError = NodeJS.ErrnoException & {
794
796
  options?: Options;
@@ -1226,6 +1228,9 @@ export default class Options {
1226
1228
  deleteInternalHeader(name: string): void;
1227
1229
  trackStateMutations<Value>(operation: (changedState: Set<string>) => Promisable<Value>): Promise<Value>;
1228
1230
  clearBody(): void;
1231
+ clearUnchangedCookieHeader(previousState: CrossOriginState | undefined, changedState?: Set<string>): void;
1232
+ restoreCookieHeader(previousState: CrossOriginState | undefined, headers?: Headers): void;
1233
+ syncCookieHeaderAfterMerge(previousState: CrossOriginState | undefined, headers?: Headers): void;
1229
1234
  stripUnchangedCrossOriginState(previousState: CrossOriginState, changedState: Set<string>, { clearBody }?: {
1230
1235
  clearBody?: boolean;
1231
1236
  }): void;
@@ -1406,6 +1406,35 @@ export default class Options {
1406
1406
  this.deleteInternalHeader(header);
1407
1407
  }
1408
1408
  }
1409
+ clearUnchangedCookieHeader(previousState, changedState) {
1410
+ if (previousState?.hadCookieJar
1411
+ && this.cookieJar === undefined
1412
+ && !this.isHeaderExplicitlySet('cookie')
1413
+ && !changedState?.has('cookie')
1414
+ && this.headers.cookie === previousState.headers.cookie) {
1415
+ this.deleteInternalHeader('cookie');
1416
+ }
1417
+ }
1418
+ restoreCookieHeader(previousState, headers) {
1419
+ if (!previousState) {
1420
+ return;
1421
+ }
1422
+ if (Object.hasOwn(headers ?? {}, 'cookie')) {
1423
+ return;
1424
+ }
1425
+ if (previousState.cookieWasExplicitlySet) {
1426
+ this.headers.cookie = previousState.headers.cookie;
1427
+ return;
1428
+ }
1429
+ delete this.headers.cookie;
1430
+ if (previousState.headers.cookie !== undefined) {
1431
+ this.setInternalHeader('cookie', previousState.headers.cookie);
1432
+ }
1433
+ }
1434
+ syncCookieHeaderAfterMerge(previousState, headers) {
1435
+ this.restoreCookieHeader(previousState, headers);
1436
+ this.clearUnchangedCookieHeader(previousState);
1437
+ }
1409
1438
  stripUnchangedCrossOriginState(previousState, changedState, { clearBody = true } = {}) {
1410
1439
  const headers = this.getInternalHeaders();
1411
1440
  const url = this.#internals.url;
@@ -2111,6 +2140,8 @@ export default class Options {
2111
2140
  }
2112
2141
  export const snapshotCrossOriginState = (options) => ({
2113
2142
  headers: { ...options.getInternalHeaders() },
2143
+ hadCookieJar: options.cookieJar !== undefined,
2144
+ cookieWasExplicitlySet: options.isHeaderExplicitlySet('cookie'),
2114
2145
  username: options.username,
2115
2146
  password: options.password,
2116
2147
  body: options.body,
@@ -1,9 +1,56 @@
1
+ const splitHeaderValue = (value, separator) => {
2
+ const values = [];
3
+ let current = '';
4
+ let inQuotes = false;
5
+ let inReference = false;
6
+ let isEscaped = false;
7
+ for (const character of value) {
8
+ if (inQuotes && isEscaped) {
9
+ current += character;
10
+ isEscaped = false;
11
+ continue;
12
+ }
13
+ if (inQuotes && character === '\\') {
14
+ current += character;
15
+ isEscaped = true;
16
+ continue;
17
+ }
18
+ if (character === '"') {
19
+ inQuotes = !inQuotes;
20
+ current += character;
21
+ continue;
22
+ }
23
+ if (!inQuotes && character === '<') {
24
+ inReference = true;
25
+ current += character;
26
+ continue;
27
+ }
28
+ if (!inQuotes && character === '>') {
29
+ inReference = false;
30
+ current += character;
31
+ continue;
32
+ }
33
+ // Link headers use both quoted strings and <URI-reference> values, so raw
34
+ // splitting on `,` / `;` would break valid values containing those characters.
35
+ if (!inQuotes && !inReference && character === separator) {
36
+ values.push(current);
37
+ current = '';
38
+ continue;
39
+ }
40
+ current += character;
41
+ }
42
+ if (inQuotes || isEscaped) {
43
+ throw new Error(`Failed to parse Link header: ${value}`);
44
+ }
45
+ values.push(current);
46
+ return values;
47
+ };
1
48
  export default function parseLinkHeader(link) {
2
49
  const parsed = [];
3
- const items = link.split(',');
50
+ const items = splitHeaderValue(link, ',');
4
51
  for (const item of items) {
5
52
  // https://tools.ietf.org/html/rfc5988#section-5
6
- const [rawUriReference, ...rawLinkParameters] = item.split(';');
53
+ const [rawUriReference, ...rawLinkParameters] = splitHeaderValue(item, ';');
7
54
  const trimmedUriReference = rawUriReference.trim();
8
55
  // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
9
56
  if (trimmedUriReference[0] !== '<' || trimmedUriReference.at(-1) !== '>') {
@@ -11,6 +58,9 @@ export default function parseLinkHeader(link) {
11
58
  }
12
59
  const reference = trimmedUriReference.slice(1, -1);
13
60
  const parameters = {};
61
+ if (reference.includes('<') || reference.includes('>')) {
62
+ throw new Error(`Invalid format of the Link header reference: ${trimmedUriReference}`);
63
+ }
14
64
  if (rawLinkParameters.length === 0) {
15
65
  throw new Error(`Unexpected end of Link header parameters: ${rawLinkParameters.join(';')}`);
16
66
  }
@@ -180,6 +180,7 @@ const create = (defaults) => {
180
180
  }
181
181
  if (optionsToMerge === response.request.options) {
182
182
  normalizedOptions = response.request.options;
183
+ normalizedOptions.clearUnchangedCookieHeader(previousState, changedState);
183
184
  if (previousUrl) {
184
185
  const nextUrl = normalizedOptions.url;
185
186
  if (nextUrl && !isSameOrigin(previousUrl, nextUrl)) {
@@ -192,10 +193,15 @@ const create = (defaults) => {
192
193
  const hasExplicitBody = (Object.hasOwn(optionsToMerge, 'body') && optionsToMerge.body !== undefined)
193
194
  || (Object.hasOwn(optionsToMerge, 'json') && optionsToMerge.json !== undefined)
194
195
  || (Object.hasOwn(optionsToMerge, 'form') && optionsToMerge.form !== undefined);
196
+ const clearsCookieJar = Object.hasOwn(optionsToMerge, 'cookieJar') && optionsToMerge.cookieJar === undefined;
195
197
  if (hasExplicitBody) {
196
198
  normalizedOptions.clearBody();
197
199
  }
200
+ if (clearsCookieJar) {
201
+ normalizedOptions.cookieJar = undefined;
202
+ }
198
203
  normalizedOptions.merge(optionsToMerge);
204
+ normalizedOptions.syncCookieHeaderAfterMerge(previousState, optionsToMerge.headers);
199
205
  try {
200
206
  assert.any([is.string, is.urlInstance, is.undefined], optionsToMerge.url);
201
207
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "got",
3
- "version": "15.0.1",
3
+ "version": "15.0.2",
4
4
  "description": "Human-friendly and powerful HTTP request library for Node.js",
5
5
  "license": "MIT",
6
6
  "repository": "sindresorhus/got",