Skip to content

fix(relay): backport HTTP response and ops-listener hardening - #7920

Merged
kixelated merged 1 commit into
releasefrom
backport/relay-http-hardening
Nov 5, 2026
Merged

kixelated merged 1 commit into
releasefrom
backport/relay-http-hardening

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Problem

On release, /fetch streams publisher bytes with no Content-Type, so a browser can sniff them as active content on the relay origin, and the authenticated /fetch and /announced responses carry no cache policy. The unauthenticated ops listener accepts browser-origin POSTs (a cross-site form to http://127.0.0.1:9101/sessions/revalidate) and reads through a DNS-rebinding hostname.

Tracking quest: quest/m0/backport-release-hardening.md, claimed in moq-dev/moq#7596.

Approach

Hand-port moq-dev/moq#7210 (merge commit 21112b33a6).

  • /fetch serves bytes as application/octet-stream. /fetch and /announced sit in their own sub-router whose map_response layer adds X-Content-Type-Options: nosniff and Cache-Control: private, no-store, refusals and extraction errors included.
  • The ops listener's acceptor reads the accepted socket's local address and wraps the router in a service that refuses, with 403, any request carrying Origin, and any request whose Host or URI authority is not that local IP and port. localhost is accepted only on loopback, dual-stack ::ffff: addresses are canonicalized, HTTP/2 authority is checked, and missing, duplicate, or malformed Host is refused. It runs before dispatch, so routes an embedder merges in inherit it.
  • Port fix(relay): harden HTTP responses and internal requests #7210's tests and its docs in doc/bin/relay/http.md and the internal.rs module and method docs. Skip its quest-file changes.

Differences from main, all from release's APIs rather than behavior: release's listener yields a plain tokio::net::TcpStream, so InternalAcceptor implements Accept<TcpStream, _> and takes local_addr() from the stream directly (no listener::Accepted wrapper). The web test uses create_track(name, None), two-argument auth::Config::init, and ConnectInfo<listener::Peer>.

Impact

  • Public API: none. No exported signatures, types, configuration, or variants change.
  • Wire: no MoQ protocol changes. /fetch and /announced responses gain Cache-Control: private, no-store and X-Content-Type-Options: nosniff; /fetch gains Content-Type: application/octet-stream.
  • Ops listener: refused requests return 403. Ops callers must use the listener's literal IP and port, or localhost on loopback. DNS aliases, forwarded caller hosts, and NAT or port-mapped addresses are refused. moq auth sessions/revalidate and the documented curl http://127.0.0.1:9101/... examples already comply; nothing under infra/ or packaging/ reaches this listener.
  • No dependency or version changes, tags, publication, deployment, or merges.

Alternatives

A cherry-pick would drag main's listener::Accepted and listener::Config into release. A bearer token was excluded by the settled quest because the listener stays on a trusted plane; CORS alone cannot stop a form POST. ConnectInfo was not used because axum_server's connect info carries only the peer address, not the local one.

Choices made without asking

  • Reworded the existing serve comment "No accept-time work" to say the acceptor only reads the local address, since the new acceptor now does that much.
  • Updated the h2 dev-dependency comment in rs/moq-relay/Cargo.toml to name the new internal-listener test as a user, alongside the http_auth bench.
  • The web test reads headers through a small header helper so a missing header fails as left: None with the path, rather than an unwrap panic.

Validation

All on head beeb23df749ad34b0c0852db5d52944e1851be57, in /home/kixelated/.worktrees/backport-relay-http-hardening/moq (outside the main checkout, so release's own .cargo/config.toml applies), each through quest gate nix develop --command ...:

  • Reproduction: with the ported tests and no fix, 3 of the 4 new tests fail (the 4th, authority_matches_only_the_local_address_and_port, unit-tests the new owns_authority helper and so cannot compile without it):
    • internal_requests_require_the_listener_authority_and_no_origin: listen=127.0.0.1:0, POST /sessions/revalidate, "Host: 127.0.0.1:42777\r\nOrigin: https://evil.example\r\n": HTTP/1.1 200 OK (expected 403).
    • internal_http2_checks_authority_without_a_host_header: evil.example, host=None, origin=None returned 200, expected 403.
    • authenticated_http_responses_are_private_and_cannot_be_sniffed: refused /fetch/room/video had Cache-Control None, expected Some("private, no-store").
    • With the fix, all 4 pass, as does the existing serve_hosts_merged_routes_alongside_the_defaults.
  • just ci check origin/release: pass (rs: selecting moq-cli moq-relay), including clippy, rustdoc, the io_uring clippy, JS check/build, actionlint, and nix flake check.
  • just ci test origin/release: pass (rs: selecting moq-cli moq-relay). Rust: 379 passed, 2 skipped, plus 133 passed (moq-cli). JS suites pass.
  • just rs uring (includes just rs uring-check): pass. 337 passed, 2 skipped (moq-uring and moq-relay with io-uring), plus 78 passed (moq-uring with qlog).
  • sh/api-diff/run.sh --deny rust (main's script; release does not carry sh/api-diff/) against origin/release: pass, all 30 published crates unchanged, including moq-relay 0.17.12.
  • quest check --base origin/release: pass.

Review

Codex, gpt-6.1-sol, medium effort, round 1 (quest review --base origin/release). The author is Claude, so Codex is the other vendor. Reviewed head: beeb23df749ad34b0c0852db5d52944e1851be57. No findings.

Follow-ups

  • As on main: if deployments scrape the ops listener through a Docker port mapping or a DNS or service name, consider an opt-in [internal] host allowlist.
  • This PR belongs in the already planned combined patch release; the maintainer chooses versions and cuts it separately.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

Backport of #7210 to release.

- /fetch serves bytes as application/octet-stream; /fetch and /announced
  send X-Content-Type-Options: nosniff and Cache-Control: private, no-store,
  refusals and extraction errors included.
- The internal (ops) listener refuses any request carrying Origin, and any
  request whose Host or URI authority is not the accepted socket's local IP
  and port (localhost only on loopback), before dispatch so merged routes
  inherit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review November 4, 2026 23:22

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: beeb23d

No actionable bugs found. The backport preserves #7210's response hardening and pre-dispatch ops guard while adapting to release's TcpStream listener API. The documented DNS/NAT restriction remains intentional. The focused backport is a reasonable, simpler approach than changing the release listener abstraction.

Static GitHub review only; I did not run tests. CI was still in progress when checked.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Ops listener admission: InternalAcceptor reads the accepted socket's local_addr() and wraps the final router in InternalService, so all requests (including embedder-merged routes) are checked before dispatch.
  • Origin and authority policy: allows_request refuses any Origin and requires every present Host/URI authority to name the socket; owns_authority normalizes IPv6/IPv4-mapped forms, accepts localhost only on loopback, and rejects userinfo, bad ports, and mismatched hosts.
  • Public HTTP headers: /announced and /fetch moved into a granted sub-router with a map_response layer adding Cache-Control: private, no-store and X-Content-Type-Options: nosniff; ServeGroup now sends Content-Type: application/octet-stream.
  • Tests and docs: ported the unit/HTTP2/HTTP1 tests, the web header/body test, and the http.md documentation. h2 dev-dependency comment updated.

The port matches main's #7210 logic exactly, with only the documented release API substitutions. I checked the authority parsing against the http/hyper/h2 sources: they share the same Authority grammar, and the policy requires all present authorities to pass, so there is no parser-disagreement fail-open. The DNS/service-name deployment tradeoff is already documented in the PR and http.md.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@kixelated
kixelated merged commit 6d6f264 into release Nov 5, 2026
23 checks passed
@kixelated
kixelated deleted the backport/relay-http-hardening branch November 5, 2026 06:09
@pullfrog pullfrog Bot mentioned this pull request Nov 5, 2026
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