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

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