From 5d0281b92287da03557d4b4f205baf2da02bab62 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 18 Mar 2026 17:51:39 +0000 Subject: [PATCH] Fix DeckGL/Leaflet 24px offset; add rate limits and security headers - Wrap MapView + DeckGLOverlay in .map-layer container so DeckGL canvas anchors to the map area (not app-shell), eliminating the 24px topbar offset that caused packet history lines to misalign with node dots - Add EXPENSIVE_LIMITER (12 req/min) to /observer-activity and /cross-network-connectivity endpoints - Tighten owner login cookie to sameSite: strict - Add Content-Security-Policy and Permissions-Policy response headers Co-Authored-By: Claude Sonnet 4.6 --- backend/src/api/routes.ts | 22 ++++++----- backend/src/index.ts | 2 + frontend/src/App.tsx | 68 +++++++++++++++++---------------- frontend/src/styles/globals.css | 7 ++++ 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 64b3e39..6c5efd5 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -66,6 +66,13 @@ const PATH_LEARNING_LIMITER = rateLimit({ legacyHeaders: false, message: { error: 'Too many path learning requests, slow down' }, }); +const EXPENSIVE_LIMITER = rateLimit({ + windowMs: 60_000, + max: 12, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, slow down' }, +}); const STATS_CHARTS_LIMITER = rateLimit({ windowMs: 60_000, max: 12, @@ -1644,7 +1651,7 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => { res.cookie(OWNER_COOKIE_NAME, token, { httpOnly: true, secure: isSecureRequest(req), - sameSite: 'lax', + sameSite: 'strict', path: '/', maxAge: OWNER_SESSION_TTL_MS, }); @@ -2654,7 +2661,7 @@ router.get('/stats/charts', STATS_CHARTS_LIMITER, async (req, res) => { }); // GET /api/observer-activity — MQTT observer nodes with 24h receive counts -router.get('/observer-activity', async (req, res) => { +router.get('/observer-activity', EXPENSIVE_LIMITER, async (req, res) => { try { const requestedNetwork = resolveRequestNetwork(req.query['network'], req.headers); const network = requestedNetwork === 'all' ? undefined : requestedNetwork; @@ -2691,10 +2698,7 @@ router.get('/observer-activity', async (req, res) => { // both sides. Hop count determines origin side: fewer hops = closer to source. // Outbound: MME observers see it with fewer hops, non-MME also received it within 120s // Inbound: non-MME observers see it with fewer hops, MME also received it within 120s -router.get('/cross-network-connectivity', async (_req, res) => { - const hours = 2; - const maxPropagationSecs = 120; - +router.get('/cross-network-connectivity', EXPENSIVE_LIMITER, async (_req, res) => { const result = await query<{ last_inbound: string | null; last_outbound: string | null }>(` WITH mme_observers AS ( SELECT DISTINCT rx_node_id AS node_id @@ -2711,7 +2715,7 @@ router.get('/cross-network-connectivity', async (_req, res) => { MIN(p.time) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) AS mme_first_seen, MIN(p.time) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) AS other_first_seen FROM packets p - WHERE p.time > NOW() - INTERVAL '${hours} hours' + WHERE p.time > NOW() - INTERVAL '2 hours' AND p.hop_count IS NOT NULL AND p.rx_node_id IS NOT NULL AND p.packet_hash IS NOT NULL @@ -2722,7 +2726,7 @@ router.get('/cross-network-connectivity', async (_req, res) => { AND ABS(EXTRACT(EPOCH FROM ( MIN(p.time) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) - MIN(p.time) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) - ))) <= ${maxPropagationSecs} + ))) <= 120 ) SELECT MAX(mme_first_seen) FILTER (WHERE other_min_hops < mme_min_hops) AS last_inbound, @@ -2739,7 +2743,7 @@ router.get('/cross-network-connectivity', async (_req, res) => { outbound: !!lastOutbound, lastInbound, lastOutbound, - windowHours: hours, + windowHours: 2, checkedAt: new Date().toISOString(), }); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 8f96501..8cff6ea 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -98,6 +98,8 @@ async function main() { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' wss: https:; font-src 'self' data:"); + res.setHeader('Permissions-Policy', 'geolocation=(), camera=(), microphone=()'); next(); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2046066..7ae9be1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -398,39 +398,41 @@ export const App: React.FC = () => { onFiltersChange={setFilters} /> - - +
+ + +