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;
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 {

View File

@@ -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,
};
}

View File

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

View File

@@ -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

View File

@@ -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
};
}

View File

@@ -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}"
}
}
```

View File

@@ -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
}
];

View File

@@ -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,

View File

@@ -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;
}

View File

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

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

View File

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

View File

@@ -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' }}
/>

View File

@@ -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 */

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