Skip to content

Static musl release image, and fix JWT verification broken by #28 - #29

Merged
nihaopaul merged 4 commits into
mainfrom
feat/rustls-static-musl
Aug 22, 2026
Merged

Static musl release image, and fix JWT verification broken by #28#29
nihaopaul merged 4 commits into
mainfrom
feat/rustls-static-musl

Conversation

@nihaopaul

@nihaopaul nihaopaul commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Why

Two problems, found while answering a question about whether the Dockerfile should target cc-debian12 or cc-debian13.

Auth is broken on main. #28 bumped jsonwebtoken 9.3.0 → 10. The only breaking change in v10 is that selecting a crypto backend became mandatory (rust_crypto or aws_lc_rs); neither was chosen, so default = ["use_pem"] left no provider and every decode() call panicked instead of verifying. It fails closed (panic → 5xx → Traefik denies), so this is not a security hole, but authentication cannot succeed.

The base images were mismatched. The binary compiled against glibc from rust:1 but ran on distroless/cc-debian12. rust:1 now tracks Debian 13, so that pairing was one base-image bump away from a GLIBC_* load failure.

What changed

Release image

Switched to a fully static musl binary on distroless/static-debian13, which removes the glibc version-matching constraint entirely rather than answering it. ~25 MB → 12.3 MB.

Linking statically forced OpenSSL out of the tree, which was worth doing anyway: reqwest was pulling in both native-tls/OpenSSL (via default features) and rustls, so two TLS stacks were compiled in and only one was used. Now rustls-only. http2 and charset are re-enabled explicitly because default-features = false silently drops them.

The upgrade

Enabled rust_crypto — also the pure-Rust backend, which keeps the static build free of new C dependencies (aws_lc_rs would have added C + cmake).

Why CI missed it

The sub-crates were never workspace members, so cargo test at the root only ran the root package's zero tests. Verified: plain cargo test runs 1 binary, --workspace runs 5. Added a [workspace] table and --workspace in CI — note a root that is both a package and a workspace still defaults to just the current package, so both changes are needed.

That also collapses three independently-resolved lockfiles into one, and let mockito — a test HTTP mock server that was being compiled into the production auth binary — move to [dev-dependencies].

The tests were vacuous

Both authenticator tests asserted tautologies that cannot fail for any Result:

assert_eq!(test.is_ok(), !test.is_err());
assert_eq!(test.is_err(), !test.is_ok());

The "success" test could not have passed on its own terms regardless: its token expired 2025-01-29, and every audience in its expected list was the real value with the leading character truncated (a4bef…4bef…). So it returned Err while the assertion still held.

Replaced with six tests that mint a throwaway RSA keypair per run and sign tokens at test time — nothing ages into a false failure, and no private key is committed. Coverage: accept path, expiry, audience mismatch, unknown kid, missing kid, and algorithm pinning. This also removed the old hardcoded JWT, which embedded a real email and a signature from a real account.

One hardening fix

Validation::new(header.alg) took the algorithm from the attacker-controlled token header. Not exploitable — jsonwebtoken rejects cross-family swaps, so an HS256 downgrade against an RSA key was already blocked. But a token signed RS384 against an RS256 JWKS was accepted, so a caller could still nominate the algorithm their own signature was checked against. Now uses key.alg from the JWKS.

The regression test for this was confirmed to fail on the old line and pass on the new one — a test that passes both ways is exactly the trap the old suite fell into.

Also dropped the .unwrap() on DecodingKey::from_rsa_components, which could panic inside a request handler given a malformed JWKS.

Verification

Built the image under podman and inspected the artifact:

Check Result
Image size 12.3 MB (was ~25 MB)
Binary statically linked aarch64 ELF, no interpreter
Dynamic deps / GLIBC_ symbols 0
OpenSSL references 0
rustls + baked-in webpki roots present
Tests 7 passing (were never running in CI)

Then ran it against live Cloudflare credentials: it fetched the Access JWKS and 11 apps from api.cloudflare.com over rustls, confirming musl DNS resolution and TLS with compiled-in trust anchors both work. /auth returned 403 to a malformed JWT, a garbage JWT, and a structurally-valid JWT carrying a real kid with a bogus signature — no CryptoProvider panic.

Also confirmed dropping an initial CC=musl-gcc line produced a byte-identical binary (same BuildID), so it was removed as redundant.

Notes for the reviewer

  • .dockerignore was letting 1.3 GB into the build context. .devbox self-ignores via a nested .gitignore, which .dockerignore does not honour. This hard-failed local image builds; CI was unaffected since .devbox doesn't exist on runners.
  • Correction: an earlier revision of this description claimed the build job pulls rust:1 unauthenticated and was exposed to Docker Hub rate limits. That was wrong. build compiles natively on the runner and pulls no image at all, and the release-only docker job runs docker/login-action before build-push-action, so its FROM rust:1 pull is authenticated. The toomanyrequests failure I hit was purely local (no Docker Hub credentials on my machine) and does not apply to CI.
  • Allocator cost is now measured (was listed here as unverified). Static musl vs a dynamic-glibc build of the same commit, load generated inside the container network, one arm at a time: musl costs ~11% peak throughput on the RSA-verify path (17.0k vs 19.2k rps, non-overlapping across 3 rounds). p99 at fixed offered rates of 1k/5k/10k rps showed no distinguishable difference — but honestly so: within-arm variance between rounds reached 8x on a podman/macOS VM, which exceeds any between-arm effect. Good enough to say there is no large regression; not an SLO-grade number. mimalloc was therefore not added — no measured justification.
  • Clippy is now clean across the workspace (0 warnings, was 10 — 6 more than first reported, in cloudflare-dynamic-config and src/main.rs). Two were not stylistic: fetch_apps cloned config.api and config.token on every outbound request.
  • jsonwebtoken applies a default 60s leeway on exp — standard clock-skew tolerance, left as-is but now documented in the test rather than being a surprise.

🤖 Generated with Claude Code

nihaopaul and others added 4 commits August 22, 2026 17:09
The release image compiled against glibc from `rust:1` but ran on
distroless/cc-debian12. `rust:1` now tracks Debian 13, so that pairing was
one base-image bump away from a GLIBC_* load failure. Switch to a fully
static musl binary on distroless/static-debian13, removing the glibc
version-matching constraint entirely (~25MB -> 12.3MB).

Linking statically forced OpenSSL out of the tree, which was worth doing
anyway: reqwest pulled in both native-tls/OpenSSL (via default features)
and rustls, so two TLS stacks were compiled in and only one was used. Now
rustls-only. `http2` and `charset` are re-enabled explicitly because
`default-features = false` silently drops them.

Fix a live bug found along the way: jsonwebtoken 10 ships no crypto
provider by default (`default = ["use_pem"]`), so decode() in
Authenticator::decode panicked instead of verifying -- the core path of
the service. Enable `rust_crypto`, which is also the pure-Rust backend
that keeps the static build free of new C dependencies.

CI never caught that because the sub-crates were not workspace members,
so `cargo test` at the root only ran the root package's zero tests. Add a
[workspace] table and pass --workspace in CI; the three existing sub-crate
tests now actually run. That also collapses three independently resolved
lockfiles into one and lets mockito -- a test HTTP server that was being
compiled into the production binary -- move to [dev-dependencies].

Exclude .devbox from the Docker build context: it is 1.3GB and self-ignores
via a nested .gitignore that .dockerignore does not honour, which
hard-failed local image builds.

Verified on a live system: aarch64 static binary (no interpreter, no GLIBC_
symbols, zero OpenSSL strings), runs on distroless/static, fetches the
Access JWKS and the Cloudflare API over rustls using baked-in webpki roots,
and rejects malformed and unsigned JWTs with 403 without panicking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #28 bumped jsonwebtoken 9.3.0 -> 10 without selecting a crypto
backend. v10 made that choice mandatory, so every decode() call panicked
instead of verifying. 3fbb7b9 enabled `rust_crypto`; this commit addresses
why the regression reached main unnoticed.

Both authenticator tests asserted tautologies:

    assert_eq!(test.is_ok(), !test.is_err());
    assert_eq!(test.is_err(), !test.is_ok());

Neither can fail for any Result. The "success" test could not have passed
on its own terms regardless: its token expired 2025-01-29, and every
audience in its expected list was the real value with the leading
character truncated, so validation returned Err while the assertion still
held.

Replace them with six tests that mint a throwaway RSA keypair per run and
sign tokens at test time, so no token ages into a false failure and no
private key is committed. They cover the accept path plus expiry,
audience mismatch, unknown kid and missing kid.

Also stop taking the algorithm from the token header. Validation::new(
header.alg) lets a caller nominate the algorithm their own signature is
checked against. jsonwebtoken blocks cross-family swaps (RSA vs HMAC), so
this was not exploitable, but a token signed RS384 against an RS256 JWKS
was accepted. Use the algorithm the JWKS advertises instead. The
regression test for this was confirmed to fail on the old line and pass
on the new one.

Drop the .unwrap() on DecodingKey::from_rsa_components, which could panic
inside a request handler given a malformed JWKS.

Point the placeholder devbox `test` script at the real suite and add a
matching `build` script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clippy reported 10 warnings across the three crates; all were needless
borrows, redundant returns or a redundant import. Now zero.

Two of them removed real work rather than just noise: fetch_apps cloned
`config.api` into a format! argument and cloned `config.token` for every
bearer_auth call, so each outbound request allocated two throwaway
Strings. Both are borrows now.

Also un-ignore devbox.lock. It is 1.9K of nix pins with no secrets, and
committing it makes `devbox run` resolve the same toolchain for everyone,
same rationale as Cargo.lock. Without it the environment is only pinned as
far as "stable".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nihaopaul
nihaopaul merged commit 1d9bf51 into main Aug 22, 2026
4 checks passed
@nihaopaul
nihaopaul deleted the feat/rustls-static-musl branch August 22, 2026 13:37
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