From b20ad542b304bf6367e2d43d34d6580b23ca13d2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 6 Mar 2026 14:52:58 +1100 Subject: [PATCH 1/5] feat: add geocode proxy to reduce Google Maps billing costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server-side Places API proxy at /_scripts/google-maps-geocode-proxy - Aggressive caching (24h default) for place name → coordinate lookups - API key stays server-side, never exposed to client - ScriptGoogleMaps component auto-uses proxy when enabled - Falls back to client-side Places API when proxy unavailable Closes #83 --- src/module.ts | 38 +++++++- .../GoogleMaps/ScriptGoogleMaps.vue | 15 +++- .../server/google-maps-geocode-proxy.ts | 87 +++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/runtime/server/google-maps-geocode-proxy.ts diff --git a/src/module.ts b/src/module.ts index 9a7755ea..c2af2a07 100644 --- a/src/module.ts +++ b/src/module.ts @@ -272,6 +272,23 @@ export interface ModuleOptions { */ cacheMaxAge?: number } + /** + * Google Geocode proxy configuration. + * Proxies Places API geocoding through your server with aggressive caching + * to reduce API costs for place name to coordinate resolution. + */ + googleGeocodeProxy?: { + /** + * Enable geocode proxying through your own origin. + * @default false + */ + enabled?: boolean + /** + * Cache duration for geocode results in seconds. + * @default 86400 (24 hours) + */ + cacheMaxAge?: number + } /** * Whether the module is enabled. * @@ -314,6 +331,10 @@ export default defineNuxtModule({ enabled: false, cacheMaxAge: 3600, }, + googleGeocodeProxy: { + enabled: false, + cacheMaxAge: 86400, + }, enabled: true, debug: false, }, @@ -335,11 +356,15 @@ export default defineNuxtModule({ if (unheadVersion?.startsWith('1')) { logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`) } + const mapsApiKey = (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey nuxt.options.runtimeConfig['nuxt-scripts'] = { version: version!, // Private proxy config with API key (server-side only) googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled - ? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey } + ? { apiKey: mapsApiKey } + : undefined, + googleGeocodeProxy: config.googleGeocodeProxy?.enabled + ? { apiKey: mapsApiKey } : undefined, } as any nuxt.options.runtimeConfig.public['nuxt-scripts'] = { @@ -350,6 +375,9 @@ export default defineNuxtModule({ googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge } : undefined, + googleGeocodeProxy: config.googleGeocodeProxy?.enabled + ? { enabled: true, cacheMaxAge: config.googleGeocodeProxy.cacheMaxAge } + : undefined, } as any // Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution @@ -703,6 +731,14 @@ export default defineNuxtModule({ }) } + // Add Google Geocode proxy handler if enabled + if (config.googleGeocodeProxy?.enabled) { + addServerHandler({ + route: '/_scripts/google-maps-geocode-proxy', + handler: await resolvePath('./runtime/server/google-maps-geocode-proxy'), + }) + } + // Add Gravatar proxy handler when registry.gravatar is enabled if (config.registry?.gravatar) { const gravatarConfig = typeof config.registry.gravatar === 'object' && !Array.isArray(config.registry.gravatar) diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index f1c89bc3..52e4d6a0 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -7,6 +7,7 @@ import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTrig import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps' import { scriptRuntimeConfig } from '#nuxt-scripts/utils' import { defu } from 'defu' +import { $fetch } from 'ofetch' import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app' import { hash } from 'ohash' import { withQuery } from 'ufo' @@ -132,6 +133,7 @@ const emits = defineEmits<{ const apiKey = props.apiKey || scriptRuntimeConfig('googleMaps')?.apiKey const runtimeConfig = useRuntimeConfig() const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy +const geocodeProxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleGeocodeProxy // Color mode support - try to auto-detect from @nuxtjs/color-mode const nuxtApp = tryUseNuxtApp() @@ -249,7 +251,18 @@ async function resolveQueryToLatLang(query: string) { if (queryToLatLngCache.has(query)) { return Promise.resolve(queryToLatLngCache.get(query)) } - // only if the query is a string we need to do a lookup + // Use server-side geocode proxy when enabled to save Places API costs + if (geocodeProxyConfig?.enabled) { + const data = await $fetch<{ lat: number, lng: number }>('/_scripts/google-maps-geocode-proxy', { + query: { input: query }, + }).catch(() => null) + if (data) { + const latLng = new mapsApi.value!.LatLng(data.lat, data.lng) + queryToLatLngCache.set(query, latLng) + return latLng + } + } + // Fallback to client-side Places API // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { if (!mapsApi.value) { diff --git a/src/runtime/server/google-maps-geocode-proxy.ts b/src/runtime/server/google-maps-geocode-proxy.ts new file mode 100644 index 00000000..380b1feb --- /dev/null +++ b/src/runtime/server/google-maps-geocode-proxy.ts @@ -0,0 +1,87 @@ +import { useRuntimeConfig } from '#imports' +import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' +import { withQuery } from 'ufo' + +export default defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig() + const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleGeocodeProxy + const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleGeocodeProxy + + if (!publicConfig?.enabled) { + throw createError({ + statusCode: 404, + statusMessage: 'Google Geocode proxy is not enabled', + }) + } + + const apiKey = privateConfig?.apiKey + if (!apiKey) { + throw createError({ + statusCode: 500, + statusMessage: 'Google Maps API key not configured for geocode proxy', + }) + } + + // Validate referer to prevent external abuse + const referer = getHeader(event, 'referer') + const host = getHeader(event, 'host') + if (referer && host) { + let refererHost: string | undefined + try { + refererHost = new URL(referer).host + } + catch {} + if (refererHost && refererHost !== host) { + throw createError({ + statusCode: 403, + statusMessage: 'Invalid referer', + }) + } + } + + const query = getQuery(event) + const input = query.input as string + + if (!input) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing "input" query parameter', + }) + } + + const googleUrl = withQuery('https://maps.googleapis.com/maps/api/place/findplacefromtext/json', { + input, + inputtype: 'textquery', + fields: 'name,geometry', + key: apiKey, + }) + + const data = await $fetch<{ candidates: Array<{ geometry: { location: { lat: number, lng: number } }, name: string }>, status: string }>(googleUrl, { + headers: { + 'User-Agent': 'Nuxt Scripts Google Geocode Proxy', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to geocode query', + }) + }) + + if (data.status !== 'OK' || !data.candidates?.[0]?.geometry?.location) { + throw createError({ + statusCode: 404, + statusMessage: `No location found for "${input}"`, + }) + } + + const location = data.candidates[0].geometry.location + + // Cache aggressively - place names rarely change coordinates + const cacheMaxAge = publicConfig.cacheMaxAge || 86400 + setHeader(event, 'Content-Type', 'application/json') + setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) + setHeader(event, 'Vary', 'Accept-Encoding') + + return { lat: location.lat, lng: location.lng, name: data.candidates[0].name } +}) From 7bfe9be0c5374ba986a68a6f405ec63e8ede87e7 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 6 Mar 2026 14:58:30 +1100 Subject: [PATCH 2/5] docs: add geocode proxy documentation to Google Maps page --- docs/content/scripts/google-maps.md | 34 +++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/content/scripts/google-maps.md b/docs/content/scripts/google-maps.md index 302afcfe..73659d48 100644 --- a/docs/content/scripts/google-maps.md +++ b/docs/content/scripts/google-maps.md @@ -53,16 +53,42 @@ Optionally, you can provide permissions to the [Static Maps API](https://develop Showing an interactive JS map requires the Maps JavaScript API, which is a paid service. If a user interacts with the map, the following costs will be incurred: - $7 per 1000 loads for the Maps JavaScript API (default for using Google Maps) -- $2 per 1000 loads for the Static Maps API - Only used when you don't provide a `placeholder` slot. -- $5 per 1000 loads for the Geocoding API - Only used when you don't provide a `google.maps.LatLng` object instead of a query string for the `center` prop +- $2 per 1000 loads for the Static Maps API - Only used when you don't provide a `placeholder` slot. **Can be cached** with `googleStaticMapsProxy`. +- $5 per 1000 loads for the Geocoding API - Only used when you don't provide a `google.maps.LatLng` object instead of a query string for the `center` prop. **Can be cached** with `googleGeocodeProxy`. However, if the user never engages with the map, only the Static Maps API usage ($2 per 1000 loads) will be charged, assuming you're using it. -Billing will be optimized in a [future update](https://github.com/nuxt/scripts/issues/83). - You should consider using the [Iframe Embed](https://developers.google.com/maps/documentation/embed/get-started) instead if you want to avoid these costs and are okay with a less interactive map. +#### Cost Optimization Proxies + +Enable server-side proxies to cache API responses, hide your API key from clients, and reduce billing: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + // Proxy and cache static map placeholder images (default: 1 hour) + googleStaticMapsProxy: { + enabled: true, + cacheMaxAge: 3600, + }, + // Proxy and cache geocoding lookups for string-based centers (default: 24 hours) + googleGeocodeProxy: { + enabled: true, + cacheMaxAge: 86400, + }, + }, +}) +``` + +| Proxy | API Saved | Cache Default | What It Does | +|-------|-----------|---------------|--------------| +| `googleStaticMapsProxy` | Static Maps ($2/1k) | 1 hour | Caches placeholder images, hides API key | +| `googleGeocodeProxy` | Places ($5/1k) | 24 hours | Caches place name → coordinate lookups | + +Both proxies validate the request referer to prevent external abuse and inject the API key server-side so it's never exposed to the client. + ### Demo ::code-group From b9ef686b152c05a91ccf0f4fd5d92d14d49fdfea Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 6 Mar 2026 15:01:56 +1100 Subject: [PATCH 3/5] feat: add rate limiting and input validation to map proxies - IP-based rate limiting (30/min geocode, 60/min static maps) - Input length validation (200 char max) for geocode queries - Query parameter allowlisting for static maps proxy - Fix unprotected URL parsing in referer validation --- .../server/google-maps-geocode-proxy.ts | 35 ++++++++++++- .../server/google-static-maps-proxy.ts | 49 +++++++++++++++++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/runtime/server/google-maps-geocode-proxy.ts b/src/runtime/server/google-maps-geocode-proxy.ts index 380b1feb..43d49731 100644 --- a/src/runtime/server/google-maps-geocode-proxy.ts +++ b/src/runtime/server/google-maps-geocode-proxy.ts @@ -1,8 +1,25 @@ import { useRuntimeConfig } from '#imports' -import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3' +import { createError, defineEventHandler, getHeader, getQuery, getRequestIP, setHeader } from 'h3' import { $fetch } from 'ofetch' import { withQuery } from 'ufo' +const MAX_INPUT_LENGTH = 200 +const RATE_LIMIT_WINDOW_MS = 60_000 +const RATE_LIMIT_MAX = 30 + +const requestCounts = new Map() + +function checkRateLimit(ip: string): boolean { + const now = Date.now() + const entry = requestCounts.get(ip) + if (!entry || now > entry.resetAt) { + requestCounts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }) + return true + } + entry.count++ + return entry.count <= RATE_LIMIT_MAX +} + export default defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleGeocodeProxy @@ -23,6 +40,15 @@ export default defineEventHandler(async (event) => { }) } + // Rate limit by IP + const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown' + if (!checkRateLimit(ip)) { + throw createError({ + statusCode: 429, + statusMessage: 'Too many geocode requests', + }) + } + // Validate referer to prevent external abuse const referer = getHeader(event, 'referer') const host = getHeader(event, 'host') @@ -50,6 +76,13 @@ export default defineEventHandler(async (event) => { }) } + if (input.length > MAX_INPUT_LENGTH) { + throw createError({ + statusCode: 400, + statusMessage: `Input too long (max ${MAX_INPUT_LENGTH} characters)`, + }) + } + const googleUrl = withQuery('https://maps.googleapis.com/maps/api/place/findplacefromtext/json', { input, inputtype: 'textquery', diff --git a/src/runtime/server/google-static-maps-proxy.ts b/src/runtime/server/google-static-maps-proxy.ts index d85a2ff6..4cfd38a2 100644 --- a/src/runtime/server/google-static-maps-proxy.ts +++ b/src/runtime/server/google-static-maps-proxy.ts @@ -1,8 +1,30 @@ import { useRuntimeConfig } from '#imports' -import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3' +import { createError, defineEventHandler, getHeader, getQuery, getRequestIP, setHeader } from 'h3' import { $fetch } from 'ofetch' import { withQuery } from 'ufo' +const RATE_LIMIT_WINDOW_MS = 60_000 +const RATE_LIMIT_MAX = 60 + +const ALLOWED_PARAMS = new Set([ + 'center', 'zoom', 'size', 'scale', 'format', 'maptype', + 'language', 'region', 'markers', 'path', 'visible', + 'style', 'map_id', 'signature', +]) + +const requestCounts = new Map() + +function checkRateLimit(ip: string): boolean { + const now = Date.now() + const entry = requestCounts.get(ip) + if (!entry || now > entry.resetAt) { + requestCounts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }) + return true + } + entry.count++ + return entry.count <= RATE_LIMIT_MAX +} + export default defineEventHandler(async (event) => { const runtimeConfig = useRuntimeConfig() const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy @@ -24,12 +46,25 @@ export default defineEventHandler(async (event) => { }) } + // Rate limit by IP + const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown' + if (!checkRateLimit(ip)) { + throw createError({ + statusCode: 429, + statusMessage: 'Too many static map requests', + }) + } + // Validate referer to prevent external abuse const referer = getHeader(event, 'referer') const host = getHeader(event, 'host') if (referer && host) { - const refererUrl = new URL(referer).host - if (refererUrl !== host) { + let refererHost: string | undefined + try { + refererHost = new URL(referer).host + } + catch {} + if (refererHost && refererHost !== host) { throw createError({ statusCode: 403, statusMessage: 'Invalid referer', @@ -39,8 +74,12 @@ export default defineEventHandler(async (event) => { const query = getQuery(event) - // Remove any client-provided key and use server-side key - const { key: _clientKey, ...safeQuery } = query + // Only allow known Static Maps API parameters + const safeQuery: Record = {} + for (const [k, v] of Object.entries(query)) { + if (ALLOWED_PARAMS.has(k)) + safeQuery[k] = v + } const googleMapsUrl = withQuery('https://maps.googleapis.com/maps/api/staticmap', { ...safeQuery, From ce9fd983e3c4d21ac3a912cc4220a53966bf7d93 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 6 Mar 2026 15:15:15 +1100 Subject: [PATCH 4/5] feat: CSRF protection and auto-enable Google Maps proxies - Double-submit cookie CSRF token for geocode proxy - Auto-enable proxies when googleMaps is in registry config - Can be explicitly disabled with `enabled: false` - Update docs to reflect auto-enable behavior --- docs/content/scripts/google-maps.md | 19 ++++----- src/module.ts | 26 ++++++------ .../GoogleMaps/ScriptGoogleMaps.vue | 18 +++++++- .../server/google-maps-geocode-proxy.ts | 4 ++ src/runtime/server/utils/proxy-csrf.ts | 41 +++++++++++++++++++ 5 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 src/runtime/server/utils/proxy-csrf.ts diff --git a/docs/content/scripts/google-maps.md b/docs/content/scripts/google-maps.md index 73659d48..0a580047 100644 --- a/docs/content/scripts/google-maps.md +++ b/docs/content/scripts/google-maps.md @@ -63,21 +63,18 @@ and are okay with a less interactive map. #### Cost Optimization Proxies -Enable server-side proxies to cache API responses, hide your API key from clients, and reduce billing: +When `googleMaps` is in your registry config, server-side proxies are **automatically enabled** to cache API responses, hide your API key, and reduce billing. You can customize cache durations or disable them: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { - // Proxy and cache static map placeholder images (default: 1 hour) - googleStaticMapsProxy: { - enabled: true, - cacheMaxAge: 3600, - }, - // Proxy and cache geocoding lookups for string-based centers (default: 24 hours) - googleGeocodeProxy: { - enabled: true, - cacheMaxAge: 86400, + registry: { + googleMaps: { apiKey: 'YOUR_KEY' }, // auto-enables proxies }, + // Optional: customize cache durations + googleStaticMapsProxy: { cacheMaxAge: 7200 }, + googleGeocodeProxy: { cacheMaxAge: 172800 }, + // Or disable: googleGeocodeProxy: { enabled: false }, }, }) ``` @@ -87,7 +84,7 @@ export default defineNuxtConfig({ | `googleStaticMapsProxy` | Static Maps ($2/1k) | 1 hour | Caches placeholder images, hides API key | | `googleGeocodeProxy` | Places ($5/1k) | 24 hours | Caches place name → coordinate lookups | -Both proxies validate the request referer to prevent external abuse and inject the API key server-side so it's never exposed to the client. +**Security:** Both proxies include IP-based rate limiting, referer validation, and CSRF token protection (geocode proxy). API keys are injected server-side and never exposed to the client. ### Demo diff --git a/src/module.ts b/src/module.ts index c2af2a07..2ca62058 100644 --- a/src/module.ts +++ b/src/module.ts @@ -328,11 +328,9 @@ export default defineNuxtModule({ }, }, googleStaticMapsProxy: { - enabled: false, cacheMaxAge: 3600, }, googleGeocodeProxy: { - enabled: false, cacheMaxAge: 86400, }, enabled: true, @@ -357,13 +355,18 @@ export default defineNuxtModule({ logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`) } const mapsApiKey = (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey + // Auto-enable Google Maps proxies when googleMaps is in the registry + const hasGoogleMaps = !!config.registry?.googleMaps + const staticMapsEnabled = config.googleStaticMapsProxy?.enabled ?? hasGoogleMaps + const geocodeEnabled = config.googleGeocodeProxy?.enabled ?? hasGoogleMaps + nuxt.options.runtimeConfig['nuxt-scripts'] = { version: version!, // Private proxy config with API key (server-side only) - googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled + googleStaticMapsProxy: staticMapsEnabled ? { apiKey: mapsApiKey } : undefined, - googleGeocodeProxy: config.googleGeocodeProxy?.enabled + googleGeocodeProxy: geocodeEnabled ? { apiKey: mapsApiKey } : undefined, } as any @@ -372,11 +375,11 @@ export default defineNuxtModule({ version: nuxt.options.dev ? version : undefined, defaultScriptOptions: config.defaultScriptOptions as any, // Only expose enabled and cacheMaxAge to client, not apiKey - googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled - ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge } + googleStaticMapsProxy: staticMapsEnabled + ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy?.cacheMaxAge } : undefined, - googleGeocodeProxy: config.googleGeocodeProxy?.enabled - ? { enabled: true, cacheMaxAge: config.googleGeocodeProxy.cacheMaxAge } + googleGeocodeProxy: geocodeEnabled + ? { enabled: true, cacheMaxAge: config.googleGeocodeProxy?.cacheMaxAge } : undefined, } as any @@ -723,16 +726,15 @@ export default defineNuxtModule({ }) }) - // Add Google Static Maps proxy handler if enabled - if (config.googleStaticMapsProxy?.enabled) { + // Add Google Maps proxy handlers (auto-enabled when googleMaps is in registry) + if (staticMapsEnabled) { addServerHandler({ route: '/_scripts/google-static-maps-proxy', handler: await resolvePath('./runtime/server/google-static-maps-proxy'), }) } - // Add Google Geocode proxy handler if enabled - if (config.googleGeocodeProxy?.enabled) { + if (geocodeEnabled) { addServerHandler({ route: '/_scripts/google-maps-geocode-proxy', handler: await resolvePath('./runtime/server/google-maps-geocode-proxy'), diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 52e4d6a0..f2b1534b 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -8,7 +8,7 @@ import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps' import { scriptRuntimeConfig } from '#nuxt-scripts/utils' import { defu } from 'defu' import { $fetch } from 'ofetch' -import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app' +import { tryUseNuxtApp, useHead, useRequestEvent, useRuntimeConfig } from 'nuxt/app' import { hash } from 'ohash' import { withQuery } from 'ufo' import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, watch } from 'vue' @@ -135,6 +135,21 @@ const runtimeConfig = useRuntimeConfig() const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy const geocodeProxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleGeocodeProxy +// Set CSRF cookie during SSR for geocode proxy protection +if (import.meta.server && geocodeProxyConfig?.enabled) { + const event = useRequestEvent() + if (event) { + const { setProxyCsrfCookie } = await import('../../server/utils/proxy-csrf') + setProxyCsrfCookie(event) + } +} + +// Read CSRF token from cookie on client for geocode proxy requests +function getCsrfToken(): string { + if (import.meta.server) return '' + return document.cookie.split('; ').find(c => c.startsWith('__nuxt_scripts_proxy='))?.split('=')[1] || '' +} + // Color mode support - try to auto-detect from @nuxtjs/color-mode const nuxtApp = tryUseNuxtApp() const nuxtColorMode = nuxtApp?.$colorMode as { value: string } | undefined @@ -255,6 +270,7 @@ async function resolveQueryToLatLang(query: string) { if (geocodeProxyConfig?.enabled) { const data = await $fetch<{ lat: number, lng: number }>('/_scripts/google-maps-geocode-proxy', { query: { input: query }, + headers: { 'x-nuxt-scripts-token': getCsrfToken() }, }).catch(() => null) if (data) { const latLng = new mapsApi.value!.LatLng(data.lat, data.lng) diff --git a/src/runtime/server/google-maps-geocode-proxy.ts b/src/runtime/server/google-maps-geocode-proxy.ts index 43d49731..26e60e5d 100644 --- a/src/runtime/server/google-maps-geocode-proxy.ts +++ b/src/runtime/server/google-maps-geocode-proxy.ts @@ -2,6 +2,7 @@ import { useRuntimeConfig } from '#imports' import { createError, defineEventHandler, getHeader, getQuery, getRequestIP, setHeader } from 'h3' import { $fetch } from 'ofetch' import { withQuery } from 'ufo' +import { validateProxyCsrf } from './utils/proxy-csrf' const MAX_INPUT_LENGTH = 200 const RATE_LIMIT_WINDOW_MS = 60_000 @@ -40,6 +41,9 @@ export default defineEventHandler(async (event) => { }) } + // CSRF validation (double-submit cookie pattern) + validateProxyCsrf(event) + // Rate limit by IP const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown' if (!checkRateLimit(ip)) { diff --git a/src/runtime/server/utils/proxy-csrf.ts b/src/runtime/server/utils/proxy-csrf.ts new file mode 100644 index 00000000..190bf83d --- /dev/null +++ b/src/runtime/server/utils/proxy-csrf.ts @@ -0,0 +1,41 @@ +import { createError, getCookie, getHeader, setCookie } from 'h3' +import type { H3Event } from 'h3' + +const COOKIE_NAME = '__nuxt_scripts_proxy' +const HEADER_NAME = 'x-nuxt-scripts-token' + +// Generate a random token +function generateToken(): string { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') +} + +// Set CSRF cookie during SSR (called from the component's server-side render) +export function setProxyCsrfCookie(event: H3Event): string { + let token = getCookie(event, COOKIE_NAME) + if (!token) { + token = generateToken() + setCookie(event, COOKIE_NAME, token, { + httpOnly: false, // must be readable by JS for double-submit pattern + secure: true, + sameSite: 'strict', + path: '/', + maxAge: 86400, + }) + } + return token +} + +// Validate CSRF token on proxy requests (double-submit cookie pattern) +export function validateProxyCsrf(event: H3Event): void { + const cookie = getCookie(event, COOKIE_NAME) + const header = getHeader(event, HEADER_NAME) + + if (!cookie || !header || cookie !== header) { + throw createError({ + statusCode: 403, + statusMessage: 'Invalid proxy token', + }) + } +} From b0201ca0330f370a8f1b2209eb38b53a380cef0f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 6 Mar 2026 15:32:30 +1100 Subject: [PATCH 5/5] fix: lint errors, move CSRF to server middleware - Move CSRF cookie setup from component to Nitro server middleware - Fix import ordering and formatting lint errors - Fix passive voice in docs --- docs/content/scripts/google-maps.md | 6 +++--- src/module.ts | 5 +++++ .../components/GoogleMaps/ScriptGoogleMaps.vue | 14 +++----------- src/runtime/server/google-static-maps-proxy.ts | 17 ++++++++++++++--- src/runtime/server/middleware/proxy-csrf.ts | 6 ++++++ src/runtime/server/utils/proxy-csrf.ts | 2 +- 6 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 src/runtime/server/middleware/proxy-csrf.ts diff --git a/docs/content/scripts/google-maps.md b/docs/content/scripts/google-maps.md index 0a580047..6511a3c1 100644 --- a/docs/content/scripts/google-maps.md +++ b/docs/content/scripts/google-maps.md @@ -53,8 +53,8 @@ Optionally, you can provide permissions to the [Static Maps API](https://develop Showing an interactive JS map requires the Maps JavaScript API, which is a paid service. If a user interacts with the map, the following costs will be incurred: - $7 per 1000 loads for the Maps JavaScript API (default for using Google Maps) -- $2 per 1000 loads for the Static Maps API - Only used when you don't provide a `placeholder` slot. **Can be cached** with `googleStaticMapsProxy`. -- $5 per 1000 loads for the Geocoding API - Only used when you don't provide a `google.maps.LatLng` object instead of a query string for the `center` prop. **Can be cached** with `googleGeocodeProxy`. +- $2 per 1000 loads for the Static Maps API - Only used when you don't provide a `placeholder` slot. The `googleStaticMapsProxy` **caches these** automatically. +- $5 per 1000 loads for the Geocoding API - Only used when you don't provide a `google.maps.LatLng` object instead of a query string for the `center` prop. The `googleGeocodeProxy` **caches these** automatically. However, if the user never engages with the map, only the Static Maps API usage ($2 per 1000 loads) will be charged, assuming you're using it. @@ -84,7 +84,7 @@ export default defineNuxtConfig({ | `googleStaticMapsProxy` | Static Maps ($2/1k) | 1 hour | Caches placeholder images, hides API key | | `googleGeocodeProxy` | Places ($5/1k) | 24 hours | Caches place name → coordinate lookups | -**Security:** Both proxies include IP-based rate limiting, referer validation, and CSRF token protection (geocode proxy). API keys are injected server-side and never exposed to the client. +**Security:** Both proxies include IP-based rate limiting, referer validation, and CSRF token protection (geocode proxy). The server injects API keys at request time, keeping them hidden from the client. ### Demo diff --git a/src/module.ts b/src/module.ts index 2ca62058..84aedbb1 100644 --- a/src/module.ts +++ b/src/module.ts @@ -739,6 +739,11 @@ export default defineNuxtModule({ route: '/_scripts/google-maps-geocode-proxy', handler: await resolvePath('./runtime/server/google-maps-geocode-proxy'), }) + // CSRF cookie middleware for geocode proxy protection + addServerHandler({ + middleware: true, + handler: await resolvePath('./runtime/server/middleware/proxy-csrf'), + }) } // Add Gravatar proxy handler when registry.gravatar is enabled diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index f2b1534b..4f6d3966 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -7,8 +7,8 @@ import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTrig import { useScriptGoogleMaps } from '#nuxt-scripts/registry/google-maps' import { scriptRuntimeConfig } from '#nuxt-scripts/utils' import { defu } from 'defu' +import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app' import { $fetch } from 'ofetch' -import { tryUseNuxtApp, useHead, useRequestEvent, useRuntimeConfig } from 'nuxt/app' import { hash } from 'ohash' import { withQuery } from 'ufo' import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, watch } from 'vue' @@ -135,18 +135,10 @@ const runtimeConfig = useRuntimeConfig() const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy const geocodeProxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleGeocodeProxy -// Set CSRF cookie during SSR for geocode proxy protection -if (import.meta.server && geocodeProxyConfig?.enabled) { - const event = useRequestEvent() - if (event) { - const { setProxyCsrfCookie } = await import('../../server/utils/proxy-csrf') - setProxyCsrfCookie(event) - } -} - // Read CSRF token from cookie on client for geocode proxy requests function getCsrfToken(): string { - if (import.meta.server) return '' + if (import.meta.server) + return '' return document.cookie.split('; ').find(c => c.startsWith('__nuxt_scripts_proxy='))?.split('=')[1] || '' } diff --git a/src/runtime/server/google-static-maps-proxy.ts b/src/runtime/server/google-static-maps-proxy.ts index 4cfd38a2..05fe17ee 100644 --- a/src/runtime/server/google-static-maps-proxy.ts +++ b/src/runtime/server/google-static-maps-proxy.ts @@ -7,9 +7,20 @@ const RATE_LIMIT_WINDOW_MS = 60_000 const RATE_LIMIT_MAX = 60 const ALLOWED_PARAMS = new Set([ - 'center', 'zoom', 'size', 'scale', 'format', 'maptype', - 'language', 'region', 'markers', 'path', 'visible', - 'style', 'map_id', 'signature', + 'center', + 'zoom', + 'size', + 'scale', + 'format', + 'maptype', + 'language', + 'region', + 'markers', + 'path', + 'visible', + 'style', + 'map_id', + 'signature', ]) const requestCounts = new Map() diff --git a/src/runtime/server/middleware/proxy-csrf.ts b/src/runtime/server/middleware/proxy-csrf.ts new file mode 100644 index 00000000..b3bb74a6 --- /dev/null +++ b/src/runtime/server/middleware/proxy-csrf.ts @@ -0,0 +1,6 @@ +import { defineEventHandler } from 'h3' +import { setProxyCsrfCookie } from '../utils/proxy-csrf' + +export default defineEventHandler((event) => { + setProxyCsrfCookie(event) +}) diff --git a/src/runtime/server/utils/proxy-csrf.ts b/src/runtime/server/utils/proxy-csrf.ts index 190bf83d..95e143d8 100644 --- a/src/runtime/server/utils/proxy-csrf.ts +++ b/src/runtime/server/utils/proxy-csrf.ts @@ -1,5 +1,5 @@ -import { createError, getCookie, getHeader, setCookie } from 'h3' import type { H3Event } from 'h3' +import { createError, getCookie, getHeader, setCookie } from 'h3' const COOKIE_NAME = '__nuxt_scripts_proxy' const HEADER_NAME = 'x-nuxt-scripts-token'