Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions docs/content/scripts/google-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,42 @@

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`.

Check warning on line 56 in docs/content/scripts/google-maps.md

View workflow job for this annotation

GitHub Actions / test

Passive voice: "be cached". Consider rewriting in active voice
- $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`.

Check warning on line 57 in docs/content/scripts/google-maps.md

View workflow job for this annotation

GitHub Actions / test

Passive voice: "be cached". Consider rewriting in active voice
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Name the billed API correctly.

googleGeocodeProxy does not call the Geocoding API; it proxies Places findplacefromtext. Documenting this as β€œGeocoding API” here can send readers to enable the wrong Google product and misread the billing surface. Please align this bullet with src/runtime/server/google-maps-geocode-proxy.ts.

Suggested wording
-- $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`.
+- $5 per 1000 requests for the Places API (Find Place From Text) - 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`.
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/scripts/google-maps.md` around lines 56 - 57, Update the
documentation text to correctly name the billed API used by googleGeocodeProxy:
it proxies the Places API's findplacefromtext endpoint (not the Geocoding API).
Replace the β€œGeocoding API” phrase with β€œPlaces API (findplacefromtext)” and
mention that billing/costs refer to the Places API; reference the proxy name
googleGeocodeProxy and the implementation in
src/runtime/server/google-maps-geocode-proxy.ts so readers enable the correct
Google product.


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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Qualify the referer-protection claim.

This sentence is stronger than the implementation. Both proxy handlers only reject mismatched referers when referer and host headers are present; requests without a referer header still pass. Please soften the wording so users do not assume this fully prevents external abuse.

Suggested wording
-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.
+Both proxies attempt same-origin referer validation when the relevant headers are present, and inject the API key server-side so it's never exposed to the client.
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.
Both proxies attempt same-origin referer validation when the relevant headers are present, and inject the API key server-side so it's never exposed to the client.
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/scripts/google-maps.md` at line 90, The sentence claiming "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." overstates behavior;
update that sentence to qualify referer protection by noting the handlers only
perform referer/host header checks when those headers are present and will not
reject requests that lack a Referer header, and keep the note that the API key
is injected server-side. Edit the sentence in the
docs/content/scripts/google-maps.md entry for that line to something like: state
that proxies perform referer validation when Referer and Host headers are
provided (so requests without a Referer may still pass), and that the API key is
injected server-side to avoid exposing it to the client.


### Demo

::code-group
Expand Down
38 changes: 37 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -314,6 +331,10 @@ export default defineNuxtModule<ModuleOptions>({
enabled: false,
cacheMaxAge: 3600,
},
googleGeocodeProxy: {
enabled: false,
cacheMaxAge: 86400,
},
enabled: true,
debug: false,
},
Expand All @@ -335,11 +356,15 @@ export default defineNuxtModule<ModuleOptions>({
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,
Comment on lines +357 to 371
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Populate proxy API keys from the registry config before enabling the handlers.

Line 357 reads the key from nuxt.options.runtimeConfig.public.scripts, but Lines 388-395 only merge config.registry into that object later. With the documented config shape scripts.registry.googleMaps = { apiKey: '...' }, both proxies are auto-enabled here and their private runtime config ends up with apiKey: undefined, so the handlers 500 at runtime. Read the key from config.registry.googleMaps first, or compute this after the merge.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/module.ts` around lines 357 - 371, The code reads mapsApiKey from
nuxt.options.runtimeConfig.public.scripts before merging registry values,
causing mapsApiKey to be undefined when the key is provided in
config.registry.googleMaps; update the logic so mapsApiKey is sourced from
config.registry.googleMaps?.apiKey (or compute mapsApiKey after merging
config.registry into nuxt.options.runtimeConfig.public.scripts) before
constructing nuxt.options.runtimeConfig['nuxt-scripts'] so that
googleStaticMapsProxy and googleGeocodeProxy receive the real API key when
staticMapsEnabled or geocodeEnabled are true; adjust the variables mapsApiKey,
staticMapsEnabled, geocodeEnabled, and the
nuxt.options.runtimeConfig['nuxt-scripts'] assignment accordingly.

} as any
nuxt.options.runtimeConfig.public['nuxt-scripts'] = {
Expand All @@ -350,6 +375,9 @@ export default defineNuxtModule<ModuleOptions>({
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
Expand Down Expand Up @@ -703,6 +731,14 @@ export default defineNuxtModule<ModuleOptions>({
})
}

// 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)
Expand Down
15 changes: 14 additions & 1 deletion src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
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'

Check failure on line 11 in src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue

View workflow job for this annotation

GitHub Actions / test

Expected "nuxt/app" to come before "ofetch"
import { hash } from 'ohash'
import { withQuery } from 'ufo'
import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, watch } from 'vue'
Expand Down Expand Up @@ -132,6 +133,7 @@
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()
Expand Down Expand Up @@ -249,7 +251,18 @@
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
}
}
Comment on lines +261 to +272
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle case where mapsApi is not yet loaded.

The proxy path assumes mapsApi.value is available, but this function is exposed via defineExpose (line 330) and could be called before the Maps API loads. The fallback path (lines 268-277) properly awaits API loading, but the proxy path doesn't.

Consider either awaiting mapsApi or returning a plain object that doesn't require the Maps API:

Option 1: Return plain coordinates (avoids Maps API dependency)
   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
+      // Return LatLngLiteral - compatible with most Maps API methods
+      const latLng = { lat: data.lat, lng: data.lng }
+      queryToLatLngCache.set(query, latLng as any)
+      return latLng as any
     }
   }
Option 2: Ensure Maps API is loaded first
   if (geocodeProxyConfig?.enabled) {
     const data = await $fetch<{ lat: number, lng: number }>('/_scripts/google-maps-geocode-proxy', {
       query: { input: query },
     }).catch(() => null)
     if (data) {
+      if (!mapsApi.value) {
+        await load()
+        await new Promise<void>((resolve) => {
+          const stop = watch(mapsApi, () => { stop(); resolve() })
+        })
+      }
       const latLng = new mapsApi.value!.LatLng(data.lat, data.lng)
       queryToLatLngCache.set(query, latLng)
       return latLng
     }
   }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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
}
}
// 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) {
// Return LatLngLiteral - compatible with most Maps API methods
const latLng = { lat: data.lat, lng: data.lng }
queryToLatLngCache.set(query, latLng as any)
return latLng as any
}
}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue` around lines 254 -
264, The proxy branch assumes mapsApi.value exists and will throw if called
before the Maps API loads; update the proxy branch that uses geocodeProxyConfig
to either await the Maps API (e.g., await mapsApi to be initialized before
creating new mapsApi.value.LatLng) or return/store a plain {lat, lng} fallback
so callers (and queryToLatLngCache) don't require the Maps API instance;
specifically, inside the geocodeProxyConfig?.enabled block check mapsApi.value
and if missing either await the loader that populates mapsApi or cache/return
the raw coordinates and convert to mapsApi.value.LatLng later (so defineExpose'd
methods can be called before the API is ready).

// Fallback to client-side Places API
// eslint-disable-next-line no-async-promise-executor
return new Promise<google.maps.LatLng>(async (resolve, reject) => {
if (!mapsApi.value) {
Expand Down
120 changes: 120 additions & 0 deletions src/runtime/server/google-maps-geocode-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useRuntimeConfig } from '#imports'
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<string, { count: number, resetAt: number }>()

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
}
Comment on lines +11 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Memory leak: rate limit entries are never evicted (same issue as static maps proxy).

This is the same unbounded memory growth issue present in google-static-maps-proxy.ts. Consider extracting a shared rate limiting utility with periodic cleanup for both proxies.

πŸ›‘οΈ Proposed fix with periodic cleanup
 const requestCounts = new Map<string, { count: number, resetAt: number }>()
+
+// Clean up stale entries periodically (every 5 minutes)
+setInterval(() => {
+  const now = Date.now()
+  for (const [ip, entry] of requestCounts) {
+    if (now > entry.resetAt) {
+      requestCounts.delete(ip)
+    }
+  }
+}, 5 * 60 * 1000).unref()
 
 function checkRateLimit(ip: string): boolean {

Consider extracting the rate limiting logic into a shared utility module (e.g., src/runtime/server/utils/rate-limit.ts) to eliminate duplication between both proxy handlers.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/google-maps-geocode-proxy.ts` around lines 10 - 21, The
in-memory Map requestCounts used by checkRateLimit (with RATE_LIMIT_WINDOW_MS
and RATE_LIMIT_MAX) will grow without bound; extract this logic into a shared
rate-limit utility (e.g., a new rate-limit module) and replace the local
requestCounts/checkRateLimit with calls to that utility which implements
eviction: store per-key {count, resetAt}, increment/reset as now does, and run a
periodic cleanup (setInterval) to remove entries whose resetAt < Date.now() to
prevent memory leaks; ensure both google-maps-geocode-proxy and
google-static-maps-proxy call the same exported functions (e.g.,
createRateLimiter or checkAndRecord) so logic is centralized and duplicated maps
are removed.


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',
})
}

// 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')
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',
})
}
}
Comment on lines +56 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Require origin metadata instead of skipping the check when it's absent.

This only rejects requests when both referer and host are present and differ. A scripted client can omit Referer, mint a valid CSRF cookie/header pair, and still call the proxy directly. If this endpoint is meant to stay same-origin only, reject when neither a same-origin Origin nor Referer is present.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/google-maps-geocode-proxy.ts` around lines 56 - 71, The
referer/host check should require origin metadata instead of silently skipping
when absent: fetch Origin via getHeader(event, 'origin') and use it as the
primary check (fall back to Referer if Origin is not present), parse the
origin/referer host in a try/catch (like refererHost) and compare that host to
the request host; if neither Origin nor Referer are present, or if the parsed
origin/referer host differs from host, throw createError({ statusCode: 403,
statusMessage: 'Invalid origin' }) to enforce same-origin-only access (update
variable names such as origin, referer, refererHost/originHost and reuse
createError).


const query = getQuery(event)
const input = query.input as string

if (!input) {
throw createError({
statusCode: 400,
statusMessage: 'Missing "input" query parameter',
})
}

if (input.length > MAX_INPUT_LENGTH) {
throw createError({
statusCode: 400,
statusMessage: `Input too long (max ${MAX_INPUT_LENGTH} characters)`,
})
Comment on lines +73 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Validate that input is a single string, not just truthy.

getQuery() can return string[], but query.input as string lets that through unchecked. That can produce malformed upstream requests instead of a clean 400.

Suggested fix
   const query = getQuery(event)
-  const input = query.input as string
+  const input = query.input
 
-  if (!input) {
+  if (typeof input !== 'string' || !input.trim()) {
     throw createError({
       statusCode: 400,
       statusMessage: 'Missing "input" query parameter',
     })
   }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const query = getQuery(event)
const input = query.input as string
if (!input) {
throw createError({
statusCode: 400,
statusMessage: 'Missing "input" query parameter',
})
}
if (input.length > MAX_INPUT_LENGTH) {
throw createError({
statusCode: 400,
statusMessage: `Input too long (max ${MAX_INPUT_LENGTH} characters)`,
})
const query = getQuery(event)
const input = query.input
if (typeof input !== 'string' || !input.trim()) {
throw createError({
statusCode: 400,
statusMessage: 'Missing "input" query parameter',
})
}
if (input.length > MAX_INPUT_LENGTH) {
throw createError({
statusCode: 400,
statusMessage: `Input too long (max ${MAX_INPUT_LENGTH} characters)`,
})
}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/google-maps-geocode-proxy.ts` around lines 73 - 87, The
code assumes query.input is a string but getQuery() may return string[]; update
the validation around query/input (referencing getQuery, query, input,
MAX_INPUT_LENGTH, createError) to explicitly reject non-string or array values:
if Array.isArray(input) or typeof input !== 'string' throw createError with a
400 and a clear message like 'Invalid "input" query parameter; must be a single
string'; keep the existing length check for valid strings afterwards.

}

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}"`,
})
}
Comment on lines +108 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

User input is reflected in error messageβ€”consider sanitizing.

The raw input query parameter is interpolated into the error message. While this aids debugging, it could leak user-provided data in logs or error responses. Consider truncating or omitting the input value.

πŸ›‘οΈ Proposed fix to avoid reflecting user input
   if (data.status !== 'OK' || !data.candidates?.[0]?.geometry?.location) {
     throw createError({
       statusCode: 404,
-      statusMessage: `No location found for "${input}"`,
+      statusMessage: 'No location found for the provided query',
     })
   }
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (data.status !== 'OK' || !data.candidates?.[0]?.geometry?.location) {
throw createError({
statusCode: 404,
statusMessage: `No location found for "${input}"`,
})
}
if (data.status !== 'OK' || !data.candidates?.[0]?.geometry?.location) {
throw createError({
statusCode: 404,
statusMessage: 'No location found for the provided query',
})
}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/google-maps-geocode-proxy.ts` around lines 104 - 109, The
error message currently interpolates the raw user-provided variable input into
the thrown createError call in the geocode check (the if block that tests
data.status and data.candidates), which can leak user data; replace the direct
interpolation with a sanitized or omitted value (e.g., use a truncated/validated
version of input or a generic placeholder like "<redacted>" or "unspecified
query") before passing it into createError so logs and responses no longer
reflect full user input while keeping the existing error condition and
createError usage intact.


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}`)
Comment on lines +117 to +120
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Honor cacheMaxAge: 0 instead of forcing the default.

Using || 86400 treats 0 as unset, so callers cannot disable caching even though the option is configurable.

Suggested fix
-  const cacheMaxAge = publicConfig.cacheMaxAge || 86400
+  const cacheMaxAge = publicConfig.cacheMaxAge ?? 86400
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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}`)
// 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}`)
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/google-maps-geocode-proxy.ts` around lines 117 - 120, The
code uses `publicConfig.cacheMaxAge || 86400` which treats 0 as falsy and
prevents callers from disabling caching; update the `cacheMaxAge` assignment in
`google-maps-geocode-proxy.ts` to honor 0 by using a nullish check (e.g., `??
86400`) or an explicit undefined check on `publicConfig.cacheMaxAge`, then
continue to call `setHeader(event, 'Cache-Control', \`public,
max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}\`)` so `setHeader` receives the
intended 0 when configured.

setHeader(event, 'Vary', 'Accept-Encoding')

return { lat: location.lat, lng: location.lng, name: data.candidates[0].name }
})
49 changes: 44 additions & 5 deletions src/runtime/server/google-static-maps-proxy.ts
Original file line number Diff line number Diff line change
@@ -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',

Check failure on line 10 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression

Check failure on line 10 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression

Check failure on line 10 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression

Check failure on line 10 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression

Check failure on line 10 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression
'language', 'region', 'markers', 'path', 'visible',

Check failure on line 11 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression

Check failure on line 11 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression

Check failure on line 11 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression

Check failure on line 11 in src/runtime/server/google-static-maps-proxy.ts

View workflow job for this annotation

GitHub Actions / test

Should have line breaks between items, in node ArrayExpression
'style', 'map_id', 'signature',
])

const requestCounts = new Map<string, { count: number, resetAt: number }>()

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
}
Comment on lines +26 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Memory leak: rate limit entries are never evicted.

The requestCounts map grows unboundedly as new IPs are tracked. Even though entries become stale after resetAt, they remain in the map indefinitely. In long-running server deployments, this will cause gradual memory growth.

πŸ›‘οΈ Proposed fix with periodic cleanup
 const requestCounts = new Map<string, { count: number, resetAt: number }>()
+
+// Clean up stale entries periodically (every 5 minutes)
+setInterval(() => {
+  const now = Date.now()
+  for (const [ip, entry] of requestCounts) {
+    if (now > entry.resetAt) {
+      requestCounts.delete(ip)
+    }
+  }
+}, 5 * 60 * 1000).unref()
 
 function checkRateLimit(ip: string): boolean {
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const requestCounts = new Map<string, { count: number, resetAt: number }>()
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
}
const requestCounts = new Map<string, { count: number, resetAt: number }>()
// Clean up stale entries periodically (every 5 minutes)
setInterval(() => {
const now = Date.now()
for (const [ip, entry] of requestCounts) {
if (now > entry.resetAt) {
requestCounts.delete(ip)
}
}
}, 5 * 60 * 1000).unref()
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
}
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/google-static-maps-proxy.ts` around lines 15 - 26, The
requestCounts map used by checkRateLimit grows without eviction; update
checkRateLimit (and the module) to remove stale entries so memory doesn't grow
unbounded: when fetching an entry in checkRateLimit, if entry.resetAt <=
Date.now() treat it as expired and replace it (or delete it) before creating a
fresh record; additionally add a periodic cleanup task (e.g., setInterval) that
scans requestCounts and deletes entries whose resetAt is in the past to reclaim
memory; reference requestCounts, checkRateLimit, RATE_LIMIT_WINDOW_MS and
RATE_LIMIT_MAX when making these changes.


export default defineEventHandler(async (event) => {
const runtimeConfig = useRuntimeConfig()
const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy
Expand All @@ -24,12 +46,25 @@
})
}

// Rate limit by IP
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
if (!checkRateLimit(ip)) {
throw createError({
statusCode: 429,
statusMessage: 'Too many static map requests',
})
}
Comment on lines +60 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

Rate limiting fallback to 'unknown' allows potential bypass.

When getRequestIP returns undefined, all such requests share the single 'unknown' key. In environments where client IPs aren't available (e.g., certain proxy configurations), legitimate users could be collectively rate-limited while attackers could also abuse this shared bucket.

Consider returning a 400 error when the IP cannot be determined, or use additional identifiers (e.g., session token) for rate limiting.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/server/google-static-maps-proxy.ts` around lines 60 - 67, The
current fallback to the literal 'unknown' when getRequestIP(event, {
xForwardedFor: true }) returns undefined creates a shared rate-limit bucket;
change the logic in google-static-maps-proxy.ts so you do NOT use a global
'unknown' key: if getRequestIP(...) returns undefined, return a 400 Bad Request
using createError (or alternatively derive a per-request identifier from a
session token/header and pass that to checkRateLimit), and ensure you call
checkRateLimit only with a valid, non-empty identifier. Update references around
getRequestIP, checkRateLimit, and createError accordingly so callers either
receive a 400 when IP is missing or rate-limiting is performed with a
per-request identifier.


// 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',
Expand All @@ -39,8 +74,12 @@

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<string, unknown> = {}
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,
Expand Down
Loading