Skip to content

fix(bridges): a stranger's line ends nothing, and a web page cannot drive a robot - #57

Merged
rokbenko merged 3 commits into
mainfrom
security/bridges
Nov 2, 2026
Merged

rokbenko merged 3 commits into
mainfrom
security/bridges

Conversation

@rokbenko

@rokbenko rokbenko commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Two ways a stranger could reach a robot's daemon, closed, and three small things that are opt in or only ever say something. Stacked on #56's branch.

One line could end the Open Duck bridge. 7, [], null, a stray byte, a token that was not ASCII or a thousand brackets each raised out of the only thread the server has, token or no token, and left the duck stopped, torqued and unreachable. Whatever one client's bytes raise now costs that client alone. A client that had not authenticated could also stop a duck somebody else was walking, by saying duck.command and hanging up.

A web page could drive a robot. A page the operator visits can POST to 127.0.0.1, which is where the ssh tunnel the docs recommend ends. On a daemon with no token, which is how the README starts ToddlerBot's, it read the state and got bot.stand accepted: the headers are no JSON and were skipped, and the body's line was read as a command. A MOSS bridge with no token took /twist and /arm/cycle the same way.

  • The two line daemons close a connection that opens with an HTTP request or an oversized line. A bad line later in a session still costs nothing, and test_a_line_that_is_not_a_request_does_not_cost_the_session still holds that
  • The three HTTP daemons refuse a request carrying Origin. Nothing real sends it, and a person opening a frame in a tab still gets it
  • ToddlerBot reads at most 64 KiB of a line, as the Open Duck bridge does

These are files you copy to the robot, so a robot has them once you copy them again.

Opt in, or only ever a line of text. Nothing is refused that worked:

  • quackd serve-mcp --lock-duckfile, off by default: refuses a contract wider than the startup --duckfile
  • A project .env choosing where a key from your shell goes gets one line on stderr, naming the variable and the host. stdout, --json and a run's saved terminal are untouched
  • quackd ui off loopback says the page is plain HTTP

How it was checked. tests/test_bridges_hostile_peers.py replays every attack against the real daemons with their own fake bodies, and 25 of its first 31 tests failed on the daemons as they were. The six that passed are the ones that say a real client still works.

An adversarial review then drove real Chrome against this, twice. It found what my first version missed:

  • a URL long enough to push the request line out of first place, so Host: was read as the first line and the body still ran
  • method words other than capitals, which a same-origin page sends as written
  • a thousand brackets, a RecursionError where the code caught ValueError
  • a connection cap and first-line deadline I had added, which let sixteen blank lines lock out a real pilot. main does not allow that, so they are gone, and the line cap alone bounds the memory they were there for
  • the warning reaching stdout when stderr is closed, where a script's JSON and the MCP protocol are

All of those are fixed and have tests. On the second pass, 32 real Chrome fetches at URLs from 1 KB to 200 KB reached nothing.

What is still open, and in SECURITY.md: a page on a name it rebinds to 127.0.0.1 can read a frame from an HTTP daemon with no token. Refusing an unfamiliar Host would close it and would also refuse tailscale serve and anybody else's proxy, so I left it. A token closes it where there is one, and the camera daemon has none.

Nothing moved. Every existing bridge test passes unmodified but one, whose spy now sees the token compared as bytes.

🤖 Generated with Claude Code

rokbenko and others added 2 commits November 1, 2026 20:20
…rive a robot

Replayed against the real daemons with their own fake bodies, one line ended the
Open Duck bridge's only thread, token or no token, and a browser-shaped POST got
bot.stand accepted on a tokenless ToddlerBot, through the ssh tunnel the docs
recommend. tests/test_bridges_hostile_peers.py replays each, and 25 of its first 31
tests failed on the daemons as they were.

The line daemons close a connection that opens with an HTTP request or an oversized
line, and keep their tolerance of a bad line mid-session. The HTTP daemons refuse a
request carrying Origin. No real client sends either, and a person opening a frame in
a tab still gets it. ToddlerBot caps a line at 64 KiB.

quackd serve-mcp --lock-duckfile, off by default, refuses a file asking for more than
the startup contract. A project .env choosing where a key from the shell goes gets one
line on stderr and nothing is refused, and quackd ui off loopback says it is plain
HTTP.

An adversarial review drove real Chrome against this and found what the first version
missed: a URL long enough to push the request line out of first place, method words
other than capitals, thousands of brackets, and a connection cap and first-line
deadline that let a stranger lock a real pilot out. That cap and deadline are gone,
and the line cap alone bounds the memory they were there for. It also found the
warning could reach stdout with stderr closed, where a script's JSON and the MCP
protocol are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…before it

cli.py: each side added one import, and both stay. Both added under [Unreleased], and
the headings stay in the order every release uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rokbenko
rokbenko changed the base branch from deps/no-opencv to main November 2, 2026 06:13
@rokbenko
rokbenko merged commit 6012dc8 into main Nov 2, 2026
12 checks passed
@rokbenko
rokbenko deleted the security/bridges branch November 2, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant