* Keep one malformed packet from breaking API endpoints or node updates
Values in a packet come from the mesh, so a forged packet or a newer firmware can carry
data the code did not expect:
- Routing.error_reason with a number the bundled protobufs do not define made
Routing.Error.Name() raise, and /api/packets returned 500 for every list that included
that packet.
- An unknown DeviceConfig.Role or HardwareModel in MAP_REPORT or NODEINFO raised the same
way, and the whole node update was dropped.
- rx_snr of +inf was stored and served as "Infinity", which is not valid JSON, so the
packet page's "seen by" list failed to load.
- A NEIGHBORINFO packet whose payload does not parse made /api/edges return 500 while it
was among the 50 most recent.
Add decode_payload.enum_name(), which returns "unknown(N)" for undefined values, and use it
at all five enum lookups. Store non-finite SNR as NULL and serve existing ones as null.
Skip NEIGHBORINFO packets that do not decode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Three places still put mesh-provided strings into HTML unescaped:
- stats.html: the pie-chart tooltip formatter returned params.name raw. The charts cover
channel, hardware model, role and gateway firmware, so a channel or firmware string ran
as HTML when someone hovered its slice.
- traceroute.html: the node tooltip formatter returned the long name raw.
- packet.html: "PDOP", "sats_in_view", "ground_speed" and "altitude" values parsed from
the payload text went into the card unescaped. A broadcast text message reading
"PDOP: <img ...>" is enough.
Use the existing escapeHtml() from static/html-escape.js in all three.
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
/kiosk builds every marker in one forEach, so a single node with unexpected data stops
the whole page:
- node.role.toLowerCase() throws when role is null. Nodes first heard through a POSITION
packet are created by ensure_node_exists() without a role, so on a fresh database the
kiosk draws no nodes at all once one of them has a position.
- markers is a plain object keyed by channel name. A node on a channel called "__proto__",
"constructor" or "toString" finds an existing non-array value there, and .push() throws.
Either error aborts the loop, and the channel filters and live packet blinking after it
never start. Treat a missing role as "not a router" and keep markers in a Map.
/stats counted values per field in a plain object too, so a channel called "constructor"
was counted as the string "function Object() { [native code] }1" and "__proto__" was lost.
Count in a Map there as well.
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The four "Export CSV" buttons each built CSV by hand, from values that come from the mesh:
- /nodelist used a data: URL through encodeURI(), which leaves "#" alone, so the file
ended at the first "#" in any cell. Real node names contain one ("Big Rock Ridge #1").
- The node page's chart export (neighbors, telemetry) did not quote cells at all.
- /stats quoted cells but did not double quotes inside them, so a channel such as
Mesh "West", Bay split into extra columns.
- None of them guarded against spreadsheet formulas. A node name, channel, firmware string
or message that starts with = + - or @ opens as a live formula in Excel or LibreOffice.
Add static/csv.js (loaded from base.html, like html-escape.js) with csvCell(), which
quotes every cell, doubles inner quotes and prefixes formula-like text with ', leaving
plain numbers alone, and csvFromRows(). All four exports use it; /nodelist now downloads a
Blob like the others.
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Answer 400, not 500, for malformed or out-of-range numeric parameters
Many endpoints turned query or path values into integers with a bare int(), so
?length=abc on /api/channels or /api/stats/top, /api/traceroute/abc and
/graph/traceroute/abc answered with an unhandled exception and a 500.
Values that do parse could still fail: node and packet ids are BIGINT and portnum is
INTEGER, and a longer number made the database driver raise, which also answered 500
(/api/packets?node_id=99999999999999999999999, /api/packets_seen/<same>, the node QR and
coverage routes, /api/stats and /api/stats/count filters). On PostgreSQL that includes
any portnum outside 32 bits.
length had no upper bound, so a huge value overflowed timedelta (500) or widened the
aggregate to the whole table. On SQLite a negative limit means "no limit", so
/api/stats/top?limit=-1 returned every node and skipped its 100-row cap.
Add parse_db_int(), which also rejects values outside the column's range, plus two small
helpers that answer 400 with a message. Clamp length to 1..3650, limit to 1..100 and
offset to >= 0. Endpoints that already ignored an unparseable filter (the /api/packets
node, portnum and time filters) treat an out-of-range one the same way. Valid requests are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Log request values and MQTT topics with %r so they cannot add log lines
/api/packets logged an unparseable since, portnum or node id filter with an f-string, so
the decoded query value went into the log as is. ?since=1%0A<fake line> wrote a second,
forged log line, and escape sequences (ESC, BEL) reached whoever reads the log in a
terminal. The ingest error added in startdb.py logged the MQTT topic with %s.
Use %r for these values, as the gateway_id warning in mqtt_store.py already does, so CR,
LF, ESC and other control characters appear escaped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Bound the traceroute data a request has to draw
Every TRACEROUTE message is stored as a new row under its packet id, and its route can list
any number of hops. /graph/traceroute decodes every row, looks up every node on every route
and runs Graphviz over the result inside the request handler, so a publisher can make one
page view take a very long time and stall every other request meanwhile:
- 3,000 rows of 8 random hops for one packet id: the graph took about 43 s and other
requests waited up to 24 s.
- 150 rows of 400 hops: about 40 s, then a 500, with other requests waiting about 10 s.
Real traceroutes are small. In 17 days of bay-area traffic no packet id had more than 72
rows, and none of 66,709 routes had more than 8 hops in either direction, which is the
firmware's limit.
get_traceroute() now returns at most the 200 earliest rows, and the TRACEROUTE decoder
treats a route longer than 8 hops as undecodable, which every caller already skips.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Remove a blank line that fails the ruff format check
`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Include the node's public key in the contact QR code #184
The contact QR code and import URL never set user.public_key. Current
Meshtastic apps refuse to import a keyless contact. On 2.7.x firmware,
an older app's add_contact also overwrites the key the radio already
holds, so direct messages to that node fail.
Read the key from the node's latest stored NODEINFO_APP packet. Share
the contact only when a 32-byte key is known. Otherwise the node page
hides the QR buttons and says why. The QR API response now also
returns public_key.
Also drop the stray blank line that fails ruff format --check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Do not share a contact when a node has reported more than one key #184
A forged NODEINFO uplinked over MQTT could otherwise become the newest
packet, and the shared contact would then replace the key a scanning
radio already trusts. The QR endpoint now reads every stored NODEINFO
from the node and refuses to share when their 32-byte keys disagree.
It reports that in public_key_conflict, and the node page says why.
Add an index on packet (from_node_id, portnum, import_time_us DESC) so
the lookup reads only the node's NODEINFO packets. Order with NULLS
LAST so PostgreSQL does not treat old rows without import_time_us as
newest. Carry is_licensed and is_unmessagable into the contact.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Skip NODEINFO rows with no payload when building the contact #184
packet.payload is nullable, and MeshPacket.FromString(None) raises
TypeError, so one such row made the QR endpoint answer 500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* Bound /api/coverage parameters and run the ITM model off the event loop
The coverage endpoint took radius_km, step_km and the other model inputs
straight from the query string with only a float() conversion. The ITM
loop advances by step_km until it passes radius_km, so step_km=0 (or a
negative value) never ends and radius_km=inf never ends either; a very
small step with a large radius means millions of model calls. All of this
ran synchronously inside the request handler, so one such request stalls
every other request the server is handling.
- Reject non-finite values and anything outside a sane range for each
parameter (400 with a message saying the allowed range).
- Cap radius_km / step_km at 4000 steps.
- Run compute_perimeter / compute_coverage in a worker thread so the event
loop keeps serving other requests while the model runs.
- Make the loops themselves refuse a non-positive step or non-finite radius,
so a future caller cannot reintroduce the hang, and shrink the result
cache (64 entries) since bounded inputs make large results possible.
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>