Skip to content
Merged

Main #33

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions backend/__tests__/controllers/weatherProxyController.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
6 changes: 6 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────
Expand Down
23 changes: 17 additions & 6 deletions backend/controllers/weatherProxyController.js
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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' });
}
Expand Down
10 changes: 6 additions & 4 deletions backend/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`);
});
});