@ai-sdk/provider-utils 4.0.29 → 4.0.30

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/provider-utils",
3
- "version": "4.0.29",
3
+ "version": "4.0.30",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cancels a response body to release the underlying connection.
3
+ *
4
+ * When a fetch Response is rejected without consuming its body (e.g. a failed
5
+ * status code, an open-redirect rejection, or a Content-Length that exceeds the
6
+ * size limit), the underlying TCP socket is not returned to the connection pool
7
+ * and may stay open until the process runs out of file descriptors. Cancelling
8
+ * the body avoids this leak.
9
+ *
10
+ * Errors thrown while cancelling are ignored: the body may already be locked,
11
+ * disturbed, or absent, none of which should mask the original rejection.
12
+ */
13
+ export async function cancelResponseBody(response: Response): Promise<void> {
14
+ try {
15
+ await response.body?.cancel();
16
+ } catch {
17
+ // Ignore cancel errors so the original rejection is preserved.
18
+ }
19
+ }
@@ -1,3 +1,4 @@
1
+ import { cancelResponseBody } from './cancel-response-body';
1
2
  import { DownloadError } from './download-error';
2
3
  import { fetchWithValidatedRedirects } from './fetch-with-validated-redirects';
3
4
  import {
@@ -27,6 +28,9 @@ export async function downloadBlob(
27
28
  });
28
29
 
29
30
  if (!response.ok) {
31
+ // Release the connection before rejecting so an error status from an
32
+ // attacker-controlled origin cannot leak open sockets.
33
+ await cancelResponseBody(response);
30
34
  throw new DownloadError({
31
35
  url,
32
36
  statusCode: response.status,
@@ -1,3 +1,4 @@
1
+ import { cancelResponseBody } from './cancel-response-body';
1
2
  import { DownloadError } from './download-error';
2
3
  import { isBrowserRuntime } from './is-browser-runtime';
3
4
  import { validateDownloadUrl } from './validate-download-url';
@@ -68,6 +69,10 @@ export async function fetchWithValidatedRedirects({
68
69
 
69
70
  const location = response.headers.get('location');
70
71
  if (response.status >= 300 && response.status < 400 && location) {
72
+ // Release the redirect response's connection before moving to the next
73
+ // hop. Whether that hop is followed or rejected by the SSRF guard, an
74
+ // unconsumed 3xx body would leak the underlying socket.
75
+ await cancelResponseBody(response);
71
76
  currentUrl = new URL(location, currentUrl).toString();
72
77
  continue;
73
78
  }
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  type ProviderToolFactory,
44
44
  type ProviderToolFactoryWithOutputSchema,
45
45
  } from './provider-tool-factory';
46
+ export { cancelResponseBody } from './cancel-response-body';
46
47
  export * from './remove-undefined-entries';
47
48
  export * from './resolve';
48
49
  export * from './response-handler';
@@ -1,3 +1,4 @@
1
+ import { cancelResponseBody } from './cancel-response-body';
1
2
  import { DownloadError } from './download-error';
2
3
 
3
4
  /**
@@ -40,6 +41,9 @@ export async function readResponseWithSizeLimit({
40
41
  if (contentLength != null) {
41
42
  const length = parseInt(contentLength, 10);
42
43
  if (!isNaN(length) && length > maxBytes) {
44
+ // Cancel the body so the underlying connection is released back to the
45
+ // pool instead of being left open until the socket is exhausted.
46
+ await cancelResponseBody(response);
43
47
  throw new DownloadError({
44
48
  url,
45
49
  message: `Download of ${url} exceeded maximum size of ${maxBytes} bytes (Content-Length: ${length}).`,