mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-23 14:22:47 +00:00
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:
1 parent
fca3de4bc1
commit
bbadebd761
3 files changed
+34
-11
No files matched your search
+23
-11
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user