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

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