mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-26 17:54:14 +00:00
fix: validate ADVERT data to prevent corrupted node entries
Fixes Kpa-clawbot/meshcore-analyzer#112
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user