fix: validate ADVERT data to prevent corrupted node entries

Fixes Kpa-clawbot/meshcore-analyzer#112
This commit is contained in:
you
2026-03-21 05:34:57 +00:00
parent edb0331a7c
commit 27f4af3f3b
5 changed files with 111 additions and 14 deletions
+13
View File
@@ -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(`
+59 -1
View File
@@ -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) {
+1 -1
View File
@@ -85,7 +85,7 @@
<script src="packets.js?v=1774059825"></script>
<script src="map.js?v=1774083841" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774064852" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774071292" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774048777" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774083840" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774256400" onerror="console.error('Failed to load:', this.src)"></script>
+7
View File
@@ -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));
+31 -12
View File
@@ -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) {