-
Notifications
You must be signed in to change notification settings - Fork 84
feat: add geocode proxy with CSRF protection for Google Maps billing optimization #635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
b20ad54
7bfe9be
b9ef686
ce9fd98
b0201ca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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`. | ||||||
| - $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. | ||||||
|
||||||
| 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.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ModuleOptions>({ | |
| enabled: false, | ||
| cacheMaxAge: 3600, | ||
| }, | ||
| googleGeocodeProxy: { | ||
| enabled: false, | ||
| cacheMaxAge: 86400, | ||
| }, | ||
| enabled: true, | ||
| debug: false, | ||
| }, | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Populate proxy API keys from the registry config before enabling the handlers. Line 357 reads the key from π€ Prompt for AI Agents |
||
| } as any | ||
| nuxt.options.runtimeConfig.public['nuxt-scripts'] = { | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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' | ||||||||||||||||||||||||||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import { hash } from 'ohash' | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { withQuery } from 'ufo' | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, toRaw, watch } from 'vue' | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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() | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle case where The proxy path assumes Consider either awaiting 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
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| // 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) { | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Memory leak: rate limit entries are never evicted (same issue as static maps proxy). This is the same unbounded memory growth issue present in π‘οΈ 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., π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Require origin metadata instead of skipping the check when it's absent. This only rejects requests when both π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate that
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
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User input is reflected in error messageβconsider sanitizing. The raw π‘οΈ 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
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Honor Using Suggested fix- const cacheMaxAge = publicConfig.cacheMaxAge || 86400
+ const cacheMaxAge = publicConfig.cacheMaxAge ?? 86400π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setHeader(event, 'Vary', 'Accept-Encoding') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { lat: location.lat, lng: location.lng, name: data.candidates[0].name } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'language', 'region', 'markers', 'path', 'visible', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Check failure on line 11 in src/runtime/server/google-static-maps-proxy.ts
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'style', 'map_id', 'signature', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Memory leak: rate limit entries are never evicted. The π‘οΈ 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
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default defineEventHandler(async (event) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const runtimeConfig = useRuntimeConfig() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rate limiting fallback to 'unknown' allows potential bypass. When 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Name the billed API correctly.
googleGeocodeProxydoes not call the Geocoding API; it proxies Placesfindplacefromtext. Documenting this as βGeocoding APIβ here can send readers to enable the wrong Google product and misread the billing surface. Please align this bullet withsrc/runtime/server/google-maps-geocode-proxy.ts.Suggested wording
π€ Prompt for AI Agents