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) {