@nuxt/scripts 1.3.3 → 1.3.5

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.
Files changed (103) hide show
  1. package/dist/devtools-client/200.html +1 -1
  2. package/dist/devtools-client/404.html +1 -1
  3. package/dist/devtools-client/_nuxt/{DAubxma0.js → 8ltZ0o6d.js} +1 -1
  4. package/dist/devtools-client/_nuxt/{CBuBrHyL.js → B8_EBdhg.js} +1 -1
  5. package/dist/devtools-client/_nuxt/Bf2Kt1P0.js +28 -0
  6. package/dist/devtools-client/_nuxt/CC0GKROJ.js +1 -0
  7. package/dist/devtools-client/_nuxt/{DvC3yV2P.js → CMXzQxJZ.js} +1 -1
  8. package/dist/devtools-client/_nuxt/{xAZ2vgo3.js → CP7c-Js6.js} +4 -4
  9. package/dist/devtools-client/_nuxt/{DzbSCA_w.js → DTP8MRtW.js} +1 -1
  10. package/dist/devtools-client/_nuxt/{DgAtAWdT.js → Dkc7iTdR.js} +1 -1
  11. package/dist/devtools-client/_nuxt/{WOtyQdps.js → KxC4Q3ev.js} +1 -1
  12. package/dist/devtools-client/_nuxt/builds/latest.json +1 -1
  13. package/dist/devtools-client/_nuxt/builds/meta/173480db-0944-415b-962d-d619682f0042.json +1 -0
  14. package/dist/devtools-client/_nuxt/{entry.9HJPJ6Hp.css → entry.CYUdJFoE.css} +1 -1
  15. package/dist/devtools-client/_nuxt/error-404.Bq2JQbJs.css +1 -0
  16. package/dist/devtools-client/_nuxt/error-500.5E_FOzlc.css +1 -0
  17. package/dist/devtools-client/_nuxt/{pages.BF1VnccH.css → pages.aU4WirOM.css} +1 -1
  18. package/dist/devtools-client/docs/index.html +1 -1
  19. package/dist/devtools-client/first-party/index.html +1 -1
  20. package/dist/devtools-client/index.html +1 -1
  21. package/dist/devtools-client/registry/index.html +1 -1
  22. package/dist/module.d.mts +1 -1
  23. package/dist/module.d.ts +1 -1
  24. package/dist/module.json +1 -1
  25. package/dist/module.mjs +270 -47
  26. package/dist/registry.mjs +2 -2
  27. package/dist/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +71 -21
  28. package/dist/runtime/components/GoogleMaps/useGoogleMapsResource.d.ts +2 -1
  29. package/dist/runtime/components/GoogleMaps/useGoogleMapsResource.js +39 -25
  30. package/dist/runtime/components/ScriptCarbonAds.vue +11 -3
  31. package/dist/runtime/components/ScriptCrisp.vue +4 -1
  32. package/dist/runtime/components/ScriptIntercom.vue +4 -1
  33. package/dist/runtime/components/ScriptLemonSqueezy.d.vue.ts +13 -13
  34. package/dist/runtime/components/ScriptLemonSqueezy.vue +30 -6
  35. package/dist/runtime/components/ScriptLemonSqueezy.vue.d.ts +13 -13
  36. package/dist/runtime/components/ScriptPayPalButtons.vue +8 -1
  37. package/dist/runtime/components/ScriptPayPalMessages.vue +6 -0
  38. package/dist/runtime/components/ScriptStripePricingTable.vue +2 -0
  39. package/dist/runtime/components/ScriptVimeoPlayer.vue +8 -1
  40. package/dist/runtime/composables/useScript.js +121 -44
  41. package/dist/runtime/composables/useScriptEventPage.js +36 -20
  42. package/dist/runtime/composables/useScriptTriggerIdleTimeout.js +19 -5
  43. package/dist/runtime/composables/useScriptTriggerInteraction.js +23 -10
  44. package/dist/runtime/composables/useScriptTriggerServiceWorker.js +21 -18
  45. package/dist/runtime/devtools-standalone-bridge.client.js +28 -3
  46. package/dist/runtime/npm-script-proxy.d.ts +7 -0
  47. package/dist/runtime/npm-script-proxy.js +46 -0
  48. package/dist/runtime/npm-script-stub.js +4 -5
  49. package/dist/runtime/registry/posthog.js +11 -3
  50. package/dist/runtime/registry/speedcurve.js +33 -4
  51. package/dist/runtime/registry/usercentrics.js +4 -21
  52. package/dist/runtime/registry/youtube-player.js +16 -5
  53. package/dist/runtime/server/bluesky-embed.js +15 -6
  54. package/dist/runtime/server/google-maps-geocode-proxy.js +10 -5
  55. package/dist/runtime/server/google-static-maps-proxy.js +20 -13
  56. package/dist/runtime/server/gravatar-proxy.js +48 -11
  57. package/dist/runtime/server/instagram-embed-asset.js +1 -1
  58. package/dist/runtime/server/instagram-embed.js +44 -94
  59. package/dist/runtime/server/proxy-handler.d.ts +2 -1
  60. package/dist/runtime/server/proxy-handler.js +183 -23
  61. package/dist/runtime/server/utils/cache-config.d.ts +14 -0
  62. package/dist/runtime/server/utils/cache-config.js +13 -0
  63. package/dist/runtime/server/utils/cached-upstream.d.ts +23 -2
  64. package/dist/runtime/server/utils/cached-upstream.js +212 -15
  65. package/dist/runtime/server/utils/image-proxy.d.ts +3 -2
  66. package/dist/runtime/server/utils/image-proxy.js +26 -16
  67. package/dist/runtime/server/utils/instagram-embed.d.ts +8 -0
  68. package/dist/runtime/server/utils/instagram-embed.js +155 -0
  69. package/dist/runtime/server/utils/network-dispatcher.node.d.ts +2 -0
  70. package/dist/runtime/server/utils/network-dispatcher.node.js +21 -0
  71. package/dist/runtime/server/utils/network-dispatcher.platform.d.ts +2 -0
  72. package/dist/runtime/server/utils/network-dispatcher.platform.js +5 -0
  73. package/dist/runtime/server/utils/network-host.d.ts +20 -0
  74. package/dist/runtime/server/utils/network-host.js +51 -0
  75. package/dist/runtime/server/utils/network-hostname.d.ts +2 -0
  76. package/dist/runtime/server/utils/network-hostname.js +41 -0
  77. package/dist/runtime/server/utils/privacy.js +55 -19
  78. package/dist/runtime/server/utils/proxy-query.d.ts +2 -0
  79. package/dist/runtime/server/utils/proxy-query.js +10 -0
  80. package/dist/runtime/server/utils/sign.d.ts +1 -1
  81. package/dist/runtime/server/utils/sign.js +1 -1
  82. package/dist/runtime/server/utils/withSigning.d.ts +1 -1
  83. package/dist/runtime/server/utils/withSigning.js +3 -3
  84. package/dist/runtime/server/vercel-insights-sink.js +1 -1
  85. package/dist/runtime/server/x-embed.js +8 -4
  86. package/dist/runtime/unhead-features.d.ts +1 -0
  87. package/dist/runtime/unhead-features.js +3 -0
  88. package/dist/runtime/utils/abortable-promise.d.ts +8 -0
  89. package/dist/runtime/utils/abortable-promise.js +63 -0
  90. package/dist/runtime/utils/after-next-paint.d.ts +1 -1
  91. package/dist/runtime/utils/after-next-paint.js +16 -1
  92. package/dist/runtime/utils/usercentrics-consent.d.ts +10 -0
  93. package/dist/runtime/utils/usercentrics-consent.js +88 -0
  94. package/dist/runtime/utils/youtube-readiness.d.ts +7 -0
  95. package/dist/runtime/utils/youtube-readiness.js +45 -0
  96. package/dist/runtime/utils.d.ts +3 -1
  97. package/dist/runtime/utils.js +29 -0
  98. package/package.json +2 -1
  99. package/dist/devtools-client/_nuxt/BGGk5RYW.js +0 -1
  100. package/dist/devtools-client/_nuxt/C8xLTMBy.js +0 -28
  101. package/dist/devtools-client/_nuxt/builds/meta/0c193d7f-fdc4-4ec6-b4c0-7ba4cc94709b.json +0 -1
  102. package/dist/devtools-client/_nuxt/error-404.CIn5oEzb.css +0 -1
  103. package/dist/devtools-client/_nuxt/error-500.B1YZl8g8.css +0 -1
@@ -1,6 +1,7 @@
1
- import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, readRawBody, setResponseHeader, setResponseStatus } from "h3";
2
- import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
1
+ import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, sendStream, setResponseHeader, setResponseStatus } from "#nuxt-scripts/h3";
2
+ import { useNitroApp, useRuntimeConfig } from "#nuxt-scripts/nitro";
3
3
  import { matchDomain } from "./utils/match-domain.js";
4
+ import { closePublicNetworkDispatcher, createPublicNetworkDispatcher, isPrivateNetworkResolutionError, isPublicNetworkHostname } from "./utils/network-host.js";
4
5
  import {
5
6
  anonymizeIP,
6
7
  mergePrivacy,
@@ -12,7 +13,29 @@ import {
12
13
  } from "./utils/privacy.js";
13
14
  const COMPRESSION_RE = /gzip|deflate|br|compress|base64/i;
14
15
  const CLIENT_HINT_VERSION_RE = /;v="(\d+)\.[^"]*"/g;
15
- const SKIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["set-cookie", "transfer-encoding", "content-encoding", "content-length"]);
16
+ const MAX_TRANSFORM_BODY_SIZE = 2 * 1024 * 1024;
17
+ const UPSTREAM_TIMEOUT_MS = 15e3;
18
+ const SKIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
19
+ "alt-svc",
20
+ "clear-site-data",
21
+ "connection",
22
+ "content-encoding",
23
+ "content-length",
24
+ "keep-alive",
25
+ "nel",
26
+ "proxy-authenticate",
27
+ "proxy-authorization",
28
+ "report-to",
29
+ "reporting-endpoints",
30
+ "set-cookie",
31
+ "set-cookie2",
32
+ "strict-transport-security",
33
+ "te",
34
+ "trailer",
35
+ "transfer-encoding",
36
+ "upgrade",
37
+ "www-authenticate"
38
+ ]);
16
39
  export const SKIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
17
40
  "connection",
18
41
  "keep-alive",
@@ -23,12 +46,101 @@ export const SKIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
23
46
  "transfer-encoding",
24
47
  "upgrade"
25
48
  ]);
49
+ async function readTransformBody(event) {
50
+ const contentLength = Number(getHeaders(event)["content-length"] || 0);
51
+ if (Number.isFinite(contentLength) && contentLength > MAX_TRANSFORM_BODY_SIZE) {
52
+ throw createError({ statusCode: 413, statusMessage: "Proxy request body too large" });
53
+ }
54
+ const stream = getRequestWebStream(event);
55
+ if (!stream)
56
+ return void 0;
57
+ const reader = stream.getReader();
58
+ const chunks = [];
59
+ let total = 0;
60
+ try {
61
+ while (true) {
62
+ const { done, value } = await reader.read();
63
+ if (done)
64
+ break;
65
+ if (!value)
66
+ continue;
67
+ total += value.byteLength;
68
+ if (total > MAX_TRANSFORM_BODY_SIZE) {
69
+ try {
70
+ await reader.cancel("Proxy request body too large");
71
+ } catch {
72
+ }
73
+ throw createError({ statusCode: 413, statusMessage: "Proxy request body too large" });
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ } finally {
78
+ reader.releaseLock();
79
+ }
80
+ const body = new Uint8Array(total);
81
+ let offset = 0;
82
+ for (const chunk of chunks) {
83
+ body.set(chunk, offset);
84
+ offset += chunk.byteLength;
85
+ }
86
+ return new TextDecoder().decode(body);
87
+ }
88
+ export function withResponseBodyIdleTimeout(body, timeoutMs, onTimeout) {
89
+ const reader = body.getReader();
90
+ let stopped = false;
91
+ let timeoutId;
92
+ const clearIdleTimeout = () => {
93
+ if (timeoutId !== void 0) {
94
+ clearTimeout(timeoutId);
95
+ timeoutId = void 0;
96
+ }
97
+ };
98
+ return new ReadableStream({
99
+ async pull(controller) {
100
+ timeoutId = setTimeout(() => {
101
+ stopped = true;
102
+ const error = createError({
103
+ statusCode: 504,
104
+ statusMessage: "Gateway Timeout",
105
+ message: "Upstream response body timed out"
106
+ });
107
+ onTimeout();
108
+ controller.error(error);
109
+ void reader.cancel(error).catch((cancelError) => {
110
+ Object.assign(error, { cause: cancelError });
111
+ });
112
+ }, timeoutMs);
113
+ const result = await reader.read().catch((error) => {
114
+ clearIdleTimeout();
115
+ if (!stopped)
116
+ controller.error(error);
117
+ return void 0;
118
+ });
119
+ clearIdleTimeout();
120
+ if (!result || stopped)
121
+ return;
122
+ if (result.done) {
123
+ stopped = true;
124
+ controller.close();
125
+ return;
126
+ }
127
+ controller.enqueue(result.value);
128
+ },
129
+ async cancel(reason) {
130
+ stopped = true;
131
+ clearIdleTimeout();
132
+ await reader.cancel(reason);
133
+ }
134
+ });
135
+ }
26
136
  function stripQueryFingerprinting(query, privacy) {
27
137
  const stripped = stripPayloadFingerprinting(query, privacy);
28
138
  const params = new URLSearchParams();
29
139
  for (const [key, value] of Object.entries(stripped)) {
30
- if (value !== void 0 && value !== null) {
31
- params.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
140
+ const values = Array.isArray(value) ? value : [value];
141
+ for (const item of values) {
142
+ if (item !== void 0 && item !== null)
143
+ params.append(key, typeof item === "object" ? JSON.stringify(item) : String(item));
32
144
  }
33
145
  }
34
146
  return { queryString: params.toString(), stripped };
@@ -61,6 +173,13 @@ export default defineEventHandler(async (event) => {
61
173
  message: `No domain in proxy path: ${path}`
62
174
  });
63
175
  }
176
+ if (!isPublicNetworkHostname(domain)) {
177
+ log("[proxy] Rejected local or non-public target:", domain);
178
+ throw createError({
179
+ statusCode: 403,
180
+ statusMessage: "Local network targets are not allowed"
181
+ });
182
+ }
64
183
  let perScriptInput;
65
184
  for (const [configDomain, privacyInput] of Object.entries(domainPrivacy)) {
66
185
  if (matchDomain(domain, configDomain)) {
@@ -168,7 +287,7 @@ export default defineEventHandler(async (event) => {
168
287
  if (!shouldTransformBody) {
169
288
  passthroughBody = true;
170
289
  } else if (transformableBodyType === "form") {
171
- const formBody = await readRawBody(event);
290
+ const formBody = await readTransformBody(event);
172
291
  rawBody = formBody;
173
292
  if (formBody != null) {
174
293
  const params = new URLSearchParams(formBody);
@@ -196,7 +315,18 @@ export default defineEventHandler(async (event) => {
196
315
  body = transformed.toString();
197
316
  }
198
317
  } else {
199
- rawBody = await readBody(event);
318
+ const jsonBody = await readTransformBody(event);
319
+ if (jsonBody !== void 0) {
320
+ try {
321
+ rawBody = JSON.parse(jsonBody);
322
+ } catch (error) {
323
+ throw createError({
324
+ statusCode: 400,
325
+ statusMessage: "Invalid JSON proxy request body",
326
+ cause: error
327
+ });
328
+ }
329
+ }
200
330
  if (Array.isArray(rawBody)) {
201
331
  body = rawBody.map(
202
332
  (item) => item && typeof item === "object" && !Array.isArray(item) ? stripPayloadFingerprinting(item, privacy) : item
@@ -209,7 +339,7 @@ export default defineEventHandler(async (event) => {
209
339
  }
210
340
  }
211
341
  const nitro = useNitroApp();
212
- await nitro.hooks.callHook("nuxt-scripts:proxy", {
342
+ await nitro.hooks?.callHook?.("nuxt-scripts:proxy", {
213
343
  timestamp: Date.now(),
214
344
  path: event.path,
215
345
  targetUrl,
@@ -233,7 +363,7 @@ export default defineEventHandler(async (event) => {
233
363
  const timeoutId = setTimeout(() => {
234
364
  timedOut = true;
235
365
  controller.abort();
236
- }, 15e3);
366
+ }, UPSTREAM_TIMEOUT_MS);
237
367
  let fetchBody;
238
368
  if (passthroughBody && originalHeaders["content-length"] !== "0") {
239
369
  fetchBody = getRequestWebStream(event);
@@ -241,43 +371,73 @@ export default defineEventHandler(async (event) => {
241
371
  fetchBody = transformableBodyType === "json" ? JSON.stringify(body) : String(body);
242
372
  }
243
373
  let response;
374
+ let network;
244
375
  try {
245
- response = await fetch(targetUrl, {
376
+ network = await createPublicNetworkDispatcher();
377
+ const requestInit = {
246
378
  method: method || "GET",
247
379
  headers,
248
380
  body: fetchBody,
249
381
  credentials: "omit",
250
382
  // Don't send cookies to third parties
251
383
  signal: controller.signal,
252
- // @ts-expect-error Node fetch supports duplex for streaming request bodies
384
+ redirect: "manual",
253
385
  duplex: fetchBody instanceof ReadableStream ? "half" : void 0
254
- });
386
+ };
387
+ response = await network.fetch(targetUrl, requestInit);
388
+ clearTimeout(timeoutId);
255
389
  } catch (err) {
390
+ clearTimeout(timeoutId);
391
+ await closePublicNetworkDispatcher(network, err);
256
392
  log("[proxy] Upstream error:", err);
393
+ const blockedPrivateNetwork = isPrivateNetworkResolutionError(err);
257
394
  throw createError({
258
- statusCode: timedOut ? 504 : 502,
259
- statusMessage: timedOut ? "Gateway Timeout" : "Bad Gateway",
260
- message: `Proxy upstream request failed: ${targetUrl}`,
395
+ statusCode: blockedPrivateNetwork ? 403 : timedOut ? 504 : 502,
396
+ statusMessage: blockedPrivateNetwork ? "Local network targets are not allowed" : timedOut ? "Gateway Timeout" : "Bad Gateway",
397
+ message: "Proxy upstream request failed",
261
398
  cause: err,
262
399
  data: {
263
400
  errorName: err?.name,
264
401
  errorCode: timedOut ? "TIMEOUT" : err?.code
265
402
  }
266
403
  });
267
- } finally {
268
- clearTimeout(timeoutId);
269
404
  }
270
405
  log("[proxy] Response:", response.status, response.statusText);
406
+ if (response.status >= 300 && response.status < 400 && response.status !== 304) {
407
+ clearTimeout(timeoutId);
408
+ const redirectError = createError({
409
+ statusCode: 502,
410
+ statusMessage: "Unsafe upstream redirect",
411
+ message: "Proxy upstream returned a redirect that was not followed"
412
+ });
413
+ await response.body?.cancel(redirectError).catch((cancelError) => Object.assign(redirectError, { cleanupError: cancelError }));
414
+ await closePublicNetworkDispatcher(network, redirectError);
415
+ throw redirectError;
416
+ }
417
+ const responseConnectionHeaders = new Set(
418
+ (response.headers.get("connection") || "").split(",").map((header) => header.trim().toLowerCase()).filter(Boolean)
419
+ );
271
420
  response.headers.forEach((value, key) => {
272
- if (!SKIP_RESPONSE_HEADERS.has(key.toLowerCase())) {
421
+ const lowerKey = key.toLowerCase();
422
+ if (!SKIP_RESPONSE_HEADERS.has(lowerKey) && !responseConnectionHeaders.has(lowerKey)) {
273
423
  setResponseHeader(event, key, value);
274
424
  }
275
425
  });
426
+ setResponseHeader(event, "Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
427
+ setResponseHeader(event, "X-Content-Type-Options", "nosniff");
276
428
  setResponseStatus(event, response.status, response.statusText);
277
- const responseContentType = response.headers.get("content-type") || "";
278
- const isTextContent = responseContentType.includes("text") || responseContentType.includes("javascript") || responseContentType.includes("json");
279
- if (isTextContent) {
280
- return await response.text();
429
+ if (!response.body) {
430
+ await closePublicNetworkDispatcher(network);
431
+ return null;
432
+ }
433
+ const guardedBody = withResponseBodyIdleTimeout(response.body, UPSTREAM_TIMEOUT_MS, () => controller.abort());
434
+ let streamError;
435
+ try {
436
+ return await sendStream(event, guardedBody);
437
+ } catch (error) {
438
+ streamError = error;
439
+ throw error;
440
+ } finally {
441
+ await closePublicNetworkDispatcher(network, streamError);
281
442
  }
282
- return Buffer.from(await response.arrayBuffer());
283
443
  });
@@ -0,0 +1,14 @@
1
+ /** Dedicated Nitro storage mount used by Nuxt Scripts' upstream caches. */
2
+ export declare const NUXT_SCRIPTS_CACHE_BASE = "nuxt-scripts-cache";
3
+ /**
4
+ * Conservative defaults for the in-process fallback. Applications can replace
5
+ * this mount with Redis, KV, filesystem, or their own storage configuration.
6
+ */
7
+ export declare const NUXT_SCRIPTS_CACHE_MAX_ENTRIES = 500;
8
+ export declare const NUXT_SCRIPTS_CACHE_MAX_SIZE: number;
9
+ export declare const NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE: number;
10
+ interface NitroOptionsWithStorage {
11
+ storage?: Record<string, unknown>;
12
+ }
13
+ export declare function ensureNuxtScriptsCacheStorage(nitroOptions: NitroOptionsWithStorage): void;
14
+ export {};
@@ -0,0 +1,13 @@
1
+ export const NUXT_SCRIPTS_CACHE_BASE = "nuxt-scripts-cache";
2
+ export const NUXT_SCRIPTS_CACHE_MAX_ENTRIES = 500;
3
+ export const NUXT_SCRIPTS_CACHE_MAX_SIZE = 32 * 1024 * 1024;
4
+ export const NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE = 8 * 1024 * 1024;
5
+ export function ensureNuxtScriptsCacheStorage(nitroOptions) {
6
+ nitroOptions.storage ||= {};
7
+ nitroOptions.storage[NUXT_SCRIPTS_CACHE_BASE] ||= {
8
+ driver: "lru-cache",
9
+ max: NUXT_SCRIPTS_CACHE_MAX_ENTRIES,
10
+ maxSize: NUXT_SCRIPTS_CACHE_MAX_SIZE,
11
+ maxEntrySize: NUXT_SCRIPTS_CACHE_MAX_ENTRY_SIZE
12
+ };
13
+ }
@@ -33,15 +33,36 @@ export interface CachedBinaryFetchOptions {
33
33
  redirect?: 'follow' | 'manual';
34
34
  ignoreResponseError?: boolean;
35
35
  }
36
+ export interface CachedBinaryFetchConfig {
37
+ /** Validate the initial URL and every redirect before requesting it. */
38
+ allowUrl?: (url: URL) => boolean;
39
+ /** Reject invalid payload types before reading or caching the response body. */
40
+ allowContentType?: (contentType: string) => boolean;
41
+ maxRedirects?: number;
42
+ /** Maximum decoded response size stored in cache. Defaults to 10 MiB. */
43
+ maxResponseBytes?: number;
44
+ }
45
+ export interface CachedJsonFetchConfig<T> {
46
+ /** Validate the initial URL and every redirect before requesting it. */
47
+ allowUrl: (url: URL) => boolean;
48
+ maxRedirects?: number;
49
+ responseType?: 'json' | 'text';
50
+ contentTypePrefixes?: string[];
51
+ /** Maximum decoded response size stored in cache. Defaults to 2 MiB. */
52
+ maxResponseBytes?: number;
53
+ /** Runs before caching. Throw to reject an invalid upstream response. */
54
+ validateResponse?: (data: T) => void;
55
+ }
36
56
  export interface CachedBinaryResult extends CachedBinaryResponse {
37
57
  body: Buffer;
38
58
  status: number;
39
59
  }
60
+ export declare function isSafeHttpsUrl(url: URL): boolean;
40
61
  /**
41
62
  * Cache upstream binary/image fetches. Returns a helper that restores the
42
63
  * response body as a Buffer so the handler can pipe it straight to the client.
43
64
  */
44
- export declare function createCachedBinaryFetch(name: string, maxAge: number): (url: string, opts?: CachedBinaryFetchOptions) => Promise<CachedBinaryResult>;
65
+ export declare function createCachedBinaryFetch(name: string, maxAge: number, config?: CachedBinaryFetchConfig): (url: string, opts?: CachedBinaryFetchOptions) => Promise<CachedBinaryResult>;
45
66
  /**
46
67
  * Cache upstream JSON/text fetches. `getKey` is caller-controlled so handlers
47
68
  * can normalize on whichever inner params identify the resource (tweet ID,
@@ -50,7 +71,7 @@ export declare function createCachedBinaryFetch(name: string, maxAge: number): (
50
71
  */
51
72
  export declare function createCachedJsonFetch<T>(name: string, maxAge: number, getKey: (url: string, opts?: {
52
73
  headers?: Record<string, string>;
53
- }) => string): (url: string, opts?: {
74
+ }) => string, config: CachedJsonFetchConfig<T>): (url: string, opts?: {
54
75
  headers?: Record<string, string>;
55
76
  timeout?: number;
56
77
  }) => Promise<T>;
@@ -1,25 +1,201 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { defineCachedFunction } from "nitropack/runtime";
3
- import { $fetch } from "ofetch";
2
+ import { createFetch } from "ofetch";
4
3
  import { hash } from "ohash";
5
- export function createCachedBinaryFetch(name, maxAge) {
4
+ import { defineCachedFunction } from "#nuxt-scripts/nitro";
5
+ import { NUXT_SCRIPTS_CACHE_BASE } from "./cache-config.js";
6
+ import { closePublicNetworkDispatcher, createPublicNetworkDispatcher, isPublicNetworkHostname } from "./network-host.js";
7
+ const DEFAULT_BINARY_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
8
+ const DEFAULT_JSON_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
9
+ export function isSafeHttpsUrl(url) {
10
+ return url.protocol === "https:" && !url.username && !url.password && (!url.port || url.port === "443") && isPublicNetworkHostname(url.hostname);
11
+ }
12
+ function upstreamError(message, statusCode, statusMessage, cause) {
13
+ return Object.assign(new Error(message, cause === void 0 ? void 0 : { cause }), {
14
+ statusCode,
15
+ statusMessage
16
+ });
17
+ }
18
+ function resolveMaxResponseBytes(value, fallback) {
19
+ const maxBytes = value ?? fallback;
20
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
21
+ throw new TypeError("maxResponseBytes must be a non-negative safe integer");
22
+ return maxBytes;
23
+ }
24
+ async function cancelBody(body, error) {
25
+ if (!body)
26
+ return;
27
+ await body.cancel(error).catch((cancelError) => {
28
+ if (error) {
29
+ Object.assign(error, { cause: cancelError });
30
+ return;
31
+ }
32
+ throw cancelError;
33
+ });
34
+ }
35
+ async function readBoundedResponseBody(body, options) {
36
+ if (!body)
37
+ return new Uint8Array();
38
+ const reader = body.getReader();
39
+ const chunks = [];
40
+ let size = 0;
41
+ let timeoutId;
42
+ const timeoutError = upstreamError("Upstream response body timed out", 504, "Gateway Timeout");
43
+ const timeout = options.timeoutMs > 0 ? new Promise((_resolve, reject) => {
44
+ timeoutId = setTimeout(() => {
45
+ reject(timeoutError);
46
+ void reader.cancel(timeoutError).catch((cancelError) => Object.assign(timeoutError, { cause: cancelError }));
47
+ }, options.timeoutMs);
48
+ }) : void 0;
49
+ try {
50
+ while (true) {
51
+ const result = await (timeout ? Promise.race([reader.read(), timeout]) : reader.read());
52
+ if (result.done)
53
+ break;
54
+ size += result.value.byteLength;
55
+ if (size > options.maxBytes) {
56
+ const error = upstreamError(
57
+ `Upstream response exceeded ${options.maxBytes} bytes`,
58
+ 502,
59
+ "Upstream response too large"
60
+ );
61
+ await reader.cancel(error).catch((cancelError) => Object.assign(error, { cause: cancelError }));
62
+ throw error;
63
+ }
64
+ chunks.push(result.value);
65
+ }
66
+ } finally {
67
+ if (timeoutId !== void 0)
68
+ clearTimeout(timeoutId);
69
+ }
70
+ const data = new Uint8Array(size);
71
+ let offset = 0;
72
+ for (const chunk of chunks) {
73
+ data.set(chunk, offset);
74
+ offset += chunk.byteLength;
75
+ }
76
+ return data;
77
+ }
78
+ async function rejectResponse(response, error) {
79
+ await cancelBody(response._data, error);
80
+ throw error;
81
+ }
82
+ function parseUpstreamUrl(url) {
83
+ try {
84
+ return new URL(url);
85
+ } catch (cause) {
86
+ throw upstreamError("Upstream URL is invalid", 400, "Invalid upstream URL", cause);
87
+ }
88
+ }
89
+ function assertAllowedUrl(url, allowUrl) {
90
+ if (allowUrl && !allowUrl(url))
91
+ throw upstreamError("Upstream URL is not allowed", 403, "Upstream URL not allowed");
92
+ }
93
+ function resolveRedirect(response, currentUrl, redirectCount, policy) {
94
+ if (response.status < 300 || response.status >= 400 || response.status === 304)
95
+ return;
96
+ const location = response.headers.get("location");
97
+ if (!location)
98
+ throw upstreamError("Upstream redirect has no Location header", 502, "Invalid upstream redirect");
99
+ if (redirectCount >= (policy.maxRedirects ?? 5))
100
+ throw upstreamError("Upstream redirect limit exceeded", 502, "Too many upstream redirects");
101
+ let nextUrl;
102
+ try {
103
+ nextUrl = new URL(location, currentUrl);
104
+ } catch (cause) {
105
+ throw upstreamError("Upstream redirect URL is invalid", 502, "Invalid upstream redirect", cause);
106
+ }
107
+ assertAllowedUrl(nextUrl, policy.allowUrl);
108
+ return nextUrl;
109
+ }
110
+ async function fetchBoundedUpstream(url, options) {
111
+ let currentUrl = parseUpstreamUrl(url);
112
+ assertAllowedUrl(currentUrl, options.allowUrl);
113
+ if (options.redirect === "follow" && !options.allowUrl)
114
+ throw new Error("Automatic redirects require an allowUrl redirect policy");
115
+ const network = await createPublicNetworkDispatcher();
116
+ let primaryError;
117
+ try {
118
+ const safeFetch = createFetch({ fetch: network.fetch });
119
+ for (let redirectCount = 0; ; redirectCount++) {
120
+ const response = await safeFetch.raw(currentUrl.toString(), {
121
+ responseType: "stream",
122
+ timeout: options.timeoutMs,
123
+ redirect: "manual",
124
+ ignoreResponseError: true,
125
+ headers: options.headers
126
+ });
127
+ if (options.redirect === "follow") {
128
+ let nextUrl;
129
+ try {
130
+ nextUrl = resolveRedirect(response, currentUrl, redirectCount, options);
131
+ } catch (error) {
132
+ await rejectResponse(response, error);
133
+ }
134
+ if (nextUrl) {
135
+ await cancelBody(response._data);
136
+ currentUrl = nextUrl;
137
+ continue;
138
+ }
139
+ }
140
+ if (!options.ignoreResponseError && response.status >= 400 && response.status < 600) {
141
+ await rejectResponse(response, upstreamError(
142
+ `Upstream request failed with status ${response.status}`,
143
+ response.status,
144
+ response.statusText || "Upstream request failed"
145
+ ));
146
+ }
147
+ const contentType = response.headers.get("content-type");
148
+ const isRedirect = response.status >= 300 && response.status < 400 && response.status !== 304;
149
+ if (!isRedirect && (options.allowContentType || options.contentTypePrefixes?.length)) {
150
+ const normalizedContentType = contentType?.split(";", 1)[0]?.trim().toLowerCase();
151
+ if (options.contentTypePrefixes?.length && (!normalizedContentType || !options.contentTypePrefixes.some((prefix) => normalizedContentType.startsWith(prefix)))) {
152
+ await rejectResponse(
153
+ response,
154
+ upstreamError("Upstream response content type is not allowed", 415, "Unsupported upstream content type")
155
+ );
156
+ }
157
+ if (options.allowContentType && (!normalizedContentType || !options.allowContentType(normalizedContentType))) {
158
+ await rejectResponse(
159
+ response,
160
+ upstreamError("Upstream response content type is not allowed", 415, "Unsupported upstream content type")
161
+ );
162
+ }
163
+ }
164
+ const data = await readBoundedResponseBody(response._data, {
165
+ maxBytes: options.maxResponseBytes,
166
+ timeoutMs: options.timeoutMs
167
+ });
168
+ return { contentType, data, status: response.status };
169
+ }
170
+ } catch (error) {
171
+ primaryError = error;
172
+ throw error;
173
+ } finally {
174
+ await closePublicNetworkDispatcher(network, primaryError);
175
+ }
176
+ }
177
+ export function createCachedBinaryFetch(name, maxAge, config = {}) {
178
+ const maxResponseBytes = resolveMaxResponseBytes(config.maxResponseBytes, DEFAULT_BINARY_MAX_RESPONSE_BYTES);
6
179
  const cached = defineCachedFunction(
7
180
  async (url, opts) => {
8
- const response = await $fetch.raw(url, {
9
- responseType: "arrayBuffer",
10
- timeout: opts?.timeout ?? 1e4,
11
- redirect: opts?.redirect ?? "follow",
181
+ const response = await fetchBoundedUpstream(url, {
182
+ allowContentType: config.allowContentType,
183
+ allowUrl: config.allowUrl,
184
+ headers: opts?.headers,
12
185
  ignoreResponseError: opts?.ignoreResponseError ?? false,
13
- headers: opts?.headers
186
+ maxRedirects: config.maxRedirects,
187
+ maxResponseBytes,
188
+ redirect: opts?.redirect ?? (config.allowUrl ? "follow" : "manual"),
189
+ timeoutMs: opts?.timeout ?? 1e4
14
190
  });
15
- const data = response._data;
16
191
  return {
17
- base64: data ? Buffer.from(data).toString("base64") : "",
18
- contentType: response.headers.get("content-type"),
192
+ base64: response.data.byteLength ? Buffer.from(response.data).toString("base64") : "",
193
+ contentType: response.contentType,
19
194
  status: response.status
20
195
  };
21
196
  },
22
197
  {
198
+ base: NUXT_SCRIPTS_CACHE_BASE,
23
199
  name,
24
200
  maxAge,
25
201
  swr: true,
@@ -49,15 +225,36 @@ export function createCachedBinaryFetch(name, maxAge) {
49
225
  };
50
226
  };
51
227
  }
52
- export function createCachedJsonFetch(name, maxAge, getKey) {
228
+ export function createCachedJsonFetch(name, maxAge, getKey, config) {
229
+ const maxResponseBytes = resolveMaxResponseBytes(config.maxResponseBytes, DEFAULT_JSON_MAX_RESPONSE_BYTES);
53
230
  return defineCachedFunction(
54
231
  async (url, opts) => {
55
- return await $fetch(url, {
56
- timeout: opts?.timeout ?? 1e4,
57
- headers: opts?.headers
232
+ const response = await fetchBoundedUpstream(url, {
233
+ allowUrl: config.allowUrl,
234
+ contentTypePrefixes: config.contentTypePrefixes,
235
+ headers: opts?.headers,
236
+ ignoreResponseError: false,
237
+ maxRedirects: config.maxRedirects,
238
+ maxResponseBytes,
239
+ redirect: "follow",
240
+ timeoutMs: opts?.timeout ?? 1e4
58
241
  });
242
+ const text = new TextDecoder().decode(response.data);
243
+ let data;
244
+ if (config.responseType === "text") {
245
+ data = text;
246
+ } else {
247
+ try {
248
+ data = JSON.parse(text);
249
+ } catch (cause) {
250
+ throw upstreamError("Upstream response is not valid JSON", 502, "Invalid upstream response", cause);
251
+ }
252
+ }
253
+ config.validateResponse?.(data);
254
+ return data;
59
255
  },
60
256
  {
257
+ base: NUXT_SCRIPTS_CACHE_BASE,
61
258
  name,
62
259
  maxAge,
63
260
  swr: true,
@@ -3,8 +3,9 @@ export interface ImageProxyConfig {
3
3
  accept?: string;
4
4
  userAgent?: string;
5
5
  cacheMaxAge?: number;
6
- contentType?: string;
7
- /** Follow redirects (default: true). Set to false to reject redirects (SSRF protection). */
6
+ /** Allowed response Content-Type prefixes. SVG is always rejected as active content. */
7
+ contentTypePrefixes?: string[];
8
+ /** Follow redirects after applying the same URL allowlist to every hop. */
8
9
  followRedirects?: boolean;
9
10
  /** Decode &amp; in URL query parameter */
10
11
  decodeAmpersands?: boolean;