Security hardening: input validation, bounded caches, and cookie secret enforcement

- Enforce OWNER_COOKIE_SECRET env var — throw on startup if unset rather than
  falling back to a weak derived secret
- Fix HTTPS detection to use exact string match instead of .includes()
- Cap and validate MQTT login credentials (32 char max, username format regex)
- Validate packetHash query param as hex before hitting the path resolver
- Validate node ID path params as 64-char hex in /nodes/:id/links and /nodes/:id/history
- Bound viableLinksCache to 50 entries with oldest-first eviction
- Bound countedAdvertHashes to 10,000 entries with oldest-first eviction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BenandClaude Sonnet 4.6 committed 2026-03-08 03:34:08 +00:00
1 parent fca3de4bc1
commit bbadebd761
3 files changed
+34 -11

No files matched your search

+23 -11
View File
@@ -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<string, string | string[] | undefined> }): 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' });
+5
View File
@@ -107,6 +107,7 @@ function isEmptyPacketEnvelope(json: Record<string, unknown>, rawHex: string, pa
* Entries expire after 60 seconds (well beyond any realistic relay window).
*/
const countedAdvertHashes = new Map<string, number>();
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;
}
+6
View File
@@ -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<string, { ts: number; data: Awaited<ReturnType<typeof getViableLinks>> }>();
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;
}