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:
Eric Gullickson
2025-12-14 09:56:33 -06:00
parent a35e1a3aea
commit bcb1cea311
16 changed files with 130 additions and 46 deletions

View File

@@ -23,7 +23,7 @@ interface CreateStationBody {
pricePremium?: number; pricePremium?: number;
priceDiesel?: number; priceDiesel?: number;
rating?: number; rating?: number;
photoUrl?: string; photoReference?: string;
} }
interface UpdateStationBody { interface UpdateStationBody {
@@ -35,7 +35,7 @@ interface UpdateStationBody {
pricePremium?: number; pricePremium?: number;
priceDiesel?: number; priceDiesel?: number;
rating?: number; rating?: number;
photoUrl?: string; photoReference?: string;
} }
interface StationParams { interface StationParams {

View File

@@ -20,7 +20,7 @@ interface CreateStationData {
pricePremium?: number; pricePremium?: number;
priceDiesel?: number; priceDiesel?: number;
rating?: number; rating?: number;
photoUrl?: string; photoReference?: string;
} }
interface UpdateStationData { interface UpdateStationData {
@@ -32,7 +32,7 @@ interface UpdateStationData {
pricePremium?: number; pricePremium?: number;
priceDiesel?: number; priceDiesel?: number;
rating?: number; rating?: number;
photoUrl?: string; photoReference?: string;
} }
interface StationListResult { interface StationListResult {
@@ -63,7 +63,7 @@ export class StationOversightService {
let dataQuery = ` let dataQuery = `
SELECT SELECT
id, place_id, name, address, latitude, longitude, 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 FROM station_cache
`; `;
const params: any[] = []; const params: any[] = [];
@@ -114,7 +114,7 @@ export class StationOversightService {
pricePremium: data.pricePremium, pricePremium: data.pricePremium,
priceDiesel: data.priceDiesel, priceDiesel: data.priceDiesel,
rating: data.rating, rating: data.rating,
photoUrl: data.photoUrl, photoReference: data.photoReference,
}; };
await this.stationsRepository.cacheStation(station); await this.stationsRepository.cacheStation(station);
@@ -198,9 +198,9 @@ export class StationOversightService {
updates.push(`rating = $${paramIndex++}`); updates.push(`rating = $${paramIndex++}`);
values.push(data.rating); values.push(data.rating);
} }
if (data.photoUrl !== undefined) { if (data.photoReference !== undefined) {
updates.push(`photo_url = $${paramIndex++}`); updates.push(`photo_reference = $${paramIndex++}`);
values.push(data.photoUrl); values.push(data.photoReference);
} }
if (updates.length === 0) { if (updates.length === 0) {
@@ -429,7 +429,7 @@ export class StationOversightService {
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined, pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined, priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
rating: row.rating ? parseFloat(row.rating) : undefined, rating: row.rating ? parseFloat(row.rating) : undefined,
photoUrl: row.photo_url, photoReference: row.photo_reference,
lastUpdated: row.cached_at, lastUpdated: row.cached_at,
}; };
} }

View File

@@ -14,6 +14,7 @@ import {
StationParams, StationParams,
UpdateSavedStationBody UpdateSavedStationBody
} from '../domain/stations.types'; } from '../domain/stations.types';
import { googleMapsClient } from '../external/google-maps/google-maps.client';
export class StationsController { export class StationsController {
private stationsService: StationsService; private stationsService: StationsService;
@@ -168,4 +169,34 @@ export class StationsController {
}); });
} }
} }
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'
});
}
}
} }

View File

@@ -51,6 +51,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],
handler: stationsController.removeSavedStation.bind(stationsController) 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 // For backward compatibility during migration

View File

@@ -12,12 +12,12 @@ export class StationsRepository {
const query = ` const query = `
INSERT INTO station_cache ( INSERT INTO station_cache (
place_id, name, address, latitude, longitude, 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (place_id) DO UPDATE ON CONFLICT (place_id) DO UPDATE
SET name = $2, address = $3, latitude = $4, longitude = $5, 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, [ await this.pool.query(query, [
@@ -30,7 +30,7 @@ export class StationsRepository {
station.pricePremium, station.pricePremium,
station.priceDiesel, station.priceDiesel,
station.rating, station.rating,
station.photoUrl station.photoReference
]); ]);
} }
@@ -166,7 +166,7 @@ export class StationsRepository {
pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined, pricePremium: row.price_premium ? parseFloat(row.price_premium) : undefined,
priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined, priceDiesel: row.price_diesel ? parseFloat(row.price_diesel) : undefined,
rating: row.rating ? parseFloat(row.rating) : undefined, rating: row.rating ? parseFloat(row.rating) : undefined,
photoUrl: row.photo_url, photoReference: row.photo_reference,
lastUpdated: row.cached_at lastUpdated: row.cached_at
}; };
} }

View File

@@ -62,7 +62,7 @@ Search for gas stations near a location using Google Maps Places API.
"latitude": 37.7750, "latitude": 37.7750,
"longitude": -122.4195, "longitude": -122.4195,
"rating": 4.2, "rating": 4.2,
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?...", "photoReference": "/api/stations/photo/{reference}",
"distance": 150 "distance": 150
} }
], ],
@@ -85,7 +85,7 @@ Search for gas stations near a location using Google Maps Places API.
| stations[].latitude | number | Station latitude | | stations[].latitude | number | Station latitude |
| stations[].longitude | number | Station longitude | | stations[].longitude | number | Station longitude |
| stations[].rating | number | Google rating (0-5) | | 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) | | stations[].distance | number | Distance from search location (meters) |
| searchLocation | object | Original search coordinates | | searchLocation | object | Original search coordinates |
| searchRadius | number | Actual search radius used | | searchRadius | number | Actual search radius used |
@@ -187,7 +187,7 @@ Save a station to user's favorites with optional metadata.
"latitude": 37.7750, "latitude": 37.7750,
"longitude": -122.4195, "longitude": -122.4195,
"rating": 4.2, "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, "latitude": 37.7750,
"longitude": -122.4195, "longitude": -122.4195,
"rating": 4.2, "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, "latitude": 37.7750,
"longitude": -122.4195, "longitude": -122.4195,
"rating": 4.2, "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, "latitude": 37.7750,
"longitude": -122.4195, "longitude": -122.4195,
"rating": 4.2, "rating": 4.2,
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..." "photoReference": "/api/stations/photo/{reference}"
} }
} }
``` ```

View File

@@ -471,7 +471,7 @@ export const mockStations = [
latitude: 37.7750, latitude: 37.7750,
longitude: -122.4195, longitude: -122.4195,
rating: 4.2, rating: 4.2,
photoUrl: 'https://example.com/photo1.jpg', photoReference: 'mock-photo-reference-1',
distance: 150 distance: 150
}, },
{ {
@@ -481,7 +481,7 @@ export const mockStations = [
latitude: 37.7755, latitude: 37.7755,
longitude: -122.4190, longitude: -122.4190,
rating: 4.0, rating: 4.0,
photoUrl: null, photoReference: null,
distance: 300 distance: 300
} }
]; ];

View File

@@ -141,7 +141,7 @@ export class StationsService {
latitude: station?.latitude || 0, latitude: station?.latitude || 0,
longitude: station?.longitude || 0, longitude: station?.longitude || 0,
rating: station?.rating, rating: station?.rating,
photoUrl: station?.photoUrl, photoReference: station?.photoReference,
priceRegular: station?.priceRegular, priceRegular: station?.priceRegular,
pricePremium: station?.pricePremium, pricePremium: station?.pricePremium,
priceDiesel: station?.priceDiesel, priceDiesel: station?.priceDiesel,

View File

@@ -17,7 +17,7 @@ export interface Station {
distance?: number; // Distance from search point in meters distance?: number; // Distance from search point in meters
isOpen?: boolean; isOpen?: boolean;
rating?: number; rating?: number;
photoUrl?: string; photoReference?: string;
isSaved?: boolean; isSaved?: boolean;
savedMetadata?: StationSavedMetadata; savedMetadata?: StationSavedMetadata;
} }

View File

@@ -65,7 +65,6 @@ export class GoogleMapsClient {
} }
private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station { private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station {
// Calculate distance from search point
const distance = this.calculateDistance( const distance = this.calculateDistance(
searchLat, searchLat,
searchLng, searchLng,
@@ -73,10 +72,10 @@ export class GoogleMapsClient {
place.geometry.location.lng place.geometry.location.lng
); );
// Generate photo URL if available // Store photo reference instead of full URL to avoid exposing API key
let photoUrl: string | undefined; let photoReference: string | undefined;
if (place.photos && place.photos.length > 0 && place.photos[0]) { 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 = { const station: Station = {
@@ -89,9 +88,8 @@ export class GoogleMapsClient {
distance distance
}; };
// Only set optional properties if defined if (photoReference !== undefined) {
if (photoUrl !== undefined) { station.photoReference = photoReference;
station.photoUrl = photoUrl;
} }
if (place.opening_hours?.open_now !== undefined) { if (place.opening_hours?.open_now !== undefined) {
@@ -105,6 +103,31 @@ export class GoogleMapsClient {
return station; 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 { private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371e3; // Earth's radius in meters const R = 6371e3; // Earth's radius in meters
const φ1 = lat1 * Math.PI / 180; const φ1 = lat1 * Math.PI / 180;

View File

@@ -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)';

View File

@@ -14,7 +14,7 @@ export const mockStations: Station[] = [
longitude: -122.4194, longitude: -122.4194,
rating: 4.2, rating: 4.2,
distance: 250, distance: 250,
photoUrl: 'https://example.com/shell-downtown.jpg', photoReference: 'mock-photo-reference-shell',
priceRegular: 4.29, priceRegular: 4.29,
pricePremium: 4.79, pricePremium: 4.79,
priceDiesel: 4.49 priceDiesel: 4.49
@@ -28,7 +28,7 @@ export const mockStations: Station[] = [
longitude: -122.3989, longitude: -122.3989,
rating: 4.5, rating: 4.5,
distance: 1200, distance: 1200,
photoUrl: 'https://example.com/chevron-fd.jpg', photoReference: 'mock-photo-reference-chevron',
priceRegular: 4.39, priceRegular: 4.39,
pricePremium: 4.89 pricePremium: 4.89
}, },
@@ -41,7 +41,7 @@ export const mockStations: Station[] = [
longitude: -122.4148, longitude: -122.4148,
rating: 3.8, rating: 3.8,
distance: 1850, distance: 1850,
photoUrl: 'https://example.com/exxon-mission.jpg', photoReference: 'mock-photo-reference-exxon',
priceRegular: 4.19, priceRegular: 4.19,
priceDiesel: 4.39 priceDiesel: 4.39
} }

View File

@@ -16,7 +16,7 @@ const mockStation: Station = {
longitude: -122.4194, longitude: -122.4194,
rating: 4.2, rating: 4.2,
distance: 250, distance: 250,
photoUrl: 'https://example.com/photo.jpg' photoReference: 'mock-photo-reference'
}; };
describe('StationCard', () => { describe('StationCard', () => {
@@ -38,7 +38,7 @@ describe('StationCard', () => {
const photo = screen.getByAltText('Shell Gas Station'); const photo = screen.getByAltText('Shell Gas Station');
expect(photo).toBeInTheDocument(); 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', () => { it('should render rating when available', () => {
@@ -54,7 +54,7 @@ describe('StationCard', () => {
}); });
it('should not crash when photo is missing', () => { it('should not crash when photo is missing', () => {
const stationWithoutPhoto = { ...mockStation, photoUrl: undefined }; const stationWithoutPhoto = { ...mockStation, photoReference: undefined };
render(<StationCard station={stationWithoutPhoto} isSaved={false} />); render(<StationCard station={stationWithoutPhoto} isSaved={false} />);
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument(); expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();

View File

@@ -18,6 +18,7 @@ import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import DirectionsIcon from '@mui/icons-material/Directions'; import DirectionsIcon from '@mui/icons-material/Directions';
import { Station, SavedStation } from '../types/stations.types'; import { Station, SavedStation } from '../types/stations.types';
import { formatDistance } from '../utils/distance'; import { formatDistance } from '../utils/distance';
import { getStationPhotoUrl } from '../utils/photo-utils';
interface StationCardProps { interface StationCardProps {
station: Station; station: Station;
@@ -83,11 +84,11 @@ export const StationCard: React.FC<StationCardProps> = ({
flexDirection: 'column' flexDirection: 'column'
}} }}
> >
{station.photoUrl && ( {station.photoReference && (
<CardMedia <CardMedia
component="img" component="img"
height="200" height="200"
image={station.photoUrl} image={getStationPhotoUrl(station.photoReference)}
alt={station.name} alt={station.name}
sx={{ objectFit: 'cover' }} sx={{ objectFit: 'cover' }}
/> />

View File

@@ -56,8 +56,8 @@ export interface Station {
rating: number; rating: number;
/** Distance from search location in meters */ /** Distance from search location in meters */
distance?: number; distance?: number;
/** URL to station photo if available */ /** Photo reference for station photo if available */
photoUrl?: string; photoReference?: string;
/** Whether the station is saved for the user */ /** Whether the station is saved for the user */
isSaved?: boolean; isSaved?: boolean;
/** Saved-station metadata if applicable */ /** Saved-station metadata if applicable */

View 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)}`;
}