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