axum: serve_tls helper + mtls-identity example#80
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
| // Reject `.workloads.example.com` (empty label) and | ||
| // `a.b.workloads.example.com` (would alias as `a.b`), which an | ||
| // attacker-controlled CA could otherwise mint to spoof an ACL | ||
| // entry. |
There was a problem hiding this comment.
I realize this is an example, but I wonder about this...
If an attacker-controlled CA is accepted by the mTLS exchange, could they just spoof everything? Not just "a.b", but also "trustedjob", and also maybe "a.b.workloads.example.com", or "trustedjob.workloads.example.com"?
There was a problem hiding this comment.
Yeah, this looks like nonsense, will fix
There was a problem hiding this comment.
[claude code] You're right — if the trust store accepts an attacker-controlled CA, the attacker can mint a cert for any SAN, including trustedjob.workloads.example.com, so the comment was overstating what this guard buys you. The empty/multi-label checks are about parsing intent (only direct subdomains map to ACL names), not a CA-compromise mitigation. Reworded the comment in 2f5777b to just state the intent and drop the spoofing framing.
axum::serve takes a plain TcpListener with no hook for terminating TLS, so axum + mTLS deployments must hand-roll the rustls accept loop and per-connection extension plumbing to give handlers the same PeerAddr/PeerCerts view the standalone Server provides automatically. connectrpc::axum::serve_tls is a drop-in axum::serve replacement that owns the accept loop, terminates TLS with a slowloris-bounded handshake timeout, captures the remote address and verified client cert chain once per connection, and stamps both into request extensions. Handler code that reads ctx.extensions.get::<PeerCerts>() is now portable between the standalone Server and an axum app. The returned ServeTls mirrors axum::serve::Serve: a configurable IntoFuture builder with .tls_handshake_timeout() and .with_graceful_shutdown() knobs. Also adds tokio/macros to the server feature set: the accept loop in both server.rs and axum.rs uses tokio::select!, but the macro feature was previously only reachable via dev-dep unification, so a downstream crate enabling server without tokio/macros would fail to compile. Refs #49.
The mTLS twin of examples/middleware: same secret-store-with-ACL shape, but identity comes from the verified client certificate instead of a Bearer token. Hosted on axum behind connectrpc::axum::serve_tls; the handler reads PeerCerts from request extensions, parses the leaf cert's DNS SAN with x509-parser, and enforces an ACL keyed on the cert-derived workload name. Single self-contained binary: in-memory rcgen PKI (CA, server leaf, two workload client leafs), serve, call with both identities, graceful shutdown. No PEM files touch disk. The shared lib.rs exposes the proto, handler, identity extraction, and PKI helpers so tests/e2e.rs reuses them rather than duplicating cert generation. x509-parser is already a transitive dep via rcgen, so no new crates land in the lockfile. Closes #49.
State the intent (only direct subdomains of the workload domain are accepted) rather than describing one specific spoofing scenario, which understates what an attacker-controlled CA could do.
2f5777b to
516882a
Compare
Closes #49.
What
Two commits, in the order #49 suggests landing them:
1.
connectrpc::axum::serve_tls— a TLS-aware counterpart toaxum::serve(listener, router)that owns the rustls accept loop and stampsPeerAddr/PeerCertsinto request extensions, the same convention the standaloneServer::with_tlsuses. Handler code that readsctx.extensions.get::<PeerCerts>()is now portable between the standaloneServerand an axum app.The returned
ServeTlsmirrorsaxum::serve::Serve's shape: a#[must_use]IntoFuture<Output = io::Result<()>>builder with.tls_handshake_timeout()and.with_graceful_shutdown()knobs. The accept loop reuses the standaloneServer's conventions: slowloris-bounded handshake timeout (defaultDEFAULT_TLS_HANDSHAKE_TIMEOUT), transientaccept()error skip,TCP_NODELAY, and hyper-utilGracefulShutdowndrain. Module docs spell out the differences fromaxum::serve(concreteRouteronly, ALPN setup,PeerCertsis conditional, no automatic panic catching).2.
examples/mtls-identity— the mTLS twin ofexamples/middleware: same secret-store-with-ACL shape, but the credential is the client certificate the TLS handshake verified, not aBearertoken. The handler readsPeerCertsfrom extensions, parses the leaf cert's DNS SAN withx509-parser, derives a single-label workload name underworkloads.example.com, and gates an ACL on it.Single self-contained binary: in-memory
rcgenPKI (CA, server leaf, two workload client leafs), serve, call with both identities, graceful shutdown. No PEM files touch disk. Thelib.rsexposes the proto, handler, identity extraction, and PKI helpers sotests/e2e.rsreuses them rather than duplicating cert generation.Side fixes
tokio/macrosadded to theserverfeature.server.rsand the newaxum.rsboth usetokio::select!, but the macro feature was previously only reachable via dev-dep unification. A downstream crate that enabledserverwithout itself enablingtokio/macroswould fail to compile; CI never caught it because both--all-targets(Check) andcargo testpull in dev-deps.docs/guide.mdTLS-hosting drift fix. The guide claimed eliza wraps axum withtokio_rustls::TlsAcceptorfor TLS; eliza actually switches to the standaloneServerfor the TLS path. Replaced with the newserve_tlssnippet.Tests
connectrpc/src/axum.rs(#[cfg(test)]):PeerAddr+PeerCertsreach the handler with the verified leafexamples/mtls-identity(tests/e2e.rs+lib.rsunit tests):WhoAmIreflects cert SAN and remote addrx-served-bytrailerextract_identityrejects no-cert, empty-prefix, multi-label-prefix, and unrelated-domain SANsNotes for review
pub mod axumshadows the externaxumcrate withinconnectrpc's crate-root scope only. There's an in-source comment explaining the constraint (don'tuse axum::...inlib.rs); downstreamuse connectrpc::axum::serve_tls;is unambiguous. I consideredconnectrpc::axum_tlsbut the issue spec'sconnectrpc::axum::serve_tlsreads better, and the shadowing is contained.serve_tlsaccepts a concreteaxum::Router, not the make-service formsaxum::serveis generic over. The module docs explain why (PeerAddrreplacesConnectInfo<SocketAddr>, sointo_make_service_with_connect_infohas no analogue). Generalising the bound would be possible but adds API surface I'd rather defer until someone needs it.lib.rsis the in-memory PKI helper, which is heavily commented because it doubles as a "what a deployment would load from a secret store" model.Reviewer findings deferred (Low / Advisory)
Flagging these for a decision rather than addressing them in this PR:
ServeTlsdoesn't expose anhttp1_keep_alive(bool)knob likeBoundServerdoes.axum::servedoesn't either, soserve_tlsis at parity with the thing it replaces. Easy to add later if anyone hits the stale-connection race on axum.Serverandaxum::serve, both unbounded. Could grow aSemaphore-backed knob if it becomes a need.PeerAddrcould deriveCopy(it wraps aCopySocketAddr). Public API change inserver.rs, out of scope here.axum.rstest fixture duplicates thepki()helper fromserver.rs's tests. They differ in what they return; hoisting to a shared#[cfg(test)]module is possible but increases coupling.