diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index d758bfe..86530ad 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -26,13 +26,8 @@ type OwnerSession = { }; function getOwnerCookieKey(): Buffer { - const secret = process.env['OWNER_COOKIE_SECRET'] - ?? [ - process.env['DATABASE_URL'], - process.env['MQTT_PASSWORD'], - process.env['CLOUDFLARE_TUNNEL_TOKEN'], - 'meshcore-owner-cookie-fallback', - ].filter(Boolean).join('|'); + const secret = process.env['OWNER_COOKIE_SECRET']; + if (!secret) throw new Error('OWNER_COOKIE_SECRET environment variable is not set'); return createHash('sha256').update(secret).digest(); } @@ -176,7 +171,7 @@ function verifyMqttCredentials(mqttUsername: string, mqttPassword: string): Prom function isSecureRequest(req: { secure: boolean; headers: Record }): boolean { if (req.secure) return true; const proto = String(req.headers['x-forwarded-proto'] ?? '').toLowerCase(); - return proto.includes('https'); + return proto === 'https'; } async function buildOwnerDashboard(nodeIds: string[]) { @@ -345,6 +340,10 @@ router.get('/nodes', async (req, res) => { router.get('/nodes/:id/links', async (req, res) => { try { const id = req.params['id']!; + if (!/^[0-9a-fA-F]{64}$/.test(id)) { + res.status(400).json({ error: 'Invalid node ID format' }); + return; + } const result = await query<{ peer_id: string; peer_name: string | null; observed_count: number; itm_path_loss_db: number | null; @@ -373,8 +372,13 @@ router.get('/nodes/:id/links', async (req, res) => { // GET /api/nodes/:id/history?hours=24 router.get('/nodes/:id/history', async (req, res) => { try { + const id = req.params['id']!; + if (!/^[0-9a-fA-F]{64}$/.test(id)) { + res.status(400).json({ error: 'Invalid node ID format' }); + return; + } const hours = Math.min(Number(req.query['hours'] ?? 24), 672); // max 28 days - const history = await getNodeHistory(req.params['id']!, hours); + const history = await getNodeHistory(id, hours); res.json(history); } catch (err) { console.error('[api] GET /nodes/:id/history', (err as Error).message); @@ -405,6 +409,10 @@ router.get('/path-beta/resolve', async (req, res) => { res.status(400).json({ error: 'Missing hash query parameter' }); return; } + if (!/^[0-9a-fA-F]{1,128}$/.test(packetHash)) { + res.status(400).json({ error: 'Invalid hash format' }); + return; + } const network = resolveRequestNetwork(req.query['network'], req.headers, 'teesside') ?? 'teesside'; const observer = normalizeObserverQuery(req.query['observer']); const resolved = await resolveBetaPathForPacketHash(packetHash, network, observer); @@ -726,12 +734,16 @@ router.get('/health', async (_req, res) => { router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => { try { const body = req.body as { mqttUsername?: string; mqttPassword?: string } | undefined; - const mqttUsername = String(body?.mqttUsername ?? '').trim(); - const mqttPassword = String(body?.mqttPassword ?? '').trim(); + const mqttUsername = String(body?.mqttUsername ?? '').trim().slice(0, 32); + const mqttPassword = String(body?.mqttPassword ?? '').trim().slice(0, 32); if (!mqttUsername || !mqttPassword) { res.status(400).json({ error: 'Missing MQTT username or password' }); return; } + if (!/^[a-zA-Z0-9_\-.@]+$/.test(mqttUsername)) { + res.status(400).json({ error: 'Invalid MQTT username format' }); + return; + } const authOk = await verifyMqttCredentials(mqttUsername, mqttPassword); if (!authOk) { res.status(403).json({ error: 'Invalid MQTT credentials' }); diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index 0045297..124d7d0 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -107,6 +107,7 @@ function isEmptyPacketEnvelope(json: Record, rawHex: string, pa * Entries expire after 60 seconds (well beyond any realistic relay window). */ const countedAdvertHashes = new Map(); +const COUNTED_ADVERT_HASHES_MAX = 10_000; function tryCountAdvert(hash: string): boolean { const now = Date.now(); @@ -114,6 +115,10 @@ function tryCountAdvert(hash: string): boolean { if (now - ts > 60_000) countedAdvertHashes.delete(h); } if (countedAdvertHashes.has(hash)) return false; + if (countedAdvertHashes.size >= COUNTED_ADVERT_HASHES_MAX) { + const oldest = countedAdvertHashes.keys().next().value; + if (oldest !== undefined) countedAdvertHashes.delete(oldest); + } countedAdvertHashes.set(hash, now); return true; } diff --git a/backend/src/ws/server.ts b/backend/src/ws/server.ts index 5302a6e..e6ecd32 100644 --- a/backend/src/ws/server.ts +++ b/backend/src/ws/server.ts @@ -11,6 +11,7 @@ const REDIS_CHANNEL = 'meshcore:live'; let pub: Redis; let sub: Redis; const VIABLE_LINK_CACHE_TTL_MS = 30_000; +const VIABLE_LINK_CACHE_MAX = 50; const viableLinksCache = new Map> }>(); type ClientScope = { @@ -33,6 +34,11 @@ async function getCachedViableLinks(network?: string, observer?: string) { const cached = viableLinksCache.get(key); if (cached && (Date.now() - cached.ts) < VIABLE_LINK_CACHE_TTL_MS) return cached.data; const data = await getViableLinks(network, observer); + if (viableLinksCache.size >= VIABLE_LINK_CACHE_MAX) { + // Evict the oldest entry + const oldest = Array.from(viableLinksCache.entries()).sort((a, b) => a[1].ts - b[1].ts)[0]; + if (oldest) viableLinksCache.delete(oldest[0]); + } viableLinksCache.set(key, { ts: Date.now(), data }); return data; }