fix(api): allowlist public node status fields

This commit is contained in:
gadgethd committed 2026-09-23 11:10:18 +00:00
1 parent d44ef5e572
commit 865b5ddb2d
6 files changed
+195 -28

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
/** Approved fields for the unauthenticated node status response contracts. */
export type PublicStatusCounters = {
battery_mv: number | null;
uptime_secs: number | null;
tx_air_secs: number | null;
rx_air_secs: number | null;
channel_utilization: number | null;
air_util_tx: number | null;
};
export type PublicLatestNodeStatusDTO = PublicStatusCounters & {
time: string;
node_id: string;
network: string | null;
name: string | null;
iata: string | null;
};
export type PublicMqttNodeStatusDTO = PublicStatusCounters & {
node_id: string;
name: string | null;
last_seen: string;
packets_24h: string;
};
type PublicStatusCounterSource = Partial<PublicStatusCounters>;
function publicStatusCounters(row: PublicStatusCounterSource): PublicStatusCounters {
return {
battery_mv: row.battery_mv ?? null,
uptime_secs: row.uptime_secs ?? null,
tx_air_secs: row.tx_air_secs ?? null,
rx_air_secs: row.rx_air_secs ?? null,
channel_utilization: row.channel_utilization ?? null,
air_util_tx: row.air_util_tx ?? null,
};
}
export function toPublicLatestNodeStatusDTO(
row: PublicLatestNodeStatusDTO,
): PublicLatestNodeStatusDTO {
return {
time: row.time,
node_id: row.node_id,
network: row.network,
name: row.name,
iata: row.iata,
...publicStatusCounters(row),
};
}
export function toPublicMqttNodeStatusDTO(
row: PublicMqttNodeStatusDTO,
): PublicMqttNodeStatusDTO {
return {
node_id: row.node_id,
name: row.name,
last_seen: row.last_seen,
...publicStatusCounters(row),
packets_24h: row.packets_24h,
};
}
+61
View File
@@ -37,3 +37,64 @@ test('channel feed history route forwards its bounded channel and scope', async
server.close();
}
});
test('public MQTT node status omits owner-only and unknown telemetry fields', async () => {
const storedRow = {
node_id: 'b'.repeat(64),
name: 'Public MQTT node',
last_seen: '2026-09-23T12:00:00.000Z',
battery_mv: 3900,
uptime_secs: 4200,
channel_utilization: 8,
air_util_tx: 4,
rx_air_secs: 18,
tx_air_secs: 9,
packets_24h: '12',
hardware_model: 'private model',
firmware_version: 'private firmware',
stats: {
wifi_ssid: 'private wifi',
reset_reason: 'private reset detail',
fs_free_bytes: 1234,
mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' },
future_owner_field: 'private future value',
},
future_status_field: 'private future value',
};
const router = Router();
registerMiscRoutes(router, {
query: async <T extends import('pg').QueryResultRow = import('pg').QueryResultRow>(text) => {
assert.doesNotMatch(text, /nss\.stats/);
return { rows: [storedRow as unknown as T] };
},
getRecentPackets: async () => [],
getRecentPacketEvents: async () => [],
getPacketDetail: async () => null,
getChannelMessageHistory: async () => [],
getPublicVisibilityGeneration: async () => 1,
packetDetailLimiter: passThroughLimiter as Parameters<typeof registerMiscRoutes>[1]['packetDetailLimiter'],
});
const app = express();
app.use(router);
const server = app.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/mqtt-nodes?network=ukmesh`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), [{
node_id: storedRow.node_id,
name: storedRow.name,
last_seen: storedRow.last_seen,
battery_mv: storedRow.battery_mv,
uptime_secs: storedRow.uptime_secs,
channel_utilization: storedRow.channel_utilization,
air_util_tx: storedRow.air_util_tx,
rx_air_secs: storedRow.rx_air_secs,
tx_air_secs: storedRow.tx_air_secs,
packets_24h: storedRow.packets_24h,
}]);
} finally {
server.close();
}
});
+4 -3
View File
@@ -16,6 +16,7 @@ import {
decodePlannedNodeCursor,
listPublicPlannedNodes,
} from '../../repositories/plannedNodes.js';
import { toPublicMqttNodeStatusDTO } from '../publicStatus.js';
type QueryFn = <T extends QueryResultRow = QueryResultRow>(
text: string,
@@ -218,7 +219,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
air_util_tx: number | null;
rx_air_secs: number | null;
tx_air_secs: number | null;
stats: Record<string, unknown> | null;
packets_24h: string;
}>(
@@ -232,7 +232,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
nss.air_util_tx,
nss.rx_air_secs,
nss.tx_air_secs,
nss.stats,
COALESCE(pc.packet_count, 0) AS packets_24h
FROM node_identity_status_samples nss
LEFT JOIN node_identity_nodes n ON n.node_id = nss.node_id
@@ -250,7 +249,9 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
ORDER BY nss.node_id, COALESCE(nss.uptime_secs, 0) DESC, nss.time DESC`,
params,
);
res.json(result.rows.filter((r) => Number(r.packets_24h) > 0));
res.json(result.rows
.filter((r) => Number(r.packets_24h) > 0)
.map(toPublicMqttNodeStatusDTO));
} catch (err) {
console.error('[api] GET /mqtt-nodes', (err as Error).message);
res.status(500).json({ error: 'Internal server error' });
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { toPublicLatestNodeStatusDTO } from '../publicStatus.js';
test('latest node status DTO omits owner-only and unknown telemetry fields', () => {
const storedRow = {
time: '2026-09-23T12:00:00.000Z',
node_id: 'a'.repeat(64),
network: 'ukmesh',
name: 'Public node',
iata: 'NCL',
battery_mv: 3910,
uptime_secs: 7200,
tx_air_secs: 14,
rx_air_secs: 28,
channel_utilization: 7,
air_util_tx: 3,
hardware_model: 'private model',
firmware_version: 'private firmware',
stats: {
wifi_ssid: 'private wifi',
reset_reason: 'private reset detail',
fs_free_bytes: 1234,
mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' },
future_owner_field: 'private future value',
},
future_status_field: 'private future value',
};
assert.deepEqual(toPublicLatestNodeStatusDTO(storedRow), {
time: storedRow.time,
node_id: storedRow.node_id,
network: storedRow.network,
name: storedRow.name,
iata: storedRow.iata,
battery_mv: storedRow.battery_mv,
uptime_secs: storedRow.uptime_secs,
tx_air_secs: storedRow.tx_air_secs,
rx_air_secs: storedRow.rx_air_secs,
channel_utilization: storedRow.channel_utilization,
air_util_tx: storedRow.air_util_tx,
});
});
+8 -24
View File
@@ -7,6 +7,10 @@ import {
parseBoundedInteger,
parseBoundedString,
} from '../utils/input.js';
import {
toPublicLatestNodeStatusDTO,
type PublicLatestNodeStatusDTO,
} from '../publicStatus.js';
const router = Router();
@@ -26,28 +30,11 @@ router.get('/node-status/latest', async (req, res) => {
}
const identityWhereClause = `WHERE ${identityConditions.join(' AND ')}`;
const result = await query<{
time: string;
node_id: string;
network: string | null;
battery_mv: number | null;
uptime_secs: number | null;
tx_air_secs: number | null;
rx_air_secs: number | null;
channel_utilization: number | null;
air_util_tx: number | null;
stats: Record<string, unknown> | null;
name: string | null;
iata: string | null;
hardware_model: string | null;
firmware_version: string | null;
}>(
const result = await query<PublicLatestNodeStatusDTO>(
`WITH identity_sources AS MATERIALIZED (
SELECT n.node_id AS canonical_id,
n.name,
n.iata,
n.hardware_model,
n.firmware_version,
source.source_node_id
FROM node_identity_nodes n
CROSS JOIN LATERAL unnest(n.identity_source_ids) AS source(source_node_id)
@@ -63,15 +50,12 @@ router.get('/node-status/latest', async (req, res) => {
nss.rx_air_secs,
nss.channel_utilization,
nss.air_util_tx,
nss.stats,
s.name,
s.iata,
s.hardware_model,
s.firmware_version
s.iata
FROM identity_sources s
JOIN LATERAL (
SELECT time, network, battery_mv, uptime_secs, tx_air_secs,
rx_air_secs, channel_utilization, air_util_tx, stats
rx_air_secs, channel_utilization, air_util_tx
FROM node_status_samples
WHERE node_id = s.source_node_id
AND network = ANY($1::text[])
@@ -85,7 +69,7 @@ router.get('/node-status/latest', async (req, res) => {
params,
);
res.json(result.rows);
res.json(result.rows.map(toPublicLatestNodeStatusDTO));
} catch (err) {
console.error('[api] GET /node-status/latest', (err as Error).message);
res.status(500).json({ error: 'Internal server error' });
+17 -1
View File
@@ -64,7 +64,23 @@ The service stores radio traffic observed on the MeshCore network.
deletion procedure in the ops playbook (rows by src/rx node id across
packets, packet_paths, decryptions, path facts).
## 3. Cookies
## 3. Device status telemetry
Status samples retain a JSON telemetry object for the authenticated owner
portal. The unauthenticated `/node-status/latest` and `/mqtt-nodes` APIs expose
only their explicit public fields: node identity, sample/availability data,
battery, uptime, airtime, channel utilization, and recent packet counts. They
do not return the telemetry object or forward unrecognized fields. Wi-Fi
identifiers, MQTT broker/account details, reset and configuration fields,
firmware metadata, and filesystem details are available only through
owner-authenticated endpoints. Unrecognized keys stay in storage until they
are deliberately added to a response contract.
Existing status samples do not need a database rewrite: public responses are
projected through the same field allowlist regardless of when the sample was
stored.
## 4. Cookies
LocalStorage-only choices (cookie consent, theme). No third-party cookies.
PECR does not apply to the mesh radio data (not a public electronic