diff --git a/db.js b/db.js index f5c0d3e1..dadcb4e8 100644 --- a/db.js +++ b/db.js @@ -119,6 +119,19 @@ for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv', } } +// --- Cleanup corrupted nodes on startup --- +// Remove nodes with obviously invalid data (short pubkeys, control chars in names, etc.) +{ + const cleaned = db.prepare(` + DELETE FROM nodes WHERE + length(public_key) < 16 + OR public_key GLOB '*[^0-9a-fA-F]*' + OR (lat IS NOT NULL AND (lat < -90 OR lat > 90)) + OR (lon IS NOT NULL AND (lon < -180 OR lon > 180)) + `).run(); + if (cleaned.changes > 0) console.log(`[cleanup] Removed ${cleaned.changes} corrupted node(s) from DB`); +} + // --- Prepared statements --- const stmts = { insertPacket: db.prepare(` diff --git a/decoder.js b/decoder.js index 4e4f1dbb..fa480594 100644 --- a/decoder.js +++ b/decoder.js @@ -265,7 +265,65 @@ function decodePacket(hexString, channelKeys) { }; } -module.exports = { decodePacket, ROUTE_TYPES, PAYLOAD_TYPES }; +// --- ADVERT validation --- + +const VALID_ROLES = new Set(['repeater', 'companion', 'room', 'sensor']); + +/** + * Validate decoded ADVERT data before upserting into the DB. + * Returns { valid: true } or { valid: false, reason: string }. + */ +function validateAdvert(advert) { + if (!advert || advert.error) return { valid: false, reason: advert?.error || 'null advert' }; + + // pubkey must be at least 16 hex chars (8 bytes) and not all zeros + const pk = advert.pubKey || ''; + if (pk.length < 16) return { valid: false, reason: `pubkey too short (${pk.length} hex chars)` }; + if (/^0+$/.test(pk)) return { valid: false, reason: 'pubkey is all zeros' }; + + // lat/lon must be in valid ranges if present + if (advert.lat != null) { + if (!Number.isFinite(advert.lat) || advert.lat < -90 || advert.lat > 90) { + return { valid: false, reason: `invalid lat: ${advert.lat}` }; + } + } + if (advert.lon != null) { + if (!Number.isFinite(advert.lon) || advert.lon < -180 || advert.lon > 180) { + return { valid: false, reason: `invalid lon: ${advert.lon}` }; + } + } + + // name must not contain control chars (except space) or be garbage + if (advert.name != null) { + // eslint-disable-next-line no-control-regex + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(advert.name)) { + return { valid: false, reason: 'name contains control characters' }; + } + // Reject names that are mostly non-printable or suspiciously long + if (advert.name.length > 64) { + return { valid: false, reason: `name too long (${advert.name.length} chars)` }; + } + } + + // role derivation check — flags byte should produce a known role + if (advert.flags) { + const role = advert.flags.repeater ? 'repeater' : advert.flags.room ? 'room' : advert.flags.sensor ? 'sensor' : 'companion'; + if (!VALID_ROLES.has(role)) return { valid: false, reason: `unknown role: ${role}` }; + } + + // timestamp sanity: must be after 2020-01-01 and not more than 1 day in the future + if (advert.timestamp != null) { + const MIN_TS = 1577836800; // 2020-01-01 + const MAX_TS = Math.floor(Date.now() / 1000) + 86400; // now + 1 day + if (advert.timestamp < MIN_TS || advert.timestamp > MAX_TS) { + return { valid: false, reason: `timestamp out of range: ${advert.timestamp}` }; + } + } + + return { valid: true }; +} + +module.exports = { decodePacket, validateAdvert, ROUTE_TYPES, PAYLOAD_TYPES, VALID_ROLES }; // --- Tests --- if (require.main === module) { diff --git a/public/index.html b/public/index.html index 20df1550..6a477aba 100644 --- a/public/index.html +++ b/public/index.html @@ -85,7 +85,7 @@ - + diff --git a/public/nodes.js b/public/nodes.js index c27272d8..1d0ebd31 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -274,6 +274,13 @@ nodes = data.nodes || []; counts = data.counts || {}; + // Defensive filter: hide nodes with obviously corrupted data + nodes = nodes.filter(n => { + if (n.public_key && n.public_key.length < 16) return false; + if (!n.name && !n.advert_count) return false; + return true; + }); + // Ensure claimed nodes are always present even if not in current page const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const existingKeys = new Set(nodes.map(n => n.public_key)); diff --git a/server.js b/server.js index 8965779e..2029aed0 100644 --- a/server.js +++ b/server.js @@ -568,17 +568,22 @@ for (const source of mqttSources) { if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) { const p = decoded.payload; - const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion'; - db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now }); - // Invalidate this node's caches on advert - cache.invalidate('node:' + p.pubKey); - cache.invalidate('health:' + p.pubKey); - cache.invalidate('bulk-health'); + const validation = decoder.validateAdvert(p); + if (validation.valid) { + const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion'; + db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now }); + // Invalidate this node's caches on advert + cache.invalidate('node:' + p.pubKey); + cache.invalidate('health:' + p.pubKey); + cache.invalidate('bulk-health'); - // Cross-reference: if this node's pubkey matches an existing observer, backfill observer name - if (p.name && p.pubKey) { - const existingObs = db.db.prepare('SELECT id FROM observers WHERE id = ?').get(p.pubKey); - if (existingObs) db.updateObserverStatus({ id: p.pubKey, name: p.name }); + // Cross-reference: if this node's pubkey matches an existing observer, backfill observer name + if (p.name && p.pubKey) { + const existingObs = db.db.prepare('SELECT id FROM observers WHERE id = ?').get(p.pubKey); + if (existingObs) db.updateObserverStatus({ id: p.pubKey, name: p.name }); + } + } else { + console.warn(`[advert] Skipping corrupted ADVERT from ${tag}: ${validation.reason} (raw: ${msg.raw.slice(0, 40)}…)`); } } @@ -629,6 +634,15 @@ for (const source of mqttSources) { const lat = advert.lat ?? advert.latitude ?? null; const lon = advert.lon ?? advert.lng ?? advert.longitude ?? null; const role = advert.role || (advert.flags?.repeater ? 'repeater' : advert.flags?.room ? 'room' : 'companion'); + + // Validate companion bridge adverts too + const bridgeAdvert = { pubKey: pubKey, name, lat, lon, timestamp: Math.floor(Date.now() / 1000), flags: advert.flags || null }; + const validation = decoder.validateAdvert(bridgeAdvert); + if (!validation.valid) { + console.warn(`[advert] Skipping corrupted companion ADVERT: ${validation.reason}`); + return; + } + db.upsertNode({ public_key: pubKey, name, role, lat, lon, last_seen: now }); const advertPktData = { @@ -955,8 +969,13 @@ app.post('/api/packets', (req, res) => { if (decoded.header.payloadTypeName === 'ADVERT' && decoded.payload.pubKey) { const p = decoded.payload; - const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion'; - db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now }); + const validation = decoder.validateAdvert(p); + if (validation.valid) { + const role = p.flags ? (p.flags.repeater ? 'repeater' : p.flags.room ? 'room' : p.flags.sensor ? 'sensor' : 'companion') : 'companion'; + db.upsertNode({ public_key: p.pubKey, name: p.name || null, role, lat: p.lat, lon: p.lon, last_seen: now }); + } else { + console.warn(`[advert] Skipping corrupted ADVERT (API): ${validation.reason}`); + } } if (observer) {