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:
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user