diff --git a/backend/__tests__/controllers/weatherProxyController.test.js b/backend/__tests__/controllers/weatherProxyController.test.js new file mode 100644 index 00000000..422fdf41 --- /dev/null +++ b/backend/__tests__/controllers/weatherProxyController.test.js @@ -0,0 +1,98 @@ +const { getGoogleMapsKey } = require('../../controllers/weatherProxyController'); + +describe('controllers/weatherProxyController', () => { + let req, res; + const originalEnv = { ...process.env }; + + beforeEach(() => { + req = { headers: {} }; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis() + }; + process.env = { ...originalEnv }; + delete process.env.BASE_URL; + delete process.env.GOOGLE_MAPS_API_KEY; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('getGoogleMapsKey', () => { + it('should return 503 when GOOGLE_MAPS_API_KEY is not configured', () => { + getGoogleMapsKey(req, res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ error: 'Google Maps API key not configured' }); + }); + + it('should return the key when BASE_URL is not set (dev mode, no origin check)', () => { + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + + getGoogleMapsKey(req, res); + + expect(res.json).toHaveBeenCalledWith({ key: 'test-key' }); + }); + + it('should allow the request when Origin matches BASE_URL exactly (with scheme)', () => { + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + process.env.BASE_URL = 'https://example.up.railway.app'; + req.headers.origin = 'https://example.up.railway.app'; + + getGoogleMapsKey(req, res); + + expect(res.json).toHaveBeenCalledWith({ key: 'test-key' }); + }); + + it('should allow the request when BASE_URL is missing its scheme (common misconfiguration)', () => { + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + process.env.BASE_URL = 'example.up.railway.app'; // no https:// prefix + req.headers.origin = 'https://example.up.railway.app'; + + getGoogleMapsKey(req, res); + + expect(res.json).toHaveBeenCalledWith({ key: 'test-key' }); + }); + + it('should allow the request via Referer when Origin header is absent', () => { + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + process.env.BASE_URL = 'https://example.up.railway.app'; + req.headers.referer = 'https://example.up.railway.app/map?lat=1.3&lng=103.8&zoom=10'; + + getGoogleMapsKey(req, res); + + expect(res.json).toHaveBeenCalledWith({ key: 'test-key' }); + }); + + it('should ignore a trailing slash on BASE_URL', () => { + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + process.env.BASE_URL = 'https://example.up.railway.app/'; + req.headers.origin = 'https://example.up.railway.app'; + + getGoogleMapsKey(req, res); + + expect(res.json).toHaveBeenCalledWith({ key: 'test-key' }); + }); + + it('should return 403 when Origin hostname does not match BASE_URL', () => { + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + process.env.BASE_URL = 'https://example.up.railway.app'; + req.headers.origin = 'https://evil.example.com'; + + getGoogleMapsKey(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }); + }); + + it('should return 403 when neither Origin nor Referer is present', () => { + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + process.env.BASE_URL = 'https://example.up.railway.app'; + + getGoogleMapsKey(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + }); +}); diff --git a/backend/app.js b/backend/app.js index e2676423..f7eef14f 100644 --- a/backend/app.js +++ b/backend/app.js @@ -71,6 +71,12 @@ app.use(helmet({ }, }, crossOriginEmbedderPolicy: false, // Google Maps requires this to be off + // Helmet defaults to 'no-referrer', which strips the Referer header from + // every request the page makes — including same-origin fetches. That + // broke the same-origin check on GET /api/gmaps/key (it always 403'd, + // even from the real page). 'strict-origin-when-cross-origin' is the + // browser default: full URL on same-origin requests, origin-only cross-origin. + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, })); // ── Middleware ──────────────────────────────────────────────────────────────── diff --git a/backend/controllers/weatherProxyController.js b/backend/controllers/weatherProxyController.js index 38f9d511..6bac5bd2 100644 --- a/backend/controllers/weatherProxyController.js +++ b/backend/controllers/weatherProxyController.js @@ -1,3 +1,15 @@ +// Parses a URL string into a lowercase hostname, tolerating a missing +// scheme (e.g. BASE_URL set to "example.com" instead of "https://example.com"). +function toHostname(value) { + if (!value) return null; + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`; + try { + return new URL(withScheme).hostname.toLowerCase(); + } catch { + return null; + } +} + // GET /api/gmaps/key — returns the Google Maps JS API key for dynamic script loading. // Restricted to same-origin requests: the Origin or Referer header must match BASE_URL. // Without BASE_URL configured the check is skipped (dev mode). @@ -7,12 +19,11 @@ function getGoogleMapsKey(req, res) { return res.status(503).json({ error: 'Google Maps API key not configured' }); } - const baseUrl = process.env.BASE_URL; - if (baseUrl) { - const origin = req.headers['origin'] || ''; - const referer = req.headers['referer'] || ''; - const allowed = baseUrl.replace(/\/$/, ''); - const fromSameOrigin = origin.startsWith(allowed) || referer.startsWith(allowed); + const allowedHost = toHostname(process.env.BASE_URL); + if (allowedHost) { + const originHost = toHostname(req.headers['origin']); + const refererHost = toHostname(req.headers['referer']); + const fromSameOrigin = originHost === allowedHost || refererHost === allowedHost; if (!fromSameOrigin) { return res.status(403).json({ error: 'Forbidden' }); } diff --git a/backend/public/app.js b/backend/public/app.js index c24bc837..696c80a1 100644 --- a/backend/public/app.js +++ b/backend/public/app.js @@ -397,9 +397,11 @@ document.addEventListener('DOMContentLoaded', function () { }); fetch('/api/gmaps/key') - .then(r => r.json()) - .then(data => { - if (!data.key) throw new Error('No Google Maps API key returned'); + .then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data }))) + .then(({ ok, status, data }) => { + if (!ok || !data.key) { + throw new Error(`${data.error || 'No Google Maps API key returned'} (HTTP ${status})`); + } const script = document.createElement('script'); script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(data.key)}&callback=initMap&loading=async`; script.async = true; @@ -409,6 +411,6 @@ document.addEventListener('DOMContentLoaded', function () { }) .catch(err => { console.error('Failed to load Google Maps:', err); - showMapLoadError('Map unavailable: GOOGLE_MAPS_API_KEY is not configured.'); + showMapLoadError(`Map unavailable: ${err.message}`); }); });