Serve the real binary behind the browser page - #22
Merged
Conversation
The page re-implemented the checks in JavaScript because a browser cannot execute this program. It still can't — but it no longer has to be the only option. - Move probe() into a library so the CLI and the server run the same code rather than two implementations that agree today. ProbeRequest replaces the clap struct at that boundary, since a command line is only one way to ask. - Add `serve`, a small axum wrapper: GET /api/health, POST /api/probe, and the static page on everything else so the two share an origin. Feature-gated, so a plain `cargo build` still produces only the CLI and pulls in no axum, tower or hyper. - The page asks /api/health on load and offers a Rust/JavaScript switch only when something answers. On static hosting there is nothing to switch to, so it stays in browser mode and says so — the one thing worse than a reimplementation is one that lets you think otherwise. - Dockerfile and render.yaml for deploying it. Running a probe for strangers is not running one for yourself. An endpoint field that becomes an outbound connection is SSRF, so the server resolves the host and refuses anything not on the public internet — loopback, RFC1918, link-local (cloud metadata), CGNAT, multicast, and the IPv4-mapped IPv6 forms of each — then caps follow, shortens the header wait, and bounds the body and the request. None of it applies to the CLI, whose default target is 127.0.0.1:9944. Writing that guard's tests found a real hole: host_str() returns an IPv6 literal still bracketed, so `ws://[::1]` parsed as neither address nor name and was refused only because the DNS lookup happened to fail. Matching on url::Host instead judges it directly. Verified: container built and run — health, static page, a live probe through it (73 peers, block 32,267,796, TLS from the image), the guard refusing loopback, and non-root at uid 10001. Page driven in Chrome against the running server: engine detected, switch shown, real report rendered. Every SSRF vector above rejected with 400. 33 tests + 1 ignored; fmt and clippy clean under --all-features, which CI now uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The page re-implemented the checks in JavaScript because a browser cannot execute this program. It still can't — but that no longer has to be the only option.
What changed
probe()moves into a library, so the CLI and the server run the same code rather than two implementations that happen to agree today.ProbeRequestreplaces the clap struct at that boundary, since a command line is only one way to ask.serve— a small axum wrapper:GET /api/health,POST /api/probe, and the static page on everything else so the two share an origin. Feature-gated: a plaincargo buildstill produces only the CLI, andcargo treeconfirms axum, tower and hyper are absent from the default graph./api/healthon load, then offers a Rust/JavaScript switch. On static hosting there is nothing to switch to, so it stays in browser mode and says so. The one thing worse than a reimplementation is one that lets you think otherwise.Dockerfile+render.yamlto deploy it.Running a probe for strangers is not running one for yourself
An endpoint field that becomes an outbound connection is SSRF.
guard.rsresolves the host and refuses anything not on the public internet — loopback, RFC1918, link-local (169.254.169.254, where cloud metadata lives), CGNAT, multicast, and the IPv4-mapped IPv6 form of each. The server also capsfollowto 3, shortens the header wait to 30s, limits the body to 4 KB, and bounds the whole request.None of it applies to the CLI, whose default target is
ws://127.0.0.1:9944. The difference isn't the address, it's who is asking.Writing that guard's tests found a real hole:
host_str()returns an IPv6 literal still bracketed, sows://[::1]parsed as neither an address nor a name and fell through to DNS — refused only because the lookup happened to fail. Right answer, wrong reason, and not a thing to rely on. Matching onurl::Hostjudges it directly.One limit stated plainly in the code and README: the guard resolves the name and the WebSocket client resolves it again, so DNS rebinding could still slip past. Closing it needs the connection pinned to the checked address, which the client doesn't expose. The service therefore holds no secrets and has nothing worth reaching.
Verified, not asserted
Container built and run locally:
Page driven in Chrome against the running server: engine detected, switch shown, real report rendered (71 peers, block 32,267,773, 3/3 heads). Every SSRF vector above rejected with 400, including
::ffff:127.0.0.1.33 tests + 1 ignored.
fmtandclippyclean under--all-features, which CI now uses throughout — optional features are exactly the code nobody compiles locally, so without it the server binary would rot while the default build stayed green. CI also builds the Dockerfile now, since a broken one otherwise surfaces as a failed deploy after it's already on master.🤖 Generated with Claude Code