* 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>
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* Keep the MQTT writer running when a message is malformed
startdb.py stores every message it receives with mqtt_store.process_envelope,
and nothing caught an exception from it. The gateway id in a ServiceEnvelope
was converted with int(gateway_id[1:], 16) without checks, so a gateway id
of "x", "!zz" or a very long hex string raised ValueError/OverflowError,
the ingest task died and startdb.py exited. A NODEINFO whose id spelled an
existing node differently ("!00abcd" vs "!abcd") hit the UNIQUE constraint
on node.node_id at commit time with the same result. Anyone who can publish
to the broker can trigger these, and repeating them keeps the writer in a
restart loop so the site stops receiving packets.
- Parse gateway and user ids with one helper that returns None for anything
that is not "!" + hex (or does not fit a 64-bit column) and skip the
PacketSeen/Traceroute/Node update instead of raising.
- When a NODEINFO id is new but its node_id already exists, update that row
instead of inserting a duplicate.
- Wrap the per-message store call in startdb.py so an unexpected error is
logged and the loop moves on to the next message.
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>
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* Escape mesh-provided strings before inserting them into the page
Node names, firmware strings, channel ids and message text all come from
the radio network, so anyone with a Meshtastic node (or publish access to
the MQTT broker) chooses their contents. Several pages dropped those
values straight into innerHTML / insertAdjacentHTML / Leaflet popups,
which lets a crafted node name run JavaScript in every visitor's browser.
Add a small shared helper (static/html-escape.js, loaded by base.html and
kiosk.html) and route every mesh-controlled value on the node list, top
nodes, firehose, node, packet and kiosk pages through it. Pages that
already had a local escapeHtml (chat, net, map, reliability) are unchanged.
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>
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* Keep /api/lang and the static page route inside their directories
Two routes built a filename from request input without checking where it
ended up:
- /api/lang?lang=... joined the value onto the lang directory and added
".json". os.path.join discards the directory when the value is an
absolute path, so ?lang=/some/where/file returned /some/where/file.json.
Any .json file the server process can read was served.
- /{page} appended ".html" and read static/<page>. aiohttp passes an
encoded slash (%2F) through a single path segment and unquotes it, so
/..%2F..%2F..%2Fx read ../../../x.html relative to the static directory.
The lang route now only accepts a plain language code (en, es, pt-BR, ...)
and falls back to English otherwise, exactly as it already did for unknown
languages. The page route resolves the final path and requires it to be a
file inside meshview/static, the same check /docs/{doc} already does.
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>
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* Do not let payload search match redacted direct messages
/api/packets shows the text of a direct message (TEXT_MESSAGE_APP to a
specific node) as "<redacted>". The contains= filter, however, is a LIKE
over the stored packet bytes, which include the decrypted text for
messages on a known channel key. With portnum left out of the request,
contains=<guess> returned the packet when the guess appeared in a redacted
message, so the text could be recovered one substring at a time.
Direct text messages are now excluded from the substring match. Broadcast
messages and all other packet types search exactly as before.
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>
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* fix: limit the neighbor SNR chart to the last 7 days
The chart asked for the last 500 neighbor-info packets and put no
limit on how old they could be. On a busy node that is several weeks
of history, and every neighbor heard in that span gets its own line.
On one node it was 119 packets covering 21.8 days and 339 lines.
The chart now asks for the last 7 days, using the `since` parameter
that /api/packets already supports, and draws only the 15 neighbors
with the most data points in that window. A subtitle says how many
neighbors are shown and how many there were, so a missing line is not
a mystery.
Three smaller fixes in the same function:
- Neighbor names are looked up only for the lines that are drawn.
fetchNodeFromApi() does not cache a node it cannot find, so every
unknown neighbor was fetched again on every packet it appeared in.
- Each point is read from a Map instead of calling indexOf() on the
timestamp list, which was quadratic in the number of points.
- Two neighbors sharing a short name are told apart. ECharts
identifies a series by its name, so they were drawn as one line.
The "Export CSV" button for this chart wrote an empty file, because
neighborData was declared but never filled in. It is now filled in
from the neighbors on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Pablo Revilla <pablorevilla@gmail.com>
Co-authored-by: Nathan <nathan@stonekitty.net>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>