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 <noreply@anthropic.com>
This commit is contained in:
BenandClaude Sonnet 4.6 committed 2026-03-18 17:51:39 +00:00
1 parent b3562f774d
commit 5d0281b922
4 files changed
+57 -42

No files matched your search

+13 -9
View File
@@ -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(),
});
});
+2
View File
@@ -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();
});
+35 -33
View File
@@ -398,39 +398,41 @@ export const App: React.FC = () => {
onFiltersChange={setFilters}
/>
<MapView
nodes={nodes}
inferredNodes={inferredNodes}
inferredActiveNodeIds={inferredActiveNodeIds}
activeNodes={activeNodes}
coverage={coverage}
onDeckViewStateChange={setDeckViewState}
showCoverage={filters.coverage}
showClientNodes={filters.clientNodes}
showHexClashes={filters.hexClashes}
maxHexClashHops={filters.hexClashMaxHops}
viablePairsArr={viablePairsArr}
linkMetrics={linkMetrics}
hiddenCoordMask={hiddenCoordMask}
pathNodeIds={pathNodeIds}
onMapReady={setMap}
onPrefixFocusActiveChange={handlePrefixFocusActiveChange}
/>
<DeckGLOverlay
arcs={arcs}
showArcs={filters.livePackets}
packetHistorySegments={packetHistorySegments}
showPacketHistory={filters.packetHistory}
betaPaths={betaPacketPaths}
betaLowSegments={betaLowConfidenceSegments}
betaCompletionPaths={betaCompletionPaths}
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
pathFadingOut={pathFadingOut}
gpuNodes={gpuNodes}
showGpuNodes={!filters.hexClashes && !prefixFocusActive}
viewState={deckViewState}
hiddenCoordMask={hiddenCoordMask}
/>
<div className="map-layer">
<MapView
nodes={nodes}
inferredNodes={inferredNodes}
inferredActiveNodeIds={inferredActiveNodeIds}
activeNodes={activeNodes}
coverage={coverage}
onDeckViewStateChange={setDeckViewState}
showCoverage={filters.coverage}
showClientNodes={filters.clientNodes}
showHexClashes={filters.hexClashes}
maxHexClashHops={filters.hexClashMaxHops}
viablePairsArr={viablePairsArr}
linkMetrics={linkMetrics}
hiddenCoordMask={hiddenCoordMask}
pathNodeIds={pathNodeIds}
onMapReady={setMap}
onPrefixFocusActiveChange={handlePrefixFocusActiveChange}
/>
<DeckGLOverlay
arcs={arcs}
showArcs={filters.livePackets}
packetHistorySegments={packetHistorySegments}
showPacketHistory={filters.packetHistory}
betaPaths={betaPacketPaths}
betaLowSegments={betaLowConfidenceSegments}
betaCompletionPaths={betaCompletionPaths}
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
pathFadingOut={pathFadingOut}
gpuNodes={gpuNodes}
showGpuNodes={!filters.hexClashes && !prefixFocusActive}
viewState={deckViewState}
hiddenCoordMask={hiddenCoordMask}
/>
</div>
<FilterPanel
filters={filters}
+7
View File
@@ -150,9 +150,16 @@ html, body, #root {
}
/* ─── Map Container ─────────────────────────────────────────────────────── */
.map-layer {
position: relative;
overflow: hidden;
min-height: 0;
}
.map-area {
position: relative;
overflow: hidden;
height: 100%;
}
.leaflet-container {