Skip to content

fix(scene): survive malformed upstream messages and keep the web UI local - #245

Open
enkerewpo wants to merge 3 commits into
devfrom
fix/scene-untrusted-inputs
Open

enkerewpo wants to merge 3 commits into
devfrom
fix/scene-untrusted-inputs

Conversation

@enkerewpo

Copy link
Copy Markdown
Member

Fixes #226. Fixes #227. Relates to #229.

What was wrong

ROS 2 does not check that a message is self-consistent. An OccupancyGrid whose data length disagrees with width * height, or an Image whose buffer does not match its declared size and encoding, is type-valid and is delivered to every subscriber. Scene then reshaped the buffer, and reshape raises.

The raising happened inside request handlers, which is what made it matter. The debug UI answered 500 on /api/state and /api/camera for as long as the malformed message stayed the latest one, so anyone able to publish on /map could hold the interface down. The same contamination reached goal_near through the MCP path, where a resolution of zero also divided by zero.

Separately, that UI has no authentication and its annotation endpoints read and write map data, and it defaulted to binding every interface.

What changed

One rule, stated once. scene_service/message_shape.py says what length a grid or an image of a given size and encoding must carry. It is pure and imports nothing. Both decode sites ask it, so the rule cannot be applied at one and forgotten at the other, and it can be tested without a ROS or robonix runtime.

A malformed frame is dropped, not raised. The rest of the page keeps working and the next well-formed message recovers the view, with one warning naming what was discarded.

The planner half was already fixed. The goal_planner refactor rejects a non-positive resolution and a mismatched cell count, so #227 no longer reproduces. Nothing tested either check, so nothing would have noticed them going away again — that is what the new tests are for.

The UI defaults to loopback. All three places that choose the bind address — the resolver and both launchers — now default to 127.0.0.1. A launcher still passing 0.0.0.0 would have made the library default meaningless, so a test checks that too. The container runs with --network host, so the service's bind is the host's bind and nothing about port publishing changes. SCENE_WEB_HOST=0.0.0.0, or web_host in the Scene config, opens it again in one line, and the service logs a warning whenever it is bound wide so the choice stays visible.

Tests

system/scene/tests/test_malformed_grid_inputs.py, 21 cases: the rule itself, both planner entry points, and every encoding Scene decodes.

These and test_goal_planner.py now run in the pull-request CI job. They import neither robonix_api nor the generated MCP types, so the lightweight dependency set already there is enough. Nothing on a pull request exercised these guards before, which is how they came to be missing in the first place.

Locally: 201 passed against a 200 baseline, with the same 11 pre-existing failures on both sides — all of them ModuleNotFoundError for workspace and codegen artifacts that are absent outside a built package.

Note on #229

Authentication itself is not added here. The endpoints remain open to whoever can reach the port; what changed is that reaching it now takes a deliberate act. Whether this UI should carry real authentication is a larger question than a default, and worth deciding separately.

ROS 2 does not check that a message is self-consistent. An OccupancyGrid whose
data length disagrees with width * height, or an Image whose buffer does not
match its declared size and encoding, is type-valid and is delivered to every
subscriber. Scene then reshaped the buffer, and reshape raises.

The raising happened inside request handlers, which is what made it matter. The
debug UI answered 500 on /api/state and /api/camera for as long as the
malformed message stayed the latest one, so anyone able to publish on /map
could hold the interface down. The same contamination reached goal_near through
the MCP path, where a resolution of zero also divided by zero.

The planner half of this was already fixed by the goal_planner refactor, which
rejects a non-positive resolution and a mismatched cell count. Nothing tested
either check, so nothing would have noticed them going away again.

message_shape states the rule once, as pure functions with no imports: what
length a grid or an image of a given size and encoding must carry. Both decode
sites ask it, so the rule cannot be applied at one and forgotten at the other,
and it can be tested without a ROS or robonix runtime. A malformed frame is
dropped with a warning rather than raised: the rest of the page keeps working
and the next well-formed message recovers the view.

Scene's web UI also now says at startup when it is bound to a non-loopback
address, because its annotation endpoints read and write map data and carry no
authentication. That is tenable on loopback and is a decision anywhere else,
and an operator who did not make it deliberately should know they have. The
default bind address is unchanged; web_host: 127.0.0.1 already keeps it local.

Reported in #226 and #227, and the warning relates to #229.
The Scene debug UI has no authentication, and its annotation endpoints read and
write map data: reaching it is enough to change what the robot believes about
its world. It defaulted to every interface, so a deployment exposed that on the
network without anyone deciding to.

Loopback is now the default in all three places that choose it — the resolver
and both launchers. A launcher that kept passing 0.0.0.0 would have made the
library default meaningless, which is what the new test checks. The container
runs with --network host, so the service's bind is the host's bind and nothing
about port publishing changes. SCENE_WEB_HOST=0.0.0.0, or web_host in the Scene
config, opens it again in one line, and the service logs a warning whenever it
is bound wide so that choice stays visible.

The goal-planner and message-shape tests also join the pull-request CI job.
They are pure and import neither robonix_api nor the generated MCP types, so
they run under the lightweight dependency set already used there. Nothing on a
pull request exercised the guards against a malformed occupancy grid until now,
which is how those guards came to be missing to begin with.

Relates to #229.
@github-actions github-actions Bot added comp:scene system/scene comp:ci .github/ workflows type:bug Bug fix (fix:) labels Sep 9, 2026
The two test files this branch added to CI could not be collected there:
pytest prepends the directory holding a test file, not the package root,
so `from scene_service import ...` only resolves when pytest runs from
system/scene. CI runs it from the repository root.

Every older test in that directory carries its own sys.path.insert, which
is why the four already in the job pass and why test_goal_planner.py could
sit in the tree without anything noticing it was never collected. A
conftest does it once for the directory.
@enkerewpo
enkerewpo marked this pull request as draft September 13, 2026 11:59
@enkerewpo

Copy link
Copy Markdown
Member Author

need to be tested

@enkerewpo
enkerewpo marked this pull request as ready for review September 13, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp:ci .github/ workflows comp:scene system/scene type:bug Bug fix (fix:)

Projects

None yet

1 participant