@nuxt/scripts 1.0.0-rc.6 → 1.0.0-rc.7

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 (39) hide show
  1. package/bin/cli.mjs +2 -0
  2. package/dist/cli.d.mts +2 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.mjs +50 -0
  5. package/dist/devtools-client/200.html +1 -1
  6. package/dist/devtools-client/404.html +1 -1
  7. package/dist/devtools-client/_nuxt/{HLYrIHDq.js → 5D-5agUu.js} +4 -4
  8. package/dist/devtools-client/_nuxt/{B7jHEBMT.js → BDlZgWHO.js} +1 -1
  9. package/dist/devtools-client/_nuxt/{BaiqmiV1.js → BntLcF3H.js} +1 -1
  10. package/dist/devtools-client/_nuxt/{qfgRjj7S.js → CC9d18RE.js} +1 -1
  11. package/dist/devtools-client/_nuxt/{CLjOeO0G.js → CaQ1scfO.js} +1 -1
  12. package/dist/devtools-client/_nuxt/{Chi3DhDl.js → DJ5bfe9v.js} +1 -1
  13. package/dist/devtools-client/_nuxt/{BjmZlwuw.js → YKhzFESo.js} +1 -1
  14. package/dist/devtools-client/_nuxt/builds/latest.json +1 -1
  15. package/dist/devtools-client/_nuxt/builds/meta/7a96fd5e-d239-4ba5-816b-05034a861ba0.json +1 -0
  16. package/dist/devtools-client/docs/index.html +1 -1
  17. package/dist/devtools-client/first-party/index.html +1 -1
  18. package/dist/devtools-client/index.html +1 -1
  19. package/dist/devtools-client/registry/index.html +1 -1
  20. package/dist/module.d.mts +51 -2
  21. package/dist/module.d.ts +51 -2
  22. package/dist/module.json +1 -1
  23. package/dist/module.mjs +80 -9
  24. package/dist/registry.mjs +10 -10
  25. package/dist/runtime/server/bluesky-embed.js +3 -2
  26. package/dist/runtime/server/google-maps-geocode-proxy.js +3 -2
  27. package/dist/runtime/server/google-static-maps-proxy.js +3 -2
  28. package/dist/runtime/server/gravatar-proxy.js +3 -2
  29. package/dist/runtime/server/instagram-embed.js +3 -2
  30. package/dist/runtime/server/utils/image-proxy.js +3 -2
  31. package/dist/runtime/server/utils/sign.d.ts +109 -0
  32. package/dist/runtime/server/utils/sign.js +88 -0
  33. package/dist/runtime/server/utils/withSigning.d.ts +23 -0
  34. package/dist/runtime/server/utils/withSigning.js +18 -0
  35. package/dist/runtime/server/x-embed.js +3 -2
  36. package/dist/runtime/types.d.ts +9 -1
  37. package/dist/types.d.mts +2 -2
  38. package/package.json +5 -1
  39. package/dist/devtools-client/_nuxt/builds/meta/640f0a39-e659-4a31-8b8d-adbd9af52f1e.json +0 -1
package/dist/module.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync, appendFileSync, writeFileSync, readdirSync } from 'node:fs';
2
3
  import { useNuxt, addDevServerHandler, extendRouteRules, tryUseNuxt, createResolver, extendViteConfig, logger as logger$1, useLogger, addTypeTemplate, defineNuxtModule, addPluginTemplate, addServerHandler, addImports, addComponentsDir, addTemplate, hasNuxtModule, addBuildPlugin } from '@nuxt/kit';
3
4
  import { defu } from 'defu';
4
5
  import { join, resolve } from 'pathe';
@@ -12,7 +13,6 @@ import { isCI, provider } from 'std-env';
12
13
  import { parseAndWalk, ScopeTracker, walk, ScopeTrackerFunction, ScopeTrackerIdentifier, ScopeTrackerFunctionParam, ScopeTrackerVariable } from 'oxc-walker';
13
14
  import { createUnplugin } from 'unplugin';
14
15
  import { pathToFileURL } from 'node:url';
15
- import { createHash } from 'node:crypto';
16
16
  import fsp from 'node:fs/promises';
17
17
  import { colors } from 'consola/utils';
18
18
  import MagicString from 'magic-string';
@@ -1373,6 +1373,42 @@ function fixSelfClosingScriptComponents(nuxt) {
1373
1373
  }
1374
1374
  const UPPER_RE = /([A-Z])/g;
1375
1375
  const toScreamingSnake = (s) => s.replace(UPPER_RE, "_$1").toUpperCase();
1376
+ const PROXY_SECRET_ENV_KEY = "NUXT_SCRIPTS_PROXY_SECRET";
1377
+ const PROXY_SECRET_ENV_LINE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=/m;
1378
+ const PROXY_SECRET_ENV_VALUE_RE = /^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m;
1379
+ function resolveProxySecret(rootDir, isDev, configSecret, autoGenerate = true) {
1380
+ if (configSecret)
1381
+ return { secret: configSecret, ephemeral: false, source: "config" };
1382
+ const envSecret = process.env[PROXY_SECRET_ENV_KEY];
1383
+ if (envSecret)
1384
+ return { secret: envSecret, ephemeral: false, source: "env" };
1385
+ if (!isDev || !autoGenerate)
1386
+ return void 0;
1387
+ const secret = randomBytes(32).toString("hex");
1388
+ const envPath = resolve(rootDir, ".env");
1389
+ const line = `${PROXY_SECRET_ENV_KEY}=${secret}
1390
+ `;
1391
+ try {
1392
+ if (existsSync(envPath)) {
1393
+ const contents = readFileSync(envPath, "utf-8");
1394
+ if (PROXY_SECRET_ENV_LINE_RE.test(contents)) {
1395
+ const match = contents.match(PROXY_SECRET_ENV_VALUE_RE);
1396
+ if (match?.[1])
1397
+ return { secret: match[1].trim(), ephemeral: false, source: "dotenv-generated" };
1398
+ }
1399
+ appendFileSync(envPath, contents.endsWith("\n") ? line : `
1400
+ ${line}`);
1401
+ } else {
1402
+ writeFileSync(envPath, `# Generated by @nuxt/scripts
1403
+ ${line}`);
1404
+ }
1405
+ process.env[PROXY_SECRET_ENV_KEY] = secret;
1406
+ return { secret, ephemeral: false, source: "dotenv-generated" };
1407
+ } catch {
1408
+ process.env[PROXY_SECRET_ENV_KEY] = secret;
1409
+ return { secret, ephemeral: true, source: "memory-generated" };
1410
+ }
1411
+ }
1376
1412
  function isProxyDisabled(registryKey, registry2, runtimeConfig) {
1377
1413
  const entry = registry2?.[registryKey];
1378
1414
  if (!entry)
@@ -1684,12 +1720,12 @@ They will load directly from third-party servers.`
1684
1720
  if (totalDomains > 0 && nuxt.options.dev) {
1685
1721
  logger.success(`Proxy mode enabled for ${registryKeys.length} script(s), ${totalDomains} domain(s) proxied (privacy: ${privacyLabel})`);
1686
1722
  }
1687
- const staticPresets = ["static", "github-pages", "cloudflare-pages-static", "netlify-static", "azure-static", "firebase-static"];
1688
- const preset = process.env.NITRO_PRESET || "";
1689
- if (staticPresets.includes(preset)) {
1723
+ const proxyStaticPresets = ["static", "github-pages", "cloudflare-pages-static", "netlify-static", "azure-static", "firebase-static"];
1724
+ const proxyPreset = process.env.NITRO_PRESET || "";
1725
+ if (proxyStaticPresets.includes(proxyPreset)) {
1690
1726
  logger.warn(
1691
- `Proxy collection endpoints require a server runtime (detected: ${preset || "static"}).
1692
- Scripts will be bundled, but collection requests will not be proxied.
1727
+ `Proxy collection endpoints require a server runtime (detected: ${proxyPreset || "static"}).
1728
+ Scripts will be bundled, but collection requests will not be proxied and URL signing will be unavailable.
1693
1729
  Options: configure platform rewrites, switch to server-rendered mode, or disable with proxy: false.`
1694
1730
  );
1695
1731
  }
@@ -1736,6 +1772,7 @@ Options: configure platform rewrites, switch to server-rendered mode, or disable
1736
1772
  });
1737
1773
  const scriptsPrefix = config.prefix || "/_scripts";
1738
1774
  const enabledEndpoints = {};
1775
+ let anyHandlerRequiresSigning = false;
1739
1776
  for (const script of scripts) {
1740
1777
  if (!script.serverHandlers?.length || !script.registryKey)
1741
1778
  continue;
@@ -1744,11 +1781,14 @@ Options: configure platform rewrites, switch to server-rendered mode, or disable
1744
1781
  continue;
1745
1782
  enabledEndpoints[script.registryKey] = true;
1746
1783
  for (const handler of script.serverHandlers) {
1784
+ const resolvedRoute = handler.route.replace("/_scripts", scriptsPrefix);
1747
1785
  addServerHandler({
1748
- route: handler.route.replace("/_scripts", scriptsPrefix),
1786
+ route: resolvedRoute,
1749
1787
  handler: handler.handler,
1750
1788
  middleware: handler.middleware
1751
1789
  });
1790
+ if (handler.requiresSigning)
1791
+ anyHandlerRequiresSigning = true;
1752
1792
  }
1753
1793
  if (script.registryKey === "gravatar") {
1754
1794
  const gravatarConfig = config.registry?.gravatar?.[0] || {};
@@ -1768,7 +1808,38 @@ Options: configure platform rewrites, switch to server-rendered mode, or disable
1768
1808
  { endpoints: enabledEndpoints },
1769
1809
  nuxt.options.runtimeConfig.public["nuxt-scripts"]
1770
1810
  );
1811
+ const staticPresets = ["static", "github-pages", "cloudflare-pages-static", "netlify-static", "azure-static", "firebase-static"];
1812
+ const nitroPreset = process.env.NITRO_PRESET || "";
1813
+ const isStaticTarget = staticPresets.includes(nitroPreset);
1814
+ const isSpa = nuxt.options.ssr === false;
1815
+ if (anyHandlerRequiresSigning && (isSpa || isStaticTarget)) {
1816
+ logger.warn(
1817
+ `[security] URL signing requires a server runtime${isStaticTarget ? ` (detected preset: ${nitroPreset})` : " (ssr: false)"}.
1818
+ Proxy endpoints will work without signature verification.
1819
+ To enable signing, deploy with a server-rendered target or configure platform-level rewrites.`
1820
+ );
1821
+ } else if (anyHandlerRequiresSigning) {
1822
+ const proxySecretResolved = resolveProxySecret(
1823
+ nuxt.options.rootDir,
1824
+ !!nuxt.options.dev,
1825
+ config.security?.secret,
1826
+ config.security?.autoGenerateSecret !== false
1827
+ );
1828
+ if (proxySecretResolved?.source === "dotenv-generated")
1829
+ logger.info(`[security] Generated ${PROXY_SECRET_ENV_KEY} in .env for signed proxy URLs.`);
1830
+ else if (proxySecretResolved?.source === "memory-generated")
1831
+ logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`);
1832
+ if (proxySecretResolved?.secret) {
1833
+ nuxt.options.runtimeConfig["nuxt-scripts"].proxySecret = proxySecretResolved.secret;
1834
+ } else if (!nuxt.options.dev) {
1835
+ logger.warn(
1836
+ `[security] ${PROXY_SECRET_ENV_KEY} is not set. Proxy endpoints will pass requests through without signature verification.
1837
+ Generate one with: npx @nuxt/scripts generate-secret
1838
+ Then set the env var: ${PROXY_SECRET_ENV_KEY}=<secret>`
1839
+ );
1840
+ }
1841
+ }
1771
1842
  }
1772
1843
  });
1773
1844
 
1774
- export { applyAutoInject, module$1 as default, isProxyDisabled };
1845
+ export { applyAutoInject, module$1 as default, isProxyDisabled, resolveProxySecret };
package/dist/registry.mjs CHANGED
@@ -554,8 +554,8 @@ async function registry(resolve) {
554
554
  envDefaults: { apiKey: "" },
555
555
  category: "content",
556
556
  serverHandlers: [
557
- { route: "/_scripts/proxy/google-static-maps", handler: "./runtime/server/google-static-maps-proxy" },
558
- { route: "/_scripts/proxy/google-maps-geocode", handler: "./runtime/server/google-maps-geocode-proxy" }
557
+ { route: "/_scripts/proxy/google-static-maps", handler: "./runtime/server/google-static-maps-proxy", requiresSigning: true },
558
+ { route: "/_scripts/proxy/google-maps-geocode", handler: "./runtime/server/google-maps-geocode-proxy", requiresSigning: true }
559
559
  ]
560
560
  }),
561
561
  def("blueskyEmbed", {
@@ -564,8 +564,8 @@ async function registry(resolve) {
564
564
  label: "Bluesky Embed",
565
565
  category: "content",
566
566
  serverHandlers: [
567
- { route: "/_scripts/embed/bluesky", handler: "./runtime/server/bluesky-embed" },
568
- { route: "/_scripts/embed/bluesky-image", handler: "./runtime/server/bluesky-embed-image" }
567
+ { route: "/_scripts/embed/bluesky", handler: "./runtime/server/bluesky-embed", requiresSigning: true },
568
+ { route: "/_scripts/embed/bluesky-image", handler: "./runtime/server/bluesky-embed-image", requiresSigning: true }
569
569
  ]
570
570
  }),
571
571
  def("instagramEmbed", {
@@ -574,9 +574,9 @@ async function registry(resolve) {
574
574
  label: "Instagram Embed",
575
575
  category: "content",
576
576
  serverHandlers: [
577
- { route: "/_scripts/embed/instagram", handler: "./runtime/server/instagram-embed" },
578
- { route: "/_scripts/embed/instagram-image", handler: "./runtime/server/instagram-embed-image" },
579
- { route: "/_scripts/embed/instagram-asset", handler: "./runtime/server/instagram-embed-asset" }
577
+ { route: "/_scripts/embed/instagram", handler: "./runtime/server/instagram-embed", requiresSigning: true },
578
+ { route: "/_scripts/embed/instagram-image", handler: "./runtime/server/instagram-embed-image", requiresSigning: true },
579
+ { route: "/_scripts/embed/instagram-asset", handler: "./runtime/server/instagram-embed-asset", requiresSigning: true }
580
580
  ]
581
581
  }),
582
582
  def("xEmbed", {
@@ -585,8 +585,8 @@ async function registry(resolve) {
585
585
  label: "X Embed",
586
586
  category: "content",
587
587
  serverHandlers: [
588
- { route: "/_scripts/embed/x", handler: "./runtime/server/x-embed" },
589
- { route: "/_scripts/embed/x-image", handler: "./runtime/server/x-embed-image" }
588
+ { route: "/_scripts/embed/x", handler: "./runtime/server/x-embed", requiresSigning: true },
589
+ { route: "/_scripts/embed/x-image", handler: "./runtime/server/x-embed-image", requiresSigning: true }
590
590
  ]
591
591
  }),
592
592
  // support
@@ -690,7 +690,7 @@ async function registry(resolve) {
690
690
  privacy: PRIVACY_IP_ONLY
691
691
  },
692
692
  serverHandlers: [
693
- { route: "/_scripts/proxy/gravatar", handler: "./runtime/server/gravatar-proxy" }
693
+ { route: "/_scripts/proxy/gravatar", handler: "./runtime/server/gravatar-proxy", requiresSigning: true }
694
694
  ]
695
695
  })
696
696
  ]);
@@ -1,7 +1,8 @@
1
1
  import { createError, defineEventHandler, getQuery, setHeader } from "h3";
2
2
  import { $fetch } from "ofetch";
3
+ import { withSigning } from "./utils/withSigning.js";
3
4
  const BSKY_POST_URL_RE = /^https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/?]+)$/;
4
- export default defineEventHandler(async (event) => {
5
+ export default withSigning(defineEventHandler(async (event) => {
5
6
  const query = getQuery(event);
6
7
  const postUrl = query.url;
7
8
  if (!postUrl) {
@@ -56,4 +57,4 @@ export default defineEventHandler(async (event) => {
56
57
  setHeader(event, "Content-Type", "application/json");
57
58
  setHeader(event, "Cache-Control", "public, max-age=600, s-maxage=600");
58
59
  return post;
59
- });
60
+ }));
@@ -2,7 +2,8 @@ import { useRuntimeConfig } from "#imports";
2
2
  import { createError, defineEventHandler, getQuery, setHeader } from "h3";
3
3
  import { $fetch } from "ofetch";
4
4
  import { withQuery } from "ufo";
5
- export default defineEventHandler(async (event) => {
5
+ import { withSigning } from "./utils/withSigning.js";
6
+ export default withSigning(defineEventHandler(async (event) => {
6
7
  const runtimeConfig = useRuntimeConfig();
7
8
  const privateConfig = runtimeConfig["nuxt-scripts"]?.googleMapsGeocodeProxy;
8
9
  const apiKey = privateConfig?.apiKey;
@@ -31,4 +32,4 @@ export default defineEventHandler(async (event) => {
31
32
  setHeader(event, "Content-Type", "application/json");
32
33
  setHeader(event, "Cache-Control", "public, max-age=86400, s-maxage=86400");
33
34
  return data;
34
- });
35
+ }));
@@ -2,7 +2,8 @@ import { useRuntimeConfig } from "#imports";
2
2
  import { createError, defineEventHandler, getQuery, setHeader } from "h3";
3
3
  import { $fetch } from "ofetch";
4
4
  import { withQuery } from "ufo";
5
- export default defineEventHandler(async (event) => {
5
+ import { withSigning } from "./utils/withSigning.js";
6
+ export default withSigning(defineEventHandler(async (event) => {
6
7
  const runtimeConfig = useRuntimeConfig();
7
8
  const publicConfig = runtimeConfig.public["nuxt-scripts"]?.googleStaticMapsProxy;
8
9
  const privateConfig = runtimeConfig["nuxt-scripts"]?.googleStaticMapsProxy;
@@ -40,4 +41,4 @@ export default defineEventHandler(async (event) => {
40
41
  setHeader(event, "Cache-Control", `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`);
41
42
  setHeader(event, "Vary", "Accept-Encoding");
42
43
  return response._data;
43
- });
44
+ }));
@@ -2,7 +2,8 @@ import { useRuntimeConfig } from "#imports";
2
2
  import { createError, defineEventHandler, getQuery, setHeader } from "h3";
3
3
  import { $fetch } from "ofetch";
4
4
  import { withQuery } from "ufo";
5
- export default defineEventHandler(async (event) => {
5
+ import { withSigning } from "./utils/withSigning.js";
6
+ export default withSigning(defineEventHandler(async (event) => {
6
7
  const runtimeConfig = useRuntimeConfig();
7
8
  const proxyConfig = runtimeConfig.public["nuxt-scripts"]?.gravatarProxy;
8
9
  const query = getQuery(event);
@@ -43,4 +44,4 @@ export default defineEventHandler(async (event) => {
43
44
  setHeader(event, "Cache-Control", `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`);
44
45
  setHeader(event, "Vary", "Accept-Encoding");
45
46
  return response._data;
46
- });
47
+ }));
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler, getQuery, setHeader } from "h3";
2
2
  import { $fetch } from "ofetch";
3
3
  import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from "ultrahtml";
4
+ import { withSigning } from "./utils/withSigning.js";
4
5
  export const RSRC_RE = /url\(\/rsrc\.php([^)]+)\)/g;
5
6
  export const AMP_RE = /&amp;/g;
6
7
  export const SCONTENT_RE = /https:\/\/scontent[^"'\s),]+\.cdninstagram\.com[^"'\s),]+/g;
@@ -124,7 +125,7 @@ function extractBlock(css, openBrace) {
124
125
  }
125
126
  return null;
126
127
  }
127
- export default defineEventHandler(async (event) => {
128
+ export default withSigning(defineEventHandler(async (event) => {
128
129
  const handlerPath = event.path?.split("?")[0] || "";
129
130
  const prefix = handlerPath.replace(EMBED_INSTAGRAM_SUFFIX_RE, "") || "/_scripts";
130
131
  const query = getQuery(event);
@@ -229,4 +230,4 @@ ${combinedCss}</style>${bodyHtml}</div>`;
229
230
  setHeader(event, "Content-Type", "text/html");
230
231
  setHeader(event, "Cache-Control", "public, max-age=600, s-maxage=600");
231
232
  return result;
232
- });
233
+ }));
@@ -1,5 +1,6 @@
1
1
  import { createError, defineEventHandler, getQuery, setHeader } from "h3";
2
2
  import { $fetch } from "ofetch";
3
+ import { withSigning } from "./withSigning.js";
3
4
  const AMP_RE = /&amp;/g;
4
5
  export function createImageProxyHandler(config) {
5
6
  const {
@@ -10,7 +11,7 @@ export function createImageProxyHandler(config) {
10
11
  followRedirects = true,
11
12
  decodeAmpersands = false
12
13
  } = config;
13
- return defineEventHandler(async (event) => {
14
+ return withSigning(defineEventHandler(async (event) => {
14
15
  const query = getQuery(event);
15
16
  let url = query.url;
16
17
  if (decodeAmpersands && url)
@@ -66,5 +67,5 @@ export function createImageProxyHandler(config) {
66
67
  setHeader(event, "Content-Type", response.headers.get("content-type") || contentType);
67
68
  setHeader(event, "Cache-Control", `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`);
68
69
  return response._data;
69
- });
70
+ }));
70
71
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * HMAC URL signing for proxy endpoints.
3
+ *
4
+ * ## Why
5
+ *
6
+ * Proxy endpoints like `/_scripts/proxy/google-static-maps` inject a server-side
7
+ * API key and forward requests to third-party services. Without signing, anyone
8
+ * can call these endpoints with arbitrary parameters and burn the site owner's
9
+ * API quota. Signing ensures only URLs generated server-side (during SSR/prerender
10
+ * or via the `/_scripts/sign` endpoint) are accepted.
11
+ *
12
+ * ## How
13
+ *
14
+ * 1. The module stores a deterministic secret in `runtimeConfig.nuxt-scripts.proxySecret`
15
+ * (env: `NUXT_SCRIPTS_PROXY_SECRET`).
16
+ * 2. URLs are canonicalized (sort query keys, strip `sig`) and signed with HMAC-SHA256.
17
+ * 3. The first 16 hex chars (64 bits) of the digest is appended as `?sig=<hex>`.
18
+ * 4. Endpoints wrapped with `withSigning()` verify the sig against the current request.
19
+ *
20
+ * A 64-bit signature is enough to defeat brute force for this threat model
21
+ * (a billion guesses gives a ~5% hit rate at 2^64). Longer signatures bloat
22
+ * prerendered HTML for no practical gain.
23
+ */
24
+ import type { H3Event } from 'h3';
25
+ /** Query param name for the signature. Chosen to be unlikely to collide with upstream APIs. */
26
+ export declare const SIG_PARAM = "sig";
27
+ /** Length of the hex signature (16 chars = 64 bits). */
28
+ export declare const SIG_LENGTH = 16;
29
+ /**
30
+ * Canonicalize a query object into a deterministic string suitable for HMAC input.
31
+ *
32
+ * Rules:
33
+ * - The `sig` param is stripped (it can't sign itself).
34
+ * - `undefined` and `null` values are skipped (mirrors `ufo.withQuery`).
35
+ * - Keys are sorted alphabetically so order-independent reconstruction works.
36
+ * - Arrays expand to repeated keys (e.g. `markers=a&markers=b`), matching how
37
+ * `ufo.withQuery` serializes array-valued params.
38
+ * - Objects are JSON-stringified (rare, but consistent with `ufo.withQuery`).
39
+ * - Encoding uses `encodeURIComponent` for both keys and values so the canonical
40
+ * form matches what shows up on the wire.
41
+ *
42
+ * The resulting string is stable across server/client and different JS runtimes
43
+ * because it does not depend on `URLSearchParams` insertion order.
44
+ */
45
+ export declare function canonicalizeQuery(query: Record<string, unknown>): string;
46
+ /**
47
+ * Sign a path + query using HMAC-SHA256 and return the 16-char hex digest.
48
+ *
49
+ * The HMAC input is `${path}?${canonicalQuery}` so that the same query signed
50
+ * against a different endpoint yields a different signature (prevents cross-
51
+ * endpoint signature reuse).
52
+ *
53
+ * `path` should be the URL path without query string (e.g. `/_scripts/proxy/google-static-maps`).
54
+ * Callers should not include origin / host since the signing contract is path-relative.
55
+ */
56
+ export declare function signProxyUrl(path: string, query: Record<string, unknown>, secret: string): string;
57
+ /**
58
+ * Build a fully-formed signed URL (path + query + sig).
59
+ *
60
+ * This is the primary helper for code paths that need to emit a proxy URL
61
+ * (SSR components, server-side URL rewriters like instagram-embed).
62
+ */
63
+ export declare function buildSignedProxyUrl(path: string, query: Record<string, unknown>, secret: string): string;
64
+ /** Query param name for the page token. */
65
+ export declare const PAGE_TOKEN_PARAM = "_pt";
66
+ /** Query param name for the page token timestamp. */
67
+ export declare const PAGE_TOKEN_TS_PARAM = "_ts";
68
+ /** Default max age for page tokens in seconds (1 hour). */
69
+ export declare const PAGE_TOKEN_MAX_AGE = 3600;
70
+ /**
71
+ * Generate a page token that authorizes client-side proxy requests.
72
+ *
73
+ * Embedded in the SSR payload so the browser can attach it to reactive proxy
74
+ * URL updates without needing a `/sign` round-trip. The token is scoped to
75
+ * a timestamp and expires after `PAGE_TOKEN_MAX_AGE` seconds.
76
+ *
77
+ * Construction: first 16 hex chars of `HMAC(secret, "proxy-access:<timestamp>")`.
78
+ */
79
+ export declare function generateProxyToken(secret: string, timestamp: number): string;
80
+ /**
81
+ * Verify a page token against the current time.
82
+ *
83
+ * Returns `true` if the token matches the HMAC for the given timestamp AND
84
+ * the timestamp is within `maxAge` seconds of `now`.
85
+ */
86
+ export declare function verifyProxyToken(token: string, timestamp: number, secret: string, maxAge?: number, now?: number): boolean;
87
+ /**
88
+ * Verify a request against either a URL signature or a page token.
89
+ *
90
+ * Two verification modes, checked in order:
91
+ *
92
+ * 1. **URL signature** (`sig` param): the exact URL was signed server-side
93
+ * during SSR/prerender. Locked to the specific path + query params.
94
+ *
95
+ * 2. **Page token** (`_pt` + `_ts` params): the client received a short-lived
96
+ * token during SSR and is making a reactive proxy request with new params.
97
+ * Valid for any params on the target path, but expires after `maxAge`.
98
+ *
99
+ * Returns `false` if neither mode validates.
100
+ */
101
+ export declare function verifyProxyRequest(event: H3Event, secret: string, maxAge?: number): boolean;
102
+ /**
103
+ * Constant-time string comparison.
104
+ *
105
+ * Both inputs are expected to be equal-length hex strings. The loop runs over
106
+ * the longer length so an early-exit on length mismatch doesn't leak the
107
+ * expected length (though both are fixed at `SIG_LENGTH` in practice).
108
+ */
109
+ export declare function constantTimeEqual(a: string, b: string): boolean;
@@ -0,0 +1,88 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { getQuery } from "h3";
3
+ export const SIG_PARAM = "sig";
4
+ export const SIG_LENGTH = 16;
5
+ export function canonicalizeQuery(query) {
6
+ const keys = Object.keys(query).filter((k) => k !== SIG_PARAM && query[k] !== void 0 && query[k] !== null).sort();
7
+ const parts = [];
8
+ for (const key of keys) {
9
+ const value = query[key];
10
+ const encodedKey = encodeURIComponent(key);
11
+ if (Array.isArray(value)) {
12
+ for (const item of value) {
13
+ if (item === void 0 || item === null)
14
+ continue;
15
+ parts.push(`${encodedKey}=${encodeURIComponent(serializeValue(item))}`);
16
+ }
17
+ } else {
18
+ parts.push(`${encodedKey}=${encodeURIComponent(serializeValue(value))}`);
19
+ }
20
+ }
21
+ return parts.join("&");
22
+ }
23
+ function serializeValue(value) {
24
+ if (typeof value === "string")
25
+ return value;
26
+ if (typeof value === "object")
27
+ return JSON.stringify(value);
28
+ return String(value);
29
+ }
30
+ export function signProxyUrl(path, query, secret) {
31
+ const canonical = canonicalizeQuery(query);
32
+ const input = canonical ? `${path}?${canonical}` : path;
33
+ return createHmac("sha256", secret).update(input).digest("hex").slice(0, SIG_LENGTH);
34
+ }
35
+ export function buildSignedProxyUrl(path, query, secret) {
36
+ const sig = signProxyUrl(path, query, secret);
37
+ const canonical = canonicalizeQuery(query);
38
+ const queryString = canonical ? `${canonical}&${SIG_PARAM}=${sig}` : `${SIG_PARAM}=${sig}`;
39
+ return `${path}?${queryString}`;
40
+ }
41
+ export const PAGE_TOKEN_PARAM = "_pt";
42
+ export const PAGE_TOKEN_TS_PARAM = "_ts";
43
+ export const PAGE_TOKEN_MAX_AGE = 3600;
44
+ export function generateProxyToken(secret, timestamp) {
45
+ return createHmac("sha256", secret).update(`proxy-access:${timestamp}`).digest("hex").slice(0, SIG_LENGTH);
46
+ }
47
+ export function verifyProxyToken(token, timestamp, secret, maxAge = PAGE_TOKEN_MAX_AGE, now = Math.floor(Date.now() / 1e3)) {
48
+ if (!token || !secret || typeof timestamp !== "number")
49
+ return false;
50
+ if (token.length !== SIG_LENGTH)
51
+ return false;
52
+ const age = now - timestamp;
53
+ if (age > maxAge || age < -60)
54
+ return false;
55
+ const expected = generateProxyToken(secret, timestamp);
56
+ return constantTimeEqual(expected, token);
57
+ }
58
+ export function verifyProxyRequest(event, secret, maxAge) {
59
+ if (!secret)
60
+ return false;
61
+ const query = getQuery(event);
62
+ const rawSig = query[SIG_PARAM];
63
+ const sig = Array.isArray(rawSig) ? rawSig[0] : rawSig;
64
+ if (typeof sig === "string" && sig.length === SIG_LENGTH) {
65
+ const path = (event.path || "").split("?")[0] || "";
66
+ const expected = signProxyUrl(path, query, secret);
67
+ if (constantTimeEqual(expected, sig))
68
+ return true;
69
+ }
70
+ const rawToken = query[PAGE_TOKEN_PARAM];
71
+ const rawTs = query[PAGE_TOKEN_TS_PARAM];
72
+ const token = Array.isArray(rawToken) ? rawToken[0] : rawToken;
73
+ const ts = Array.isArray(rawTs) ? rawTs[0] : rawTs;
74
+ if (typeof token === "string" && ts !== void 0) {
75
+ const timestamp = Number(ts);
76
+ if (!Number.isNaN(timestamp))
77
+ return verifyProxyToken(token, timestamp, secret, maxAge);
78
+ }
79
+ return false;
80
+ }
81
+ export function constantTimeEqual(a, b) {
82
+ if (a.length !== b.length)
83
+ return false;
84
+ let diff = 0;
85
+ for (let i = 0; i < a.length; i++)
86
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
87
+ return diff === 0;
88
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Middleware wrapper that enforces HMAC signature verification on a proxy handler.
3
+ *
4
+ * Usage:
5
+ * ```ts
6
+ * export default withSigning(defineEventHandler(async (event) => {
7
+ * // ... handler logic
8
+ * }))
9
+ * ```
10
+ *
11
+ * Behavior:
12
+ * - Reads `runtimeConfig.nuxt-scripts.proxySecret` (server-only).
13
+ * - If no secret is configured: passes through (signing not yet enabled).
14
+ * This allows shipping handler wiring before components emit signed URLs.
15
+ * Once `NUXT_SCRIPTS_PROXY_SECRET` is set, verification is enforced.
16
+ * - If a secret IS configured and the request's signature is invalid: 403.
17
+ * - Otherwise, delegates to the wrapped handler.
18
+ *
19
+ * The outer wrapper runs before any handler logic, so unauthorized requests
20
+ * never reach the upstream fetch and cannot consume API quota.
21
+ */
22
+ import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from 'h3';
23
+ export declare function withSigning<Req extends EventHandlerRequest = EventHandlerRequest, Res extends EventHandlerResponse = EventHandlerResponse>(handler: EventHandler<Req, Res>): EventHandler<Req, Res>;
@@ -0,0 +1,18 @@
1
+ import { createError, defineEventHandler } from "h3";
2
+ import { verifyProxyRequest } from "./sign.js";
3
+ export function withSigning(handler) {
4
+ return defineEventHandler(async (event) => {
5
+ const { useRuntimeConfig } = await import("#imports");
6
+ const runtimeConfig = useRuntimeConfig(event);
7
+ const secret = runtimeConfig["nuxt-scripts"]?.proxySecret;
8
+ if (!secret)
9
+ return handler(event);
10
+ if (!verifyProxyRequest(event, secret)) {
11
+ throw createError({
12
+ statusCode: 403,
13
+ statusMessage: "Invalid signature"
14
+ });
15
+ }
16
+ return handler(event);
17
+ });
18
+ }
@@ -1,7 +1,8 @@
1
1
  import { createError, defineEventHandler, getQuery, setHeader } from "h3";
2
2
  import { $fetch } from "ofetch";
3
+ import { withSigning } from "./utils/withSigning.js";
3
4
  const TWEET_ID_RE = /^\d+$/;
4
- export default defineEventHandler(async (event) => {
5
+ export default withSigning(defineEventHandler(async (event) => {
5
6
  const query = getQuery(event);
6
7
  const tweetId = query.id;
7
8
  if (!tweetId || !TWEET_ID_RE.test(tweetId)) {
@@ -29,4 +30,4 @@ export default defineEventHandler(async (event) => {
29
30
  setHeader(event, "Content-Type", "application/json");
30
31
  setHeader(event, "Cache-Control", "public, max-age=600, s-maxage=600");
31
32
  return tweetData;
32
- });
33
+ }));
@@ -235,7 +235,7 @@ export type BuiltInRegistryScriptKey = 'bingUet' | 'blueskyEmbed' | 'carbonAds'
235
235
  * Includes both built-in and augmented keys.
236
236
  */
237
237
  export type RegistryScriptKey = Exclude<keyof ScriptRegistry, `${string}-npm`>;
238
- type RegistryConfigInput<T> = [T] extends [true] ? Record<string, never> : T;
238
+ type RegistryConfigInput<T> = 0 extends 1 & T ? Record<string, any> : [T] extends [true] ? Record<string, never> : T;
239
239
  export type NuxtConfigScriptRegistryEntry<T> = true | false | 'mock' | (RegistryConfigInput<T> & {
240
240
  trigger?: NuxtUseScriptOptionsSerializable['trigger'] | false;
241
241
  proxy?: boolean;
@@ -268,6 +268,14 @@ export interface RegistryScriptServerHandler {
268
268
  route: string;
269
269
  handler: string;
270
270
  middleware?: boolean;
271
+ /**
272
+ * Whether this handler verifies HMAC signatures via `withSigning()`.
273
+ *
274
+ * When any enabled script registers a handler with `requiresSigning: true`,
275
+ * the module enforces that `NUXT_SCRIPTS_PROXY_SECRET` is set in production,
276
+ * and the `/_scripts/sign` endpoint will accept this route as a signable path.
277
+ */
278
+ requiresSigning?: boolean;
271
279
  }
272
280
  /**
273
281
  * Declares what optimization modes a script supports and what's active by default.
package/dist/types.d.mts CHANGED
@@ -6,6 +6,6 @@ declare module '@nuxt/schema' {
6
6
 
7
7
  export { type FirstPartyPrivacy } from '../dist/runtime/types.js'
8
8
 
9
- export { type applyAutoInject, default, type isProxyDisabled } from './module.mjs'
9
+ export { type applyAutoInject, default, type isProxyDisabled, type resolveProxySecret } from './module.mjs'
10
10
 
11
- export { type ModuleHooks, type ModuleOptions } from './module.mjs'
11
+ export { type ModuleHooks, type ModuleOptions, type ResolvedProxySecret } from './module.mjs'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxt/scripts",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.6",
4
+ "version": "1.0.0-rc.7",
5
5
  "description": "Load third-party scripts with better performance, privacy and DX in Nuxt Apps.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -45,7 +45,11 @@
45
45
  ]
46
46
  }
47
47
  },
48
+ "bin": {
49
+ "nuxt-scripts": "./bin/cli.mjs"
50
+ },
48
51
  "files": [
52
+ "bin",
49
53
  "dist"
50
54
  ],
51
55
  "build": {
@@ -1 +0,0 @@
1
- {"id":"640f0a39-e659-4a31-8b8d-adbd9af52f1e","timestamp":1775787703990,"prerendered":[]}