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}
/>
-