nuxt-og-image 6.8.0 → 6.9.0

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.
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { resolveModulePath } from 'exsolve';
4
4
  import { dirname, join } from 'pathe';
5
- import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.D936Fp6O.mjs';
5
+ import { e as extractVariantBaseClasses, r as resolveVariantPrefixes, h as resolveExtractedVars, l as resolveVarsDeep, s as simplifyCss, p as postProcessStyles, m as loadLightningCss, a as extractCssVars, c as extractUniversalVars, d as extractPerClassVars, g as extractVarsFromCss, f as extractClassStyles } from '../shared/nuxt-og-image.CTD0ZPNd.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import '@nuxt/kit';
@@ -1,8 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { defu } from 'defu';
3
3
  import { logger } from '../../dist/runtime/logger.js';
4
- import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.D936Fp6O.mjs';
5
- export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.D936Fp6O.mjs';
4
+ import { e as extractVariantBaseClasses, s as simplifyCss, a as extractCssVars, b as extractPropertyInitialValues, c as extractUniversalVars, d as extractPerClassVars, f as extractClassStyles, p as postProcessStyles, r as resolveVariantPrefixes, i as importOptionalPeer, g as extractVarsFromCss, h as resolveExtractedVars } from '../shared/nuxt-og-image.CTD0ZPNd.mjs';
5
+ export { j as importResolvedModule, k as resolveOptionalModulePath } from '../shared/nuxt-og-image.CTD0ZPNd.mjs';
6
6
  import 'node:crypto';
7
7
  import 'node:fs';
8
8
  import 'node:url';
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=3.16.0"
5
5
  },
6
6
  "configKey": "ogImage",
7
- "version": "6.8.0",
7
+ "version": "6.9.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -10,7 +10,7 @@ import 'nuxtseo-shared/kit';
10
10
  import 'pathe';
11
11
  import 'pkg-types';
12
12
  import 'std-env';
13
- export { n as default } from './shared/nuxt-og-image.D936Fp6O.mjs';
13
+ export { n as default } from './shared/nuxt-og-image.CTD0ZPNd.mjs';
14
14
  import '../dist/runtime/logger.js';
15
15
  import 'magic-string';
16
16
  import 'oxc-walker';
@@ -0,0 +1,9 @@
1
+ import type { H3Event } from '#nuxtseo/h3';
2
+ import type { OgImageOptionsInternal } from '../types.js';
3
+ type Options = Partial<OgImageOptionsInternal>;
4
+ /**
5
+ * Build the absolute OG image URL for a page. Without a page path, uses the current request path.
6
+ */
7
+ export declare function getOgImageUrl(event: H3Event, options?: Options): string;
8
+ export declare function getOgImageUrl(event: H3Event, pagePath: string, options?: Options): string;
9
+ export {};
@@ -0,0 +1,10 @@
1
+ import { parseURL, withoutBase } from "ufo";
2
+ import { useRuntimeConfig } from "#nuxtseo/nitro";
3
+ import { withSiteUrl } from "#site-config/server/composables/utils";
4
+ import { getOgImagePath } from "./utils.js";
5
+ export function getOgImageUrl(event, pathOrOptions, maybeOptions) {
6
+ const pagePath = typeof pathOrOptions === "string" ? pathOrOptions : withoutBase(parseURL(event.path).pathname, useRuntimeConfig(event).app.baseURL) || "/";
7
+ const options = typeof pathOrOptions === "string" ? maybeOptions : pathOrOptions;
8
+ const { path } = getOgImagePath(event, pagePath, options);
9
+ return withSiteUrl(event, path, { canonical: !import.meta.dev });
10
+ }
@@ -0,0 +1,2 @@
1
+ import type { GuardedFetchInit } from './web.js';
2
+ export declare function guardedFetch(url: string, { headers, signal, isBlockedAddress }: GuardedFetchInit): Promise<Response>;
@@ -0,0 +1,42 @@
1
+ import { lookup as dnsLookup } from "node:dns";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { Readable } from "node:stream";
5
+ function guardedLookup(isBlocked) {
6
+ return (hostname, options, callback) => {
7
+ dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
8
+ if (err)
9
+ return callback(err, "", 0);
10
+ const blocked = addresses.find((a) => isBlocked(a.address));
11
+ if (blocked)
12
+ return callback(new Error(`[Nuxt OG Image] ${hostname} resolves to blocked address ${blocked.address}.`), "", 0);
13
+ if (options.all)
14
+ return callback(null, addresses);
15
+ callback(null, addresses[0].address, addresses[0].family);
16
+ });
17
+ };
18
+ }
19
+ export function guardedFetch(url, { headers, signal, isBlockedAddress }) {
20
+ const request = url.startsWith("https:") ? httpsRequest : httpRequest;
21
+ return new Promise((resolve, reject) => {
22
+ const req = request(url, {
23
+ // Match the defaults that `fetch` sent before this transport existed.
24
+ headers: { "accept": "*/*", "user-agent": "node", ...headers },
25
+ signal,
26
+ lookup: guardedLookup(isBlockedAddress ?? (() => false)),
27
+ // A fresh socket per hop: never reuse a connection nobody checked.
28
+ agent: false
29
+ }, (res) => {
30
+ const status = res.statusCode ?? 0;
31
+ const responseHeaders = new Headers();
32
+ for (const [name, value] of Object.entries(res.headers)) {
33
+ for (const v of [value ?? []].flat())
34
+ responseHeaders.append(name, v);
35
+ }
36
+ const body = status === 204 || status === 304 ? null : Readable.toWeb(res);
37
+ resolve(new Response(body, { status, headers: responseHeaders }));
38
+ });
39
+ req.on("error", reject);
40
+ req.end();
41
+ });
42
+ }
@@ -0,0 +1,7 @@
1
+ export interface GuardedFetchInit {
2
+ headers?: Record<string, string>;
3
+ signal: AbortSignal;
4
+ /** Rejects the connection when a resolved address matches. Node only. */
5
+ isBlockedAddress?: (address: string) => boolean;
6
+ }
7
+ export declare function guardedFetch(url: string, { headers, signal }: GuardedFetchInit): Promise<Response>;
@@ -0,0 +1,3 @@
1
+ export function guardedFetch(url, { headers, signal }) {
2
+ return fetch(url, { redirect: "manual", headers, signal });
3
+ }
@@ -12,17 +12,6 @@ export declare function isDataFontUrl(path: string): boolean;
12
12
  * Check `isDataFontUrl` first — `data:` is non-http and would report true here.
13
13
  */
14
14
  export declare function isExternalFontUrl(path: string): boolean;
15
- /**
16
- * Resolve an authority-bearing font URL to the href to fetch, but only when it
17
- * is same-origin with the configured site URL. Returns null otherwise.
18
- *
19
- * Runtime external font URLs are unsupported — `@nuxt/fonts` is the only
20
- * supported way to load custom fonts, and it serves them same-origin. The single
21
- * allowed exception is the site's own origin, gated on an explicitly configured
22
- * site URL. Resolving against that origin also collapses canonicalization
23
- * bypasses (a crafted " //127.0.0.1" resolves cross-origin → rejected).
24
- */
25
- export declare function resolveSameOriginFontUrl(path: string, siteUrl: string | undefined): string | null;
26
15
  /**
27
16
  * Fetch a `data:` or authority-bearing font URL. `data:` is decoded directly
28
17
  * (no network). An external URL is fetched only when same-origin with the site
@@ -1,4 +1,4 @@
1
- import { fetchWithRedirectValidation } from "../../../util/ssrf.js";
1
+ import { fetchWithRedirectValidation, resolveSameOriginUrl } from "../../../util/ssrf.js";
2
2
  const SENTINEL_ORIGIN = "http://font-asset.invalid/";
3
3
  const SENTINEL_HOST = "font-asset.invalid";
4
4
  export function isDataFontUrl(path) {
@@ -13,23 +13,6 @@ export function isExternalFontUrl(path) {
13
13
  }
14
14
  return resolved.protocol !== "http:" || resolved.host !== SENTINEL_HOST;
15
15
  }
16
- export function resolveSameOriginFontUrl(path, siteUrl) {
17
- if (!siteUrl)
18
- return null;
19
- let siteOrigin;
20
- try {
21
- siteOrigin = new URL(siteUrl).origin;
22
- } catch {
23
- return null;
24
- }
25
- let target;
26
- try {
27
- target = new URL(path, siteOrigin);
28
- } catch {
29
- return null;
30
- }
31
- return target.origin === siteOrigin ? target.href : null;
32
- }
33
16
  export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
34
17
  if (isDataFontUrl(path)) {
35
18
  const res = await fetch(path.trimStart(), { signal: AbortSignal.timeout(timeout) }).catch(() => {
@@ -39,7 +22,7 @@ export async function fetchSpecialFontUrl(path, siteUrl, timeout) {
39
22
  return Buffer.from(await res.arrayBuffer());
40
23
  throw new Error("[Nuxt OG Image] Invalid data: font URL.");
41
24
  }
42
- const href = resolveSameOriginFontUrl(path, siteUrl);
25
+ const href = resolveSameOriginUrl(path, siteUrl);
43
26
  if (!href)
44
27
  throw new Error("[Nuxt OG Image] External font URLs are not supported. Load custom fonts via @nuxt/fonts.");
45
28
  const ab = await fetchWithRedirectValidation(href, { timeout });
@@ -5,6 +5,7 @@ import { getNitroOrigin } from "#site-config/server/composables";
5
5
  import { buildOgImageUrl } from "../../../shared.js";
6
6
  import { getFetchTimeout } from "../../util/fetchTimeout.js";
7
7
  import { logger } from "../../util/logger.js";
8
+ import { resolveSameOriginUrl } from "../../util/ssrf.js";
8
9
  import { useOgImageRuntimeConfig } from "../../utils.js";
9
10
  function isPlaywrightPage(page) {
10
11
  return typeof page.setViewportSize === "function";
@@ -101,7 +102,9 @@ export async function createScreenshot({ basePath, e, options, extension, timing
101
102
  await page.setContent(html, { waitUntil: "networkidle0" });
102
103
  }
103
104
  } else {
104
- const url = isPlaywrightPage(page) ? withQuery(path, options.props || {}) : `${getNitroOrigin(e)}${withQuery(path, options.props || {})}`;
105
+ const url = resolveSameOriginUrl(withQuery(path, options.props || {}), getNitroOrigin(e));
106
+ if (!url)
107
+ throw new Error("[Nuxt OG Image] The browser renderer can only navigate to a same-origin path.");
105
108
  await gotoWithIdle(page, url, 1e4);
106
109
  }
107
110
  const screenshotOptions = {
@@ -13,6 +13,7 @@ import { autoEjectCommunityTemplate } from "../util/auto-eject.js";
13
13
  import { createNitroRouteRuleMatcher } from "../util/kit.js";
14
14
  import { normaliseOptions } from "../util/options.js";
15
15
  import { getEventQuery } from "../util/query.js";
16
+ import { parseSameOriginPath } from "../util/ssrf.js";
16
17
  import { createTimings, TIMING_CTX_KEY } from "../util/timings.js";
17
18
  import { withTimeout } from "../util/withTimeout.js";
18
19
  import { useOgImageRuntimeConfig } from "../utils.js";
@@ -120,7 +121,14 @@ export async function resolveContext(e) {
120
121
  delete queryParams.html;
121
122
  queryParams = separateProps(queryParams);
122
123
  }
123
- const basePath = withoutTrailingSlash(urlOptions._path || "/");
124
+ const rawPath = urlOptions._path ?? "/";
125
+ const basePath = parseSameOriginPath(typeof rawPath === "string" ? withoutTrailingSlash(rawPath) : rawPath);
126
+ if (!basePath) {
127
+ return createError({
128
+ statusCode: 400,
129
+ statusMessage: "[Nuxt OG Image] The _path option must be a same-origin path."
130
+ });
131
+ }
124
132
  const componentHash = urlOptions._componentHash || "";
125
133
  delete urlOptions._path;
126
134
  delete urlOptions._hash;
@@ -4,6 +4,7 @@ import { fetchWithEvent } from "#nuxtseo/nitro";
4
4
  import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
5
5
  import { isInternalRoute } from "../../shared.js";
6
6
  import { getEventQuery } from "../util/query.js";
7
+ import { parseSameOriginPath } from "../util/ssrf.js";
7
8
  import { useOgImageRuntimeConfig } from "../utils.js";
8
9
  const RE_META_TAG = /<meta\b[^>]*>/gi;
9
10
  const RE_META_KEY = /\b(?:property|name)\s*=\s*(?:"([^"]+)"|'([^']+)')/i;
@@ -11,7 +12,6 @@ const RE_META_CONTENT = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
11
12
  const MAX_RESOLVE_PATH_LENGTH = 2048;
12
13
  const RE_STRIP_PREFIX = /^.*?\/_og\/r/;
13
14
  const RE_IMAGE_EXT = /\.(?:png|jpe?g|webp|svg)$/i;
14
- const RE_DOUBLE_LEADING_SLASH = /^\/{2,}/;
15
15
  function extractMeta(html, key) {
16
16
  for (const tagMatch of html.matchAll(RE_META_TAG)) {
17
17
  const tag = tagMatch[0];
@@ -78,7 +78,7 @@ export default defineEventHandler(async (event) => {
78
78
  statusMessage: `[Nuxt OG Image] Target path exceeds ${MAX_RESOLVE_PATH_LENGTH} characters.`
79
79
  });
80
80
  }
81
- if (targetPath.includes("://") || RE_DOUBLE_LEADING_SLASH.test(targetPath)) {
81
+ if (targetPath.includes("://") || !parseSameOriginPath(targetPath)) {
82
82
  throw createError({
83
83
  statusCode: 400,
84
84
  statusMessage: "[Nuxt OG Image] Target path must be a same-origin path."
@@ -7,11 +7,11 @@ export function getCloudflareEnv(event) {
7
7
  const runtimeEnv = toRecord(runtimeCloudflare?.env);
8
8
  if (runtimeEnv)
9
9
  return runtimeEnv;
10
- const contextCloudflare = toRecord(event?.context.cloudflare);
10
+ const contextCloudflare = toRecord(event?.context?.cloudflare);
11
11
  const contextEnv = toRecord(contextCloudflare?.env);
12
12
  if (contextEnv)
13
13
  return contextEnv;
14
- const platform = toRecord(event?.context._platform);
14
+ const platform = toRecord(event?.context?._platform);
15
15
  const platformCloudflare = toRecord(platform?.cloudflare);
16
16
  return toRecord(platformCloudflare?.env);
17
17
  }
@@ -10,6 +10,36 @@ export declare function expandIPv6(addr: string): number[] | null;
10
10
  * blocked outright.
11
11
  */
12
12
  export declare function isBlockedUrl(url: string): boolean;
13
+ /** True when a resolved IP address is internal, reserved, or unparseable. */
14
+ export declare function isBlockedAddress(address: string): boolean;
15
+ declare const sameOriginPathBrand: unique symbol;
16
+ /**
17
+ * A path that stays on the current origin when resolved against any base URL.
18
+ * Only `parseSameOriginPath` creates one, so a value of this type is safe to
19
+ * pass to a same-origin fetch or a headless browser navigation.
20
+ */
21
+ export type SameOriginPath = string & {
22
+ readonly [sameOriginPathBrand]: true;
23
+ };
24
+ /**
25
+ * Parse an untrusted path (for example the `_path` URL option) into a
26
+ * same-origin path. Returns `undefined` for anything that could leave the
27
+ * origin: absolute URLs, other schemes, protocol-relative `//host`,
28
+ * backslash variants, control characters, and paths without a leading `/`
29
+ * (which a string concatenation with the origin turns into `origin@host`).
30
+ *
31
+ * GHSA-h5qf-97cw-2h86: an absolute `_path` navigated the browser renderer to
32
+ * an internal host and reflected the response into the image.
33
+ */
34
+ export declare function parseSameOriginPath(input: unknown): SameOriginPath | undefined;
35
+ /**
36
+ * Resolve `path` against `origin` and return the href, but only when the result
37
+ * stays on that origin. Returns null for a missing or unparseable origin, and for
38
+ * anything that resolves elsewhere. The WHATWG parser normalizes leading
39
+ * controls, tabs, and backslashes first, so " //127.0.0.1" resolves cross-origin
40
+ * and is rejected (GHSA-q8hw-4fvp-9rwv).
41
+ */
42
+ export declare function resolveSameOriginUrl(path: string, origin: string | undefined): string | null;
13
43
  export interface SafeFetchOptions {
14
44
  timeout: number;
15
45
  headers?: Record<string, string>;
@@ -35,3 +65,4 @@ export interface SafeFetchOptions {
35
65
  * usual missing-asset behaviour.
36
66
  */
37
67
  export declare function fetchWithRedirectValidation(initialUrl: string, opts: SafeFetchOptions): Promise<ArrayBuffer | null>;
68
+ export {};
@@ -1,4 +1,6 @@
1
+ import { guardedFetch } from "#og-image/bindings/fetch";
1
2
  const RE_IPV6_BRACKETS = /^\[|\]$/g;
3
+ const RE_TRAILING_DOTS = /\.+$/;
2
4
  const RE_DIGIT_ONLY = /^\d+$/;
3
5
  const RE_INT_IP = /^(?:0x[\da-f]+|\d+)$/i;
4
6
  const RE_HEX_GROUP = /^[0-9a-f]{1,4}$/i;
@@ -18,6 +20,12 @@ function isPrivateIPv4(a, b) {
18
20
  return true;
19
21
  if (a === 0)
20
22
  return true;
23
+ if (a === 100 && b >= 64 && b <= 127)
24
+ return true;
25
+ if (a === 198 && (b === 18 || b === 19))
26
+ return true;
27
+ if (a >= 224)
28
+ return true;
21
29
  return false;
22
30
  }
23
31
  export function expandIPv6(addr) {
@@ -88,6 +96,10 @@ function isPrivateIPv6(groups) {
88
96
  }
89
97
  if (g0 === 100 && g1 === 65435 && g2 === 1)
90
98
  return true;
99
+ if (g0 === 256 && g1 === 0 && g2 === 0 && g3 === 0)
100
+ return true;
101
+ if (g0 === 8193 && g1 === 0)
102
+ return true;
91
103
  if ((g0 & 65024) === 64512)
92
104
  return true;
93
105
  if ((g0 & 65472) === 65152)
@@ -114,7 +126,7 @@ export function isBlockedUrl(url) {
114
126
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
115
127
  return true;
116
128
  const hostname = parsed.hostname.toLowerCase();
117
- const bare = hostname.replace(RE_IPV6_BRACKETS, "");
129
+ const bare = hostname.replace(RE_IPV6_BRACKETS, "").replace(RE_TRAILING_DOTS, "");
118
130
  if (bare === "localhost" || bare.endsWith(".localhost"))
119
131
  return true;
120
132
  const dottedParts = bare.split(".");
@@ -137,6 +149,50 @@ export function isBlockedUrl(url) {
137
149
  }
138
150
  return false;
139
151
  }
152
+ export function isBlockedAddress(address) {
153
+ return isBlockedUrl(`http://${address.includes(":") ? `[${address}]` : address}/`);
154
+ }
155
+ const PROBE_ORIGIN = "http://og-image.invalid";
156
+ function hasUnsafePathChar(input) {
157
+ for (let i = 0; i < input.length; i++) {
158
+ const code = input.charCodeAt(i);
159
+ if (code <= 31 || code === 127 || code === 92)
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ export function parseSameOriginPath(input) {
165
+ if (typeof input !== "string" || input[0] !== "/" || input[1] === "/")
166
+ return void 0;
167
+ if (hasUnsafePathChar(input))
168
+ return void 0;
169
+ let resolved;
170
+ try {
171
+ resolved = new URL(input, PROBE_ORIGIN);
172
+ } catch {
173
+ return void 0;
174
+ }
175
+ if (resolved.origin !== PROBE_ORIGIN)
176
+ return void 0;
177
+ return input;
178
+ }
179
+ export function resolveSameOriginUrl(path, origin) {
180
+ if (!origin)
181
+ return null;
182
+ let base;
183
+ try {
184
+ base = new URL(origin).origin;
185
+ } catch {
186
+ return null;
187
+ }
188
+ let target;
189
+ try {
190
+ target = new URL(path, base);
191
+ } catch {
192
+ return null;
193
+ }
194
+ return target.origin === base ? target.href : null;
195
+ }
140
196
  export async function fetchWithRedirectValidation(initialUrl, opts) {
141
197
  const controller = new AbortController();
142
198
  const externalAbort = opts.signal;
@@ -161,20 +217,23 @@ export async function fetchWithRedirectValidation(initialUrl, opts) {
161
217
  }
162
218
  if (!isTrusted && isBlockedUrl(url))
163
219
  return null;
164
- const res = await fetch(url, {
165
- redirect: "manual",
220
+ const res = await guardedFetch(url, {
166
221
  signal: controller.signal,
167
- headers: opts.headers
222
+ headers: opts.headers,
223
+ isBlockedAddress: isTrusted ? void 0 : isBlockedAddress
168
224
  });
169
225
  if (res.status >= 300 && res.status < 400) {
226
+ await res.body?.cancel();
170
227
  const loc = res.headers.get("location");
171
228
  if (!loc)
172
229
  return null;
173
230
  url = new URL(loc, url).toString();
174
231
  continue;
175
232
  }
176
- if (!res.ok)
233
+ if (!res.ok) {
234
+ await res.body?.cancel();
177
235
  return null;
236
+ }
178
237
  return await res.arrayBuffer();
179
238
  }
180
239
  return null;
@@ -4,5 +4,5 @@ export interface GetOgImagePathResult {
4
4
  path: string;
5
5
  hash?: string;
6
6
  }
7
- export declare function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult;
7
+ export declare function getOgImagePath(event: H3Event, _pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult;
8
8
  export declare function useOgImageRuntimeConfig(e?: H3Event): OgImageRuntimeConfig;
@@ -3,9 +3,9 @@ import { useRuntimeConfig } from "#nuxtseo/nitro";
3
3
  import { componentNames } from "#og-image-virtual/component-names.mjs";
4
4
  import { buildOgImageUrl } from "../shared.js";
5
5
  import { getCloudflareEnv } from "./util/cloudflare.js";
6
- export function getOgImagePath(_pagePath, _options) {
7
- const baseURL = useRuntimeConfig().app.baseURL;
8
- const { defaults, security } = useOgImageRuntimeConfig();
6
+ export function getOgImagePath(event, _pagePath, _options) {
7
+ const baseURL = useRuntimeConfig(event).app.baseURL;
8
+ const { defaults, security } = useOgImageRuntimeConfig(event);
9
9
  const extension = _options?.extension || defaults.extension;
10
10
  const isStatic = import.meta.prerender && !(security?.secret && security?.strict);
11
11
  const options = { ..._options, _path: _pagePath };
@@ -4,12 +4,13 @@ import type { SatoriOptions } from 'satori';
4
4
  import type { JpegOptions, SharpOptions } from 'sharp';
5
5
  import type { MaybeRefOrGetter, Ref } from 'vue';
6
6
  import type { H3Error, H3Event } from '#nuxtseo/h3';
7
+ import type { SameOriginPath } from './server/util/ssrf.js';
7
8
  import type { Timings } from './server/util/timings.js';
8
9
  export interface OgImageRenderEventContext {
9
10
  e: H3Event;
10
11
  extension: 'png' | 'jpeg' | 'jpg' | 'webp' | 'svg' | 'html' | 'json';
11
12
  key: string;
12
- basePath: string;
13
+ basePath: SameOriginPath;
13
14
  renderer: Renderer;
14
15
  options: OgImageOptionsInternal;
15
16
  isDevToolsContextRequest: boolean;
@@ -241,6 +242,7 @@ export interface RuntimeCompatibilitySchema {
241
242
  takumi: 'node' | 'node-dev' | 'wasm' | false;
242
243
  sharp: 'node' | false;
243
244
  emoji?: 'local' | 'fetch';
245
+ fetch?: 'node' | 'web';
244
246
  wasm?: any;
245
247
  }
246
248
  export interface RuntimeCompatibilityMeta {
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, rmSync } from 'node:fs';
4
4
  import { readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addBuildPlugin, addServerHandler, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
6
+ import { resolvePath, useNuxt, getNuxtVersion, addTemplate, updateTemplates, directoryToURL, tryResolveModule, addTypeTemplate, defineNuxtModule, createResolver, addImports, hasNuxtModule, hasNuxtModuleCompatibility, getNuxtModuleVersion, addVitePlugin, addBuildPlugin, addServerHandler, addServerImports, addPlugin, addComponentsDir, addServerPlugin } from '@nuxt/kit';
7
7
  import { defu } from 'defu';
8
8
  import { fnv1a64Base36 } from 'fnv1a-64';
9
9
  import { installNuxtSiteConfig } from 'nuxt-site-config/kit';
@@ -119,6 +119,16 @@ async function hasResolvableDependency(dep) {
119
119
  return null;
120
120
  }).then((r) => r && r !== dep);
121
121
  }
122
+ const MIN_SATORI_VERSION = "0.33.5";
123
+ function isSupportedSatoriVersion(version) {
124
+ const current = (version || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
125
+ const minimum = MIN_SATORI_VERSION.split(".").map(Number);
126
+ for (let i = 0; i < minimum.length; i++) {
127
+ if ((current[i] ?? 0) !== minimum[i])
128
+ return (current[i] ?? 0) > minimum[i];
129
+ }
130
+ return true;
131
+ }
122
132
  const VALID_RENDERER_SUFFIXES = ["satori", "browser", "takumi"];
123
133
  const RE_RENDERER_SUFFIX_CI = /\.?(satori|browser|takumi)$/i;
124
134
  const RE_RENDERER_SUFFIX = /(Satori|Browser|Takumi)$/;
@@ -192,6 +202,8 @@ const cloudflare = {
192
202
  sharp: false,
193
203
  emoji: "fetch",
194
204
  // edge size limits - use API instead of bundling 24MB icons
205
+ fetch: "web",
206
+ // no DNS API
195
207
  wasm: {
196
208
  esmImport: true
197
209
  // Do NOT set lazy here — Nitro's cloudflare preset uses lazy: false because
@@ -214,8 +226,9 @@ const WebContainer = {
214
226
  satori: "wasm-fs",
215
227
  takumi: "wasm",
216
228
  sharp: false,
217
- emoji: "fetch"
229
+ emoji: "fetch",
218
230
  // webcontainer has size constraints
231
+ fetch: "web"
219
232
  };
220
233
  const RuntimeCompatibility = {
221
234
  "nitro-dev": NodeDevRuntime,
@@ -256,6 +269,7 @@ const RuntimeCompatibility = {
256
269
  sharp: false,
257
270
  emoji: "fetch",
258
271
  // edge size limits
272
+ fetch: "web",
259
273
  wasm: {
260
274
  rollup: {
261
275
  targetEnv: "auto-inline",
@@ -276,6 +290,7 @@ const RuntimeCompatibility = {
276
290
  sharp: false,
277
291
  emoji: "fetch",
278
292
  // edge size limits - bundling 24MB icons not viable
293
+ fetch: "web",
279
294
  wasm: {
280
295
  // lowers workers kb size
281
296
  esmImport: true,
@@ -351,32 +366,32 @@ async function applyNitroPresetCompatibility(nitroConfig, options) {
351
366
  );
352
367
  if (resolvedCompatibility.satori) {
353
368
  const satoriPackage = await readPackageJSON(`${satoriPkgDir}/package.json`);
354
- if (satoriPackage.dependencies?.harfbuzzjs) {
355
- const harfbuzzWasmPath = resolveModulePath("harfbuzzjs/hb.wasm", { from: `${satoriPkgDir}/package.json` });
356
- if (resolvedCompatibility.satori === "wasm" || target.replace(RE_LEGACY_SUFFIX, "") === "netlify-edge") {
357
- const harfbuzzDir = harfbuzzWasmPath.slice(0, -"hb.wasm".length);
358
- nitroConfig.virtual["#og-image/harfbuzz-callbacks"] = resolvedCompatibility.satori === "wasm" ? await prepareHarfBuzzCallbacks(`${harfbuzzDir}hbjs.js`, join(useNuxt().options.buildDir, "cache/og-image/harfbuzz")) : "export default undefined";
359
- Object.assign(nitroConfig.alias, {
360
- "harfbuzzjs": await resolve.resolvePath("./runtime/server/og-image/bindings/satori/harfbuzz"),
361
- "#og-image/harfbuzz-factory": `${harfbuzzDir}hb.js`,
362
- "#og-image/harfbuzz-adapter": `${harfbuzzDir}hbjs.js`
363
- });
364
- if (resolvedCompatibility.satori === "wasm") {
365
- nitroConfig.alias["#og-image/harfbuzz-wasm"] = `${harfbuzzWasmPath}?module`;
366
- } else {
367
- const base64 = (await readFile(harfbuzzWasmPath)).toString("base64");
368
- nitroConfig.virtual["#og-image/harfbuzz-wasm"] = `export default imports => WebAssembly.instantiate(Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0)), imports)`;
369
- }
370
- } else if (Number.parseInt(getNuxtVersion(useNuxt()), 10) >= 5) {
371
- const config = nitroConfig;
372
- config.traceDeps = [.../* @__PURE__ */ new Set([...config.traceDeps || [], "harfbuzzjs*"])];
369
+ if (!isSupportedSatoriVersion(satoriPackage.version))
370
+ throw new Error(`[Nuxt OG Image] Satori ${satoriPackage.version} is not supported. Satori ${MIN_SATORI_VERSION} or later is required (GHSA-wx4j-mvgx-mqwp). Run: npm install satori@latest`);
371
+ const harfbuzzWasmPath = resolveModulePath("harfbuzzjs/hb.wasm", { from: `${satoriPkgDir}/package.json` });
372
+ if (resolvedCompatibility.satori === "wasm" || target.replace(RE_LEGACY_SUFFIX, "") === "netlify-edge") {
373
+ const harfbuzzDir = harfbuzzWasmPath.slice(0, -"hb.wasm".length);
374
+ nitroConfig.virtual["#og-image/harfbuzz-callbacks"] = resolvedCompatibility.satori === "wasm" ? await prepareHarfBuzzCallbacks(`${harfbuzzDir}hbjs.js`, join(useNuxt().options.buildDir, "cache/og-image/harfbuzz")) : "export default undefined";
375
+ Object.assign(nitroConfig.alias, {
376
+ "harfbuzzjs": await resolve.resolvePath("./runtime/server/og-image/bindings/satori/harfbuzz"),
377
+ "#og-image/harfbuzz-factory": `${harfbuzzDir}hb.js`,
378
+ "#og-image/harfbuzz-adapter": `${harfbuzzDir}hbjs.js`
379
+ });
380
+ if (resolvedCompatibility.satori === "wasm") {
381
+ nitroConfig.alias["#og-image/harfbuzz-wasm"] = `${harfbuzzWasmPath}?module`;
373
382
  } else {
374
- nitroConfig.externals = nitroConfig.externals || {};
375
- nitroConfig.externals.traceInclude = [.../* @__PURE__ */ new Set([
376
- ...nitroConfig.externals.traceInclude || [],
377
- harfbuzzWasmPath
378
- ])];
383
+ const base64 = (await readFile(harfbuzzWasmPath)).toString("base64");
384
+ nitroConfig.virtual["#og-image/harfbuzz-wasm"] = `export default imports => WebAssembly.instantiate(Uint8Array.from(atob(${JSON.stringify(base64)}), c => c.charCodeAt(0)), imports)`;
379
385
  }
386
+ } else if (Number.parseInt(getNuxtVersion(useNuxt()), 10) >= 5) {
387
+ const config = nitroConfig;
388
+ config.traceDeps = [.../* @__PURE__ */ new Set([...config.traceDeps || [], "harfbuzzjs*"])];
389
+ } else {
390
+ nitroConfig.externals = nitroConfig.externals || {};
391
+ nitroConfig.externals.traceInclude = [.../* @__PURE__ */ new Set([
392
+ ...nitroConfig.externals.traceInclude || [],
393
+ harfbuzzWasmPath
394
+ ])];
380
395
  }
381
396
  }
382
397
  if (Object.values(resolvedCompatibility).includes("wasm")) {
@@ -4928,6 +4943,10 @@ declare module '#og-image/renderers/takumi' {
4928
4943
  export default _default
4929
4944
  }
4930
4945
 
4946
+ declare module '#og-image/bindings/fetch' {
4947
+ export { guardedFetch } from '${typesPath.replace("/types", "")}/server/og-image/bindings/fetch/web'
4948
+ }
4949
+
4931
4950
  declare module '#og-image/emoji-transform' {
4932
4951
  import type { OgImageRenderEventContext } from '${typesPath}'
4933
4952
  export function getEmojiSvg(ctx: OgImageRenderEventContext, emoji: string): Promise<string | null>
@@ -5172,6 +5191,7 @@ Or pin to a specific version in package.json:
5172
5191
  setRuntimeAlias("#og-image-cache", resolve("./runtime/server/og-image/cache/lru"));
5173
5192
  const preset = resolveOgImagePreset(nuxt.options.nitro);
5174
5193
  const targetCompatibility = getPresetNitroPresetCompatibility(preset);
5194
+ setRuntimeAlias("#og-image/bindings/fetch", resolve(`./runtime/server/og-image/bindings/fetch/${targetCompatibility.fetch ?? "node"}`));
5175
5195
  const runtimeCompatibilityMeta = {};
5176
5196
  const normalizedPreset = preset.replace(RE_LEGACY_SUFFIX, "");
5177
5197
  const isCloudflareWorkers = ["cloudflare", "cloudflare-module", "cloudflare-durable"].includes(normalizedPreset);
@@ -5587,6 +5607,9 @@ ${familyInfo}`);
5587
5607
  name: "getOgImagePath",
5588
5608
  from: resolve("./runtime/app/utils")
5589
5609
  });
5610
+ addServerImports([
5611
+ { name: "getOgImageUrl", from: resolve("./runtime/server/nitro") }
5612
+ ]);
5590
5613
  const basePluginPath = `./runtime/app/plugins${config.zeroRuntime ? "/__zero-runtime" : ""}`;
5591
5614
  addPlugin({ mode: "server", src: resolve(`${basePluginPath}/route-rule-og-image.server`) });
5592
5615
  addPlugin({ mode: "server", src: resolve(`${basePluginPath}/og-image-canonical-urls.server`) });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-og-image",
3
3
  "type": "module",
4
- "version": "6.8.0",
4
+ "version": "6.9.0",
5
5
  "description": "Enlightened OG Image generation for Nuxt.",
6
6
  "author": {
7
7
  "website": "https://harlanzw.com",
@@ -53,13 +53,13 @@
53
53
  "@takumi-rs/core": "^1.0.0-beta.3 || ^2.0.0",
54
54
  "@takumi-rs/wasm": "^1.0.0-beta.3 || ^2.0.0",
55
55
  "@unhead/vue": "^2.0.5 || ^3.0.0",
56
- "@unocss/config": "^66.10.2",
57
- "@unocss/core": "^66.10.2",
58
- "fontless": "^0.2.0 || ^0.4.3",
56
+ "@unocss/config": "^66.10.5",
57
+ "@unocss/core": "^66.10.5",
58
+ "fontless": "^0.2.0 || ^0.4.3 || ^1.0.0",
59
59
  "nitropack": "^2.13.4",
60
60
  "playwright-core": "^1.50.0",
61
- "satori": ">=0.19.2",
62
- "sharp": "^0.34.0 || ^0.35.4",
61
+ "satori": ">=0.33.5",
62
+ "sharp": "^0.35.4",
63
63
  "tailwindcss": "^4.0.0",
64
64
  "unifont": "^0.7.0 || ^0.8.3",
65
65
  "unstorage": "^1.15.0",
@@ -110,9 +110,9 @@
110
110
  }
111
111
  },
112
112
  "dependencies": {
113
- "@clack/prompts": "^1.8.0",
113
+ "@clack/prompts": "^1.8.1",
114
114
  "@nuxt/kit": "^4.5.2",
115
- "@vue/compiler-sfc": "^3.5.42",
115
+ "@vue/compiler-sfc": "^3.5.43",
116
116
  "chrome-launcher": "^1.2.1",
117
117
  "consola": "^3.4.2",
118
118
  "defu": "^6.1.7",
@@ -120,7 +120,7 @@
120
120
  "exsolve": "^1.1.1",
121
121
  "fnv1a-64": "^0.1.2",
122
122
  "lightningcss": "^1.33.0",
123
- "magic-string": "^1.3.1",
123
+ "magic-string": "^1.4.1",
124
124
  "magicast": "^0.5.5",
125
125
  "mocked-exports": "^0.1.1",
126
126
  "nuxt-site-config": "^4.2.3",
@@ -129,7 +129,7 @@
129
129
  "object-identity": "^0.2.3",
130
130
  "ofetch": "^1.5.1",
131
131
  "ohash": "^2.0.12",
132
- "oxc-parser": "^0.149.0",
132
+ "oxc-parser": "^0.151.0",
133
133
  "oxc-walker": "^1.1.1",
134
134
  "pathe": "^2.0.3",
135
135
  "pkg-types": "^2.3.3",
@@ -139,21 +139,21 @@
139
139
  "tinyexec": "^1.3.1",
140
140
  "ufo": "^1.6.4",
141
141
  "ultrahtml": "^1.7.0",
142
- "unplugin": "^3.3.0"
142
+ "unplugin": "^3.4.0"
143
143
  },
144
144
  "devDependencies": {
145
145
  "@antfu/eslint-config": "^9.5.1",
146
146
  "@fontsource/noto-sans-sc": "^5.3.0",
147
147
  "@iconify-json/carbon": "^1.2.27",
148
148
  "@iconify-json/logos": "^1.2.14",
149
- "@iconify-json/noto": "^1.2.7",
149
+ "@iconify-json/noto": "^1.2.8",
150
150
  "@iconify-json/ri": "^1.2.10",
151
- "@iconify-json/tabler": "^1.2.38",
151
+ "@iconify-json/tabler": "^1.2.39",
152
152
  "@iconify/types": "^2.0.0",
153
153
  "@img/sharp-linux-x64": "^0.35.4",
154
- "@nuxt/content": "^3.16.0",
155
- "@nuxt/devtools": "4.0.0-alpha.18",
156
- "@nuxt/devtools-kit": "4.0.0-alpha.18",
154
+ "@nuxt/content": "^3.16.1",
155
+ "@nuxt/devtools": "4.0.0-beta.1",
156
+ "@nuxt/devtools-kit": "4.0.0-beta.1",
157
157
  "@nuxt/fonts": "^0.14.0",
158
158
  "@nuxt/icon": "^2.5.1",
159
159
  "@nuxt/image": "^2.1.0",
@@ -167,30 +167,30 @@
167
167
  "@resvg/resvg-js": "^2.6.2",
168
168
  "@resvg/resvg-wasm": "^2.6.2",
169
169
  "@tailwindcss/vite": "^4.3.3",
170
- "@takumi-rs/core": "^2.13.7",
170
+ "@takumi-rs/core": "^2.14.0",
171
171
  "@takumi-rs/core-v1": "npm:@takumi-rs/core@1.8.7",
172
- "@takumi-rs/helpers": "^2.13.7",
172
+ "@takumi-rs/helpers": "^2.14.0",
173
173
  "@takumi-rs/helpers-v1": "npm:@takumi-rs/helpers@1.8.7",
174
- "@takumi-rs/wasm": "^2.13.7",
174
+ "@takumi-rs/wasm": "^2.14.0",
175
175
  "@takumi-rs/wasm-v1": "npm:@takumi-rs/wasm@1.8.7",
176
- "@unocss/config": "^66.10.2",
177
- "@unocss/core": "^66.10.2",
178
- "@unocss/nuxt": "^66.10.2",
179
- "@unocss/preset-icons": "^66.10.2",
180
- "@unocss/preset-wind": "^66.10.2",
181
- "@unocss/preset-wind4": "^66.10.2",
182
- "@vitejs/plugin-vue": "^6.0.8",
183
- "@vue/compiler-core": "^3.5.42",
184
- "@vueuse/nuxt": "^14.4.0",
176
+ "@unocss/config": "^66.10.5",
177
+ "@unocss/core": "^66.10.5",
178
+ "@unocss/nuxt": "^66.10.5",
179
+ "@unocss/preset-icons": "^66.10.5",
180
+ "@unocss/preset-wind": "^66.10.5",
181
+ "@unocss/preset-wind4": "^66.10.5",
182
+ "@vitejs/plugin-vue": "^6.0.9",
183
+ "@vue/compiler-core": "^3.5.43",
184
+ "@vueuse/nuxt": "^15.0.0",
185
185
  "birpc": "^4.2.0",
186
186
  "bumpp": "^12.3.0",
187
- "eslint": "^10.10.0",
188
- "eslint-plugin-harlanzw": "^0.21.1",
189
- "fontless": "^0.4.3",
187
+ "eslint": "^10.11.0",
188
+ "eslint-plugin-harlanzw": "^0.22.0",
189
+ "fontless": "^1.0.0",
190
190
  "fontless-v0-2": "npm:fontless@0.2.0",
191
191
  "globby": "^16.2.4",
192
192
  "happy-dom": "^20.14.5",
193
- "hookable": "^6.1.1",
193
+ "hookable": "^6.1.2",
194
194
  "jest-image-snapshot": "^6.5.2",
195
195
  "lightningcss": "^1.33.0",
196
196
  "nitropack": "^2.13.4",
@@ -199,24 +199,21 @@
199
199
  "playwright": "^1.63.0",
200
200
  "playwright-core": "^1.63.0",
201
201
  "sass": "^1.104.1",
202
- "satori": "^0.33.4",
203
- "satori-v0-19": "npm:satori@0.19.2",
204
- "satori-v0-32": "npm:satori@0.32.0",
202
+ "satori": "^0.33.5",
205
203
  "sharp": "^0.35.4",
206
- "sharp-v0-34": "npm:sharp@0.34.0",
207
204
  "sirv": "^3.0.2",
208
205
  "tailwindcss": "^4.3.3",
209
206
  "typescript": "6.0.3",
210
207
  "unbuild": "^3.6.1",
211
208
  "unifont": "^0.8.3",
212
209
  "unifont-v0-7": "npm:unifont@0.7.0",
213
- "unocss": "^66.10.2",
214
- "vitest": "^5.0.0",
215
- "vue": "^3.5.42",
210
+ "unocss": "^66.10.5",
211
+ "vitest": "^5.0.1",
212
+ "vue": "^3.5.43",
216
213
  "vue-tsc": "^3.3.11",
217
- "wrangler": "^4.131.1",
214
+ "wrangler": "^4.136.1",
218
215
  "yoga-wasm-web": "^0.3.3",
219
- "zod": "^4.6.4"
216
+ "zod": "^4.6.5"
220
217
  },
221
218
  "scripts": {
222
219
  "bench:edge-startup": "nuxt build bench/fixtures/edge-startup && node bench/edge-startup.mjs bench/fixtures/edge-startup/.netlify/edge-functions/server/server.js",
@@ -29,6 +29,14 @@ declare module '#og-image/renderers/browser' {
29
29
  export default renderer
30
30
  }
31
31
 
32
+ declare module '#og-image/bindings/fetch' {
33
+ export function guardedFetch(url: string, init: {
34
+ headers?: Record<string, string>
35
+ signal: AbortSignal
36
+ isBlockedAddress?: (address: string) => boolean
37
+ }): Promise<Response>
38
+ }
39
+
32
40
  declare module '#og-image/bindings/satori' {
33
41
  const satori: typeof import('satori').satori
34
42
  export default satori