Security fix: Implement Google Maps API photo proxy (Fix 3)
Completed HIGH severity security fix (CVSS 6.5) to prevent Google Maps API key exposure to frontend clients. Issue: API key was embedded in photo URLs sent to frontend, allowing potential abuse and quota exhaustion. Solution: Implemented backend proxy endpoint for photos. Backend Changes: - google-maps.client.ts: Changed photoUrl to photoReference, added fetchPhoto() - stations.types.ts: Updated type definition (photoUrl → photoReference) - stations.controller.ts: Added getStationPhoto() proxy method - stations.routes.ts: Added GET /api/stations/photo/:reference route - stations.service.ts: Updated to use photoReference - stations.repository.ts: Updated database queries and mappings - admin controllers/services: Updated for consistency - Created migration 003 to rename photo_url column Frontend Changes: - stations.types.ts: Updated type definition (photoUrl → photoReference) - photo-utils.ts: NEW - Helper to generate proxy URLs - StationCard.tsx: Use photoReference with helper function Tests & Docs: - Updated mock data to use photoReference - Updated test expectations for proxy URLs - Updated API.md and TESTING.md documentation Database Migration: - 003_rename_photo_url_to_photo_reference.sql: Renames column in station_cache Security Benefits: - API key never sent to frontend - All photo requests proxied through authenticated endpoint - Photos cached for 24 hours (Cache-Control header) - No client-side API key exposure Files modified: 16 files New files: 2 (photo-utils.ts, migration 003) Status: All 3 P0 security fixes now complete - Fix 1: crypto.randomBytes() ✓ - Fix 2: Magic byte validation ✓ - Fix 3: API key proxy ✓ Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ interface CreateStationBody {
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoUrl?: string;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface UpdateStationBody {
|
||||
@@ -35,7 +35,7 @@ interface UpdateStationBody {
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoUrl?: string;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface StationParams {
|
||||
|
||||
@@ -20,7 +20,7 @@ interface CreateStationData {
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoUrl?: string;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface UpdateStationData {
|
||||
@@ -32,7 +32,7 @@ interface UpdateStationData {
|
||||
pricePremium?: number;
|
||||
priceDiesel?: number;
|
||||
rating?: number;
|
||||
photoUrl?: string;
|
||||
photoReference?: string;
|
||||
}
|
||||
|
||||
interface StationListResult {
|
||||
@@ -63,7 +63,7 @@ export class StationOversightService {
|
||||
let dataQuery = `
|
||||
SELECT
|
||||
id, place_id, name, address, latitude, longitude,
|
||||
price_regular, price_premium, price_diesel, rating, photo_url, cached_at
|
||||
price_regular, price_premium, price_diesel, rating, photo_reference, cached_at
|
||||
FROM station_cache
|
||||
`;
|
||||
const params: any[] = [];
|
||||
@@ -114,7 +114,7 @@ export class StationOversightService {
|
||||
pricePremium: data.pricePremium,
|
||||
priceDiesel: data.priceDiesel,
|
||||
rating: data.rating,
|
||||
photoUrl: data.photoUrl,
|
||||
photoReference: data.photoReference,
|
||||
};
|
||||
|
||||
await this.stationsRepository.cacheStation(station);
|
||||
@@ -198,9 +198,9 @@ export class StationOversightService {
|
||||
updates.push(`rating = $${paramIndex++}`);
|
||||
values.push(data.rating);
|
||||
}
|
||||
if (data.photoUrl !== undefined) {
|
||||
updates.push(`photo_url = $${paramIndex++}`);
|
||||
values.push(data.photoUrl);
|
||||
if (data.photoReference !== undefined) {
|
||||
updates.push(`photo_reference = $${paramIndex++}`);
|
||||
values.push(data.photoReference);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
@@ -429,7 +429,7 @@ export class StationOversightService {
|
||||
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
||||
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
||||
rating: row.rating ? parseFloat(row.rating) : undefined,
|
||||
photoUrl: row.photo_url,
|
||||
photoReference: row.photo_reference,
|
||||
lastUpdated: row.cached_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
StationParams,
|
||||
UpdateSavedStationBody
|
||||
} from '../domain/stations.types';
|
||||
import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
||||
|
||||
export class StationsController {
|
||||
private stationsService: StationsService;
|
||||
@@ -148,24 +149,54 @@ export class StationsController {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const { placeId } = request.params;
|
||||
|
||||
|
||||
await this.stationsService.removeSavedStation(placeId, userId);
|
||||
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error: any) {
|
||||
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: (request as any).user?.sub });
|
||||
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to remove saved station'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getStationPhoto(request: FastifyRequest<{ Params: { reference: string } }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { reference } = request.params;
|
||||
|
||||
if (!reference) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Photo reference is required'
|
||||
});
|
||||
}
|
||||
|
||||
const photoBuffer = await googleMapsClient.fetchPhoto(reference);
|
||||
|
||||
return reply
|
||||
.code(200)
|
||||
.header('Content-Type', 'image/jpeg')
|
||||
.header('Cache-Control', 'public, max-age=86400')
|
||||
.send(photoBuffer);
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching station photo', {
|
||||
error,
|
||||
reference: request.params.reference
|
||||
});
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to fetch station photo'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: stationsController.removeSavedStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// GET /api/stations/photo/:reference - Proxy for Google Maps photos
|
||||
fastify.get<{ Params: { reference: string } }>('/stations/photo/:reference', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: stationsController.getStationPhoto.bind(stationsController)
|
||||
});
|
||||
};
|
||||
|
||||
// For backward compatibility during migration
|
||||
|
||||
@@ -12,14 +12,14 @@ export class StationsRepository {
|
||||
const query = `
|
||||
INSERT INTO station_cache (
|
||||
place_id, name, address, latitude, longitude,
|
||||
price_regular, price_premium, price_diesel, rating, photo_url
|
||||
price_regular, price_premium, price_diesel, rating, photo_reference
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (place_id) DO UPDATE
|
||||
SET name = $2, address = $3, latitude = $4, longitude = $5,
|
||||
rating = $9, photo_url = $10, cached_at = NOW()
|
||||
rating = $9, photo_reference = $10, cached_at = NOW()
|
||||
`;
|
||||
|
||||
|
||||
await this.pool.query(query, [
|
||||
station.placeId,
|
||||
station.name,
|
||||
@@ -30,7 +30,7 @@ export class StationsRepository {
|
||||
station.pricePremium,
|
||||
station.priceDiesel,
|
||||
station.rating,
|
||||
station.photoUrl
|
||||
station.photoReference
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export class StationsRepository {
|
||||
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
|
||||
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
|
||||
rating: row.rating ? parseFloat(row.rating) : undefined,
|
||||
photoUrl: row.photo_url,
|
||||
photoReference: row.photo_reference,
|
||||
lastUpdated: row.cached_at
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ Search for gas stations near a location using Google Maps Places API.
|
||||
"latitude": 37.7750,
|
||||
"longitude": -122.4195,
|
||||
"rating": 4.2,
|
||||
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?...",
|
||||
"photoReference": "/api/stations/photo/{reference}",
|
||||
"distance": 150
|
||||
}
|
||||
],
|
||||
@@ -85,7 +85,7 @@ Search for gas stations near a location using Google Maps Places API.
|
||||
| stations[].latitude | number | Station latitude |
|
||||
| stations[].longitude | number | Station longitude |
|
||||
| stations[].rating | number | Google rating (0-5) |
|
||||
| stations[].photoUrl | string | Photo URL (nullable) |
|
||||
| stations[].photoReference | string | Photo URL (nullable) |
|
||||
| stations[].distance | number | Distance from search location (meters) |
|
||||
| searchLocation | object | Original search coordinates |
|
||||
| searchRadius | number | Actual search radius used |
|
||||
@@ -187,7 +187,7 @@ Save a station to user's favorites with optional metadata.
|
||||
"latitude": 37.7750,
|
||||
"longitude": -122.4195,
|
||||
"rating": 4.2,
|
||||
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
|
||||
"photoReference": "/api/stations/photo/{reference}"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -286,7 +286,7 @@ Retrieve all stations saved by the authenticated user.
|
||||
"latitude": 37.7750,
|
||||
"longitude": -122.4195,
|
||||
"rating": 4.2,
|
||||
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
|
||||
"photoReference": "/api/stations/photo/{reference}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -355,7 +355,7 @@ Retrieve a specific saved station by Google Place ID.
|
||||
"latitude": 37.7750,
|
||||
"longitude": -122.4195,
|
||||
"rating": 4.2,
|
||||
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
|
||||
"photoReference": "/api/stations/photo/{reference}"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -437,7 +437,7 @@ Update metadata for a saved station.
|
||||
"latitude": 37.7750,
|
||||
"longitude": -122.4195,
|
||||
"rating": 4.2,
|
||||
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
|
||||
"photoReference": "/api/stations/photo/{reference}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -471,7 +471,7 @@ export const mockStations = [
|
||||
latitude: 37.7750,
|
||||
longitude: -122.4195,
|
||||
rating: 4.2,
|
||||
photoUrl: 'https://example.com/photo1.jpg',
|
||||
photoReference: 'mock-photo-reference-1',
|
||||
distance: 150
|
||||
},
|
||||
{
|
||||
@@ -481,7 +481,7 @@ export const mockStations = [
|
||||
latitude: 37.7755,
|
||||
longitude: -122.4190,
|
||||
rating: 4.0,
|
||||
photoUrl: null,
|
||||
photoReference: null,
|
||||
distance: 300
|
||||
}
|
||||
];
|
||||
|
||||
@@ -141,7 +141,7 @@ export class StationsService {
|
||||
latitude: station?.latitude || 0,
|
||||
longitude: station?.longitude || 0,
|
||||
rating: station?.rating,
|
||||
photoUrl: station?.photoUrl,
|
||||
photoReference: station?.photoReference,
|
||||
priceRegular: station?.priceRegular,
|
||||
pricePremium: station?.pricePremium,
|
||||
priceDiesel: station?.priceDiesel,
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface Station {
|
||||
distance?: number; // Distance from search point in meters
|
||||
isOpen?: boolean;
|
||||
rating?: number;
|
||||
photoUrl?: string;
|
||||
photoReference?: string;
|
||||
isSaved?: boolean;
|
||||
savedMetadata?: StationSavedMetadata;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export class GoogleMapsClient {
|
||||
}
|
||||
|
||||
private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station {
|
||||
// Calculate distance from search point
|
||||
const distance = this.calculateDistance(
|
||||
searchLat,
|
||||
searchLng,
|
||||
@@ -73,10 +72,10 @@ export class GoogleMapsClient {
|
||||
place.geometry.location.lng
|
||||
);
|
||||
|
||||
// Generate photo URL if available
|
||||
let photoUrl: string | undefined;
|
||||
// Store photo reference instead of full URL to avoid exposing API key
|
||||
let photoReference: string | undefined;
|
||||
if (place.photos && place.photos.length > 0 && place.photos[0]) {
|
||||
photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photo_reference=${place.photos[0].photo_reference}&key=${this.apiKey}`;
|
||||
photoReference = place.photos[0].photo_reference;
|
||||
}
|
||||
|
||||
const station: Station = {
|
||||
@@ -89,9 +88,8 @@ export class GoogleMapsClient {
|
||||
distance
|
||||
};
|
||||
|
||||
// Only set optional properties if defined
|
||||
if (photoUrl !== undefined) {
|
||||
station.photoUrl = photoUrl;
|
||||
if (photoReference !== undefined) {
|
||||
station.photoReference = photoReference;
|
||||
}
|
||||
|
||||
if (place.opening_hours?.open_now !== undefined) {
|
||||
@@ -105,6 +103,31 @@ export class GoogleMapsClient {
|
||||
return station;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch photo from Google Maps API using photo reference
|
||||
* Used by photo proxy endpoint to serve photos without exposing API key
|
||||
*/
|
||||
async fetchPhoto(photoReference: string, maxWidth: number = 400): Promise<Buffer> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${this.baseURL}/photo`,
|
||||
{
|
||||
params: {
|
||||
photo_reference: photoReference,
|
||||
maxwidth: maxWidth,
|
||||
key: this.apiKey
|
||||
},
|
||||
responseType: 'arraybuffer'
|
||||
}
|
||||
);
|
||||
|
||||
return Buffer.from(response.data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch photo from Google Maps', { error, photoReference });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371e3; // Earth's radius in meters
|
||||
const φ1 = lat1 * Math.PI / 180;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Rename photo_url column to photo_reference in station_cache table
|
||||
-- This is part of the security fix to prevent API key exposure
|
||||
|
||||
ALTER TABLE station_cache
|
||||
RENAME COLUMN photo_url TO photo_reference;
|
||||
|
||||
-- Add comment explaining the column stores references, not full URLs
|
||||
COMMENT ON COLUMN station_cache.photo_reference IS 'Google Maps photo reference token (not full URL)';
|
||||
@@ -14,7 +14,7 @@ export const mockStations: Station[] = [
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250,
|
||||
photoUrl: 'https://example.com/shell-downtown.jpg',
|
||||
photoReference: 'mock-photo-reference-shell',
|
||||
priceRegular: 4.29,
|
||||
pricePremium: 4.79,
|
||||
priceDiesel: 4.49
|
||||
@@ -28,7 +28,7 @@ export const mockStations: Station[] = [
|
||||
longitude: -122.3989,
|
||||
rating: 4.5,
|
||||
distance: 1200,
|
||||
photoUrl: 'https://example.com/chevron-fd.jpg',
|
||||
photoReference: 'mock-photo-reference-chevron',
|
||||
priceRegular: 4.39,
|
||||
pricePremium: 4.89
|
||||
},
|
||||
@@ -41,7 +41,7 @@ export const mockStations: Station[] = [
|
||||
longitude: -122.4148,
|
||||
rating: 3.8,
|
||||
distance: 1850,
|
||||
photoUrl: 'https://example.com/exxon-mission.jpg',
|
||||
photoReference: 'mock-photo-reference-exxon',
|
||||
priceRegular: 4.19,
|
||||
priceDiesel: 4.39
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const mockStation: Station = {
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250,
|
||||
photoUrl: 'https://example.com/photo.jpg'
|
||||
photoReference: 'mock-photo-reference'
|
||||
};
|
||||
|
||||
describe('StationCard', () => {
|
||||
@@ -38,7 +38,7 @@ describe('StationCard', () => {
|
||||
|
||||
const photo = screen.getByAltText('Shell Gas Station');
|
||||
expect(photo).toBeInTheDocument();
|
||||
expect(photo).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||
expect(photo).toHaveAttribute('src', '/api/stations/photo/mock-photo-reference');
|
||||
});
|
||||
|
||||
it('should render rating when available', () => {
|
||||
@@ -54,7 +54,7 @@ describe('StationCard', () => {
|
||||
});
|
||||
|
||||
it('should not crash when photo is missing', () => {
|
||||
const stationWithoutPhoto = { ...mockStation, photoUrl: undefined };
|
||||
const stationWithoutPhoto = { ...mockStation, photoReference: undefined };
|
||||
render(<StationCard station={stationWithoutPhoto} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
||||
|
||||
@@ -18,6 +18,7 @@ import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
|
||||
import DirectionsIcon from '@mui/icons-material/Directions';
|
||||
import { Station, SavedStation } from '../types/stations.types';
|
||||
import { formatDistance } from '../utils/distance';
|
||||
import { getStationPhotoUrl } from '../utils/photo-utils';
|
||||
|
||||
interface StationCardProps {
|
||||
station: Station;
|
||||
@@ -83,11 +84,11 @@ export const StationCard: React.FC<StationCardProps> = ({
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{station.photoUrl && (
|
||||
{station.photoReference && (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={station.photoUrl}
|
||||
image={getStationPhotoUrl(station.photoReference)}
|
||||
alt={station.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
|
||||
@@ -56,8 +56,8 @@ export interface Station {
|
||||
rating: number;
|
||||
/** Distance from search location in meters */
|
||||
distance?: number;
|
||||
/** URL to station photo if available */
|
||||
photoUrl?: string;
|
||||
/** Photo reference for station photo if available */
|
||||
photoReference?: string;
|
||||
/** Whether the station is saved for the user */
|
||||
isSaved?: boolean;
|
||||
/** Saved-station metadata if applicable */
|
||||
|
||||
15
frontend/src/features/stations/utils/photo-utils.ts
Normal file
15
frontend/src/features/stations/utils/photo-utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @ai-summary Helper utilities for station photos
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate secure photo URL using backend proxy endpoint
|
||||
* This prevents exposing the Google Maps API key to the frontend
|
||||
*/
|
||||
export function getStationPhotoUrl(photoReference: string | undefined): string | undefined {
|
||||
if (!photoReference) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `/api/stations/photo/${encodeURIComponent(photoReference)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user