Skip to content

Add OAuth 2.1 auth and HTTPS to cve-intel serve - #10

Merged
Fazmin merged 5 commits into
Fazmin:mainfrom
pkilar:feat/oauth
Nov 7, 2026
Merged

Fazmin merged 5 commits into
Fazmin:mainfrom
pkilar:feat/oauth

Conversation

@pkilar

@pkilar pkilar commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Why

cve-intel serve has no authentication and permissive CORS. That is fine on loopback, but it means the server can't be shared with a team or run in production, and the Docker image already listens on 0.0.0.0 with nothing in front of it. MCP clients (Claude Code, Cursor, VS Code) now support the MCP authorization spec, so the server can sit behind an existing identity provider without any custom login code. Those clients and providers also expect the server's URL to be https://, which until now meant putting a TLS-terminating proxy in front of it, so the server can now serve HTTPS itself.

Approach

The server acts as an OAuth 2.1 resource server only. It never issues tokens, never sees a password and needs no client secret. It validates JWT access tokens against the issuer's published keys and advertises where to log in through RFC 9728 protected-resource metadata, which is what the MCP authorization spec requires. OAuth is off unless CVE_INTEL_OAUTH_ISSUER and CVE_INTEL_OAUTH_RESOURCE are set.

Decisions worth reviewing:

  • Behaviour change without OAuth. serve now refuses a non-loopback HOST (Docker's 0.0.0.0 included) unless CVE_INTEL_ALLOW_UNAUTHENTICATED=1. Existing container deployments need either OAuth or that flag. The Dockerfile comments and README explain both.
  • Deny by default. With OAuth on, every path needs a token, including unknown ones, so a route added later can't be public by accident. Only /health and the discovery document stay open.
  • /health is trimmed to {"status":"ok"} when OAuth is on. The full report made an uncached NVD call and showed which keys are set, so it moves to GET /api/v1/health, which needs a token.
  • Fail closed. Startup fails if discovery or the JWKS can't be loaded, if none of its keys can verify tokens, or if the discovered issuer differs from the configured one. If keys were never loaded, requests get 503, never a pass. While refreshes fail, the keys already held are trusted for at most 4 hours, then requests get 503 too. REQUIRED_ROLES or REQUIRED_SCOPES set but empty stops startup instead of turning its check off. The key-fetching HTTP client fails closed if it can't be built.
  • Roles are optional. CVE_INTEL_OAUTH_REQUIRED_ROLES limits which users get in (Keycloak realm/client roles, groups, Entra app roles; exact matches, comma-separated because names may contain spaces). A refused user gets a plain 403 access_denied that doesn't name the allowed roles. Scopes limit which clients connect, not which users.
  • ID tokens are refused. An ID token issued to a client whose ID is also the configured audience would otherwise pass every check. Few providers set RFC 9068's typ: at+jwt, so the claims decide: a Keycloak typ other than Bearer, a Cognito token_use other than access, or (without those) a nonce, at_hash, c_hash or s_hash claim.
  • jsonwebtoken 11 on ring. jsonwebtoken before 10.3 skipped an nbf of the wrong type (GHSA-h395-gr6q-cpjc). From 10.0 on it needs a crypto provider, and its built-in ones add aws-lc or the rsa crate, which fails cargo audit on RUSTSEC-2023-0071. So jwt_crypto.rs is a small verify-only provider on ring, the library rustls already uses here. It refuses to sign.
  • Connection limits. serve runs its own hyper HTTP/1.1 loop instead of axum::serve, which sets no timer. Every connection, plain or TLS, must send a complete header within 10 s, and at most 512 are open at once. Both are constants, not settings.
  • Built-in HTTPS, optional. With CVE_INTEL_TLS_CERT and CVE_INTEL_TLS_KEY set, serve speaks TLS itself; a proxy in front still works as before. Certificates come from the operator (certbot, a corporate CA, cert-manager); there is no built-in ACME client. Renewed files are picked up without a restart. HTTPS doesn't change who may connect: a non-loopback bind still needs OAuth or the opt-out.
  • .env loader fix (affects everyone, not only OAuth). The loader read inline comments as part of the value, and .env.example has one after nearly every key. So copying it to .env, as the README says, set NVD_API_KEY and GITHUB_TOKEN to comment text, and on CVE_INTEL_OAUTH_REQUIRED_ROLES it would have added the comment's words as allowed roles. Unquoted values now end at a # that follows whitespace (abc#def and PASS=#abc are kept). Quoted values end at the first closing quote followed by nothing, whitespace or a comment, so 'it's' stays whole, and \" and \\ are escapes inside double quotes. export KEY= is accepted. Any cut text or ignored line prints one warning naming the line and key, never the value. .env.example puts comments on their own lines, because docker run --env-file never strips them.

How it works

  • Validation (crates/cve-server/src/auth.rs): asymmetric algorithms only (RS256/384/512, PS256/384/512, ES256, ES384, EdDSA); no crit header (RFC 7515 4.1.11; jsonwebtoken ignores it); iss a single string equal to the configured issuer (jsonwebtoken alone also accepts an array containing it); aud containing the resource (or CVE_INTEL_OAUTH_AUDIENCE); exp/nbf/iat with a configurable leeway of 0-600 s; a sub that isn't blank; optional required scopes from scope or scp; not an ID token. An exp or nbf that isn't a number in range rejects the token. Keys marked "use": "enc", symmetric keys and keys naming another algorithm never verify. Signatures are checked by ring through jwt_crypto.rs.
  • JWKS cache: found through OIDC discovery (or CVE_INTEL_OAUTH_JWKS_URI), cached for an hour, refetched on an unknown kid at most every 30 seconds. Refreshes are serialised, so a burst of forged tokens with random key IDs waits for one fetch instead of flooding the issuer. Each refresh runs in its own task, so a client hanging up can't cancel it and leave the 30 s limit blocking a newly rotated-in key. Keys of a type or algorithm the server doesn't understand are skipped (RFC 7517 section 5). Keys already held survive a failed refresh for up to 4 hours. A failed fetch from a discovered jwks_uri runs discovery again, so a moved key set is found without a restart. Once the keys pass their TTL, a request with a known key uses them at once unless it is the one due to refresh, so a slow or unreachable issuer delays at most one request per 30 s. Discovery and JWKS bodies are capped at 1 MiB while streaming.
  • Responses: 401 with WWW-Authenticate: Bearer resource_metadata=..., 403 insufficient_scope, 403 access_denied, or 503 when keys can't load. Metadata is served at /.well-known/oauth-protected-resource/mcp.
  • Logging: rejection reasons at info level. --debug serve logs the whole exchange (discovery, JWKS, metadata served, each token's header and standard claims, every check, the challenge sent). Each accepted request is logged with its user and client, and each MCP tool call with its tool name. Tokens appear only as a SHA-256 fingerprint; the token, signature and Authorization header are never logged, even when malformed. Claim text is escaped with clean_text (control characters and bidi overrides shown as \u{..}), since the debug log shows it before the signature is checked. A non-bearer Authorization header is logged only as a known scheme name. Without --debug, serve shows only its own records; rustls, hyper and reqwest lines (a scanner's illegal SNI, say) need --debug. Scopes are checked against RFC 6749's character set because they are quoted into WWW-Authenticate.
  • HTTPS (crates/cve-server/src/https.rs): rustls with the ring provider already in the tree, TLS 1.2 and 1.3, ALPN http/1.1 (axum is built without HTTP/2). There is no fallback to plain HTTP: only one variable set, an unreadable or oversized file, an encrypted key, or a key that doesn't match the certificate stops startup with a message naming the file. Each handshake runs in its own task with a 10 s limit, so stalled clients can't block the accept loop, and then joins the shared connection loop in listen.rs (header timeout, connection cap). The certificate is the TLS config's resolver; the files are re-read every minute, and a changed pair is swapped in only if it loads and matches, otherwise the error is logged once and the previous certificate stays. Key file contents never reach logs or errors; the PEM parser's own error text is dropped for the key because it can quote a line as a byte list. Startup logs the certificate's SHA-256 fingerprint in openssl x509 -fingerprint form.
  • Wiring: rest.rs loads the OAuth config at startup, refuses an unauthenticated non-loopback bind, and adds the auth middleware and routes. cli.rs turns on info-level logging for serve so operators see refused requests.
  • Dependencies: jsonwebtoken 11.1 (no default features, no crypto backend; ring through jwt_crypto.rs), ring (already in the tree through rustls), sha2, base64, url, and reqwest from the workspace, tokio-rustls (ring only, so rustls keeps one crypto provider), and hyper / hyper-util (already in the tree through axum) for the connection loop. cargo audit reports nothing new, and OSV has no advisories for jsonwebtoken 11.1.
  • Docs: README gets a "Securing the HTTP server" section with a Keycloak walkthrough, troubleshooting and notes for other providers, and a "Serving HTTPS" section (certbot and key permissions, ports below 1024, Docker volume mounts). SECURITY.md, .env.example and the Dockerfile describe the new settings and the loopback rule.

Testing:

  • CI's commands pass: cargo fmt --check, clippy with -D warnings, cargo test -p cve-core -p cve-server, and cargo check -p cve-intel-desktop.
  • tests/oauth.rs (19 tests) drives the real router with test keys (RSA, EC, Ed25519) and a rotated JWKS: valid and invalid tokens on MCP and REST, wrong issuer/audience/algorithm, an iss array, a crit header, expiry, scopes, roles, key rotation, a forged token carrying terminal escapes, an oversized JWKS, a slow issuer with stale keys, the /health split and the metadata document. Added in review: a client hanging up mid-refresh (Fazmin's repro: 401 before the fix, 200 after), a JWKS with ES512/X25519/ML-DSA/enc keys, held keys expiring after the max age, a moved jwks_uri, malformed nbf/exp, ID tokens, and CORS preflights needing no token. Fixtures and how they were generated are in tests/fixtures/README.md.
  • 19 unit tests in auth.rs cover config parsing (including partial configs, comma-only roles and empty required lists), role and scope matching across providers' claim shapes, ID-token markers, JWKS parsing and key selection, crit detection and the logging rules. 2 in jwt_crypto.rs sign with ring and verify all nine algorithms, plus tampered messages, wrong keys, wrong key types, HMAC and signing (refused). The integration tests capture the server's logs and fail if any rejected token, or any raw control or bidi character, appears in them.
  • tests/https.rs (4 tests) serves the real router over TLS to a rustls client that trusts only a test CA: MCP over HTTPS, no answer to plain HTTP, stalled handshakes not blocking other clients, a stalled handshake dropped at the limit, and a renewed certificate served without a restart (with a half-finished renewal kept on the old one). 5 unit tests in https.rs cover config parsing, ECDSA PKCS#8 and RSA PKCS#1 keys, mismatched, swapped, missing, oversized and encrypted files, and fail if malformed key files' contents appear in any error. Each test was seen to fail with the code it guards broken.
  • The built binary was also probed with openssl s_client and curl: TLS 1.0/1.1 refused, h2-only ALPN refused, full chains served, the startup fingerprint matching openssl's, a real renewal picked up within a minute, and a broken one warned about once.
  • tests/limits.rs (6 tests) runs the real connection loop with small limits: half a header, a silent connect and an idle keep-alive connection are closed (over plain HTTP and TLS), and with a cap of 2 a third client waits until one closes. Against the real binary, all three timeouts closed at 10.0 s.
  • config.rs tests the .env line parser with a table of cases (quotes, escapes, # rules, export, CRLF), checks that no warning ever contains a value, and fails if .env.example gains an inline comment or an active HOST line.
  • Each security test added in the review fixes was seen to fail with its fix reverted (13 reverts in auth.rs, plus the .env parser and the connection limits).
  • About 80 attack inputs were also tried through the router during review, and all were refused: alg:none, HS256 key confusion, embedded jwk/jku/x5u, malformed claims, path variants (/MCP, //mcp, /%6dcp, ..), header shapes, and 200 concurrent unknown kids (no extra JWKS fetches).
  • No network: the tests serve discovery and the JWKS from a local listener.

With CVE_INTEL_OAUTH_ISSUER and CVE_INTEL_OAUTH_RESOURCE set, every
request to /mcp and /api/v1 needs a bearer token. Tokens are JWTs checked
against the issuer's JWKS (from OIDC discovery of the configured issuer,
cached, refetched on unknown kid at most every 30 s): asymmetric algs
only, exact iss, aud containing the resource (or CVE_INTEL_OAUTH_AUDIENCE),
exp/nbf/iat, non-empty sub, and optional required scopes (scope or scp).
Failures get 401 with WWW-Authenticate: Bearer resource_metadata=...,
403 insufficient_scope, or 503 if keys can't load. RFC 9728 metadata is
served at /.well-known/oauth-protected-resource/mcp. Every other path
needs a token, including unknown ones, so a route added later can't be
public by accident. /health stays open but only answers {"status":"ok"};
the full report (an uncached NVD call, which keys are set) moves to
/api/v1/health. JWKS refreshes are serialised: a burst of unknown kids
waits for one fetch, at most every 30 s, and keys already held survive
a failed refresh. The key-fetching client fails closed if it can't be
built, and "use": "enc" keys never verify. Startup fails if discovery or the JWKS can't be loaded, or the
discovered issuer differs.

CVE_INTEL_OAUTH_REQUIRED_ROLES adds authorization: a user needs one of
the listed roles or groups (Keycloak realm or client roles, groups,
Entra app roles; exact matches), else a plain 403 access_denied that
doesn't name the allowed roles. Scope and role lists take spaces or
commas.

Without OAuth, serve now refuses a non-loopback HOST (Docker's 0.0.0.0
included) unless CVE_INTEL_ALLOW_UNAUTHENTICATED=1. serve logs rejection
reasons at info level; `--debug serve` logs the whole exchange
(discovery, JWKS, metadata served, each token's header and standard
claims, every check and the challenge sent). Each accepted request logs
who made it and each MCP tool call its tool, as an access log. Tokens
appear only as a SHA-256 fingerprint; the token, signature and
Authorization header are never logged, even when malformed (a bare
token or a tab separator), and a test fails if they are. Claim values
are logged escaped, and scopes are checked against RFC 6749's charset
because they are quoted into WWW-Authenticate.
The .env loader took everything after `=` as the value, and .env.example
puts a comment after nearly every key. Copying it to .env, as the README
says, set NVD_API_KEY and GITHUB_TOKEN to their comment text, pointed
CACHE_DB_PATH at a comment, and gave HOST a value serve refuses. On
CVE_INTEL_OAUTH_REQUIRED_ROLES the comment's words became extra allowed
roles, widening access.

parse_dotenv_line now ends an unquoted value at a `#` that starts it or
follows whitespace (`abc#def` keeps its `#`), ends a quoted value at its
closing quote, and skips lines whose key is empty or would make set_var
panic. docker run --env-file never strips comments, so .env.example now
puts every comment on its own line and leaves HOST/PORT commented out at
their defaults (an active HOST overrode the image's 0.0.0.0). A test
fails if the example gains an inline comment or an active HOST line.
- Escape claim text in the --debug log with clean_text. It is read
  before the signature check, and resource_access keys went out raw, so
  any client could write terminal escapes (OSC 52, line erase) to it.
- Require iss to be a single string; jsonwebtoken also accepts an array
  that contains the issuer.
- Refuse tokens with a crit header (RFC 7515 4.1.11), which jsonwebtoken
  ignores.
- Refuse a blank sub.
- Treat SCOPES_SUPPORTED or LEEWAY_SECS without ISSUER as a partial
  config error, like the other OAuth settings.
- Cap discovery and JWKS bodies at 1 MiB while streaming them.
- Once keys pass their TTL, a request with a known key uses them at once
  unless it is the one due to refresh, so a slow issuer delays at most
  one request per 30 s instead of every request.
- Docs: the exact algorithm list, leeway on exp and its 0-600 s range,
  and what --debug logs.

Each new test was seen to fail with its fix reverted.
OAuth providers and MCP clients expect an https:// resource URL, so
serve needed a TLS-terminating proxy in front of it. With
CVE_INTEL_TLS_CERT and CVE_INTEL_TLS_KEY set it now speaks TLS itself.

- rustls with the ring provider already in the tree (tokio-rustls added
  with ring only), TLS 1.2 and 1.3, ALPN http/1.1.
- Only one variable set, an unreadable file, an encrypted key or a key
  that doesn't match the certificate stops startup; there is no
  fallback to plain HTTP.
- Handshakes run in their own tasks with a 10 s limit, so stalled
  clients can't block the accept loop.
- The files are re-read every minute and a valid renewed pair is served
  without a restart; a broken pair is logged once and the old one kept.
- Key file contents never reach logs or errors, including the PEM
  parser's own error text, which can quote a line as a byte list.

Docs: README "Serving HTTPS" (certbot, permissions, ports, Docker),
SECURITY.md, .env.example and the Dockerfile.
@pkilar pkilar changed the title Add OAuth 2.1 resource-server auth to cve-intel serve Add OAuth 2.1 auth and HTTPS to cve-intel serve Nov 3, 2026
@pkilar

pkilar commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Fazmin,
OAuth support is ready; I've tested it wing Keycloak.
--PK

@Fazmin Fazmin left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks Paul, this is a big one and it's really well done. Keeping the server as a pure resource server is the right call, and I like that the auth layer wraps everything including the 404 fallback, so a new route can't end up public by accident. The algorithm allow-list, the exact iss check and failing closed at startup all look solid. The .env catch was a great find too, and it's good to know you've run it against a real Keycloak.

I'm happy with the loopback rule, even though it changes things for existing Docker setups. It's the safer default, and the error message tells people exactly what to do.

A few things I'd like sorted before merging. I reproduced 1 to 5 with quick tests against the router.

1. A client that hangs up can freeze the key set (auth.rs ~L596 and ~L620). The JWKS refresh runs inside the request's future, and last is stamped before the fetch. So every 30 s someone can send a token with a random kid and close the socket while the fetch is in flight. Hyper drops the handler, the refresh is cancelled, and last still blocks the next one for 30 s. No valid token is needed. In my test, a token from a newly rotated-in key got a 401 (200 without the disconnects), and a rotated-out key past its TTL kept working. Running the refresh in a detached tokio::spawn that waiters join would fix it.

2. REQUIRED_ROLES can let the wrong people in (auth.rs ~L188). It uses the scope parser, which splits on whitespace, but Keycloak role and group names can contain spaces. CVE_INTEL_OAUTH_REQUIRED_ROLES="CVE Admin,/Security Team" becomes CVE, Admin, /Security and Team, so anyone with a role called Admin gets in and the real role never matches. Separately, a value with only separators (,, say from a template that rendered empty) gives an empty list, which turns the check off, so any valid token gets in. AUDIENCE already refuses that. Could roles split on commas only, and could a set-but-empty list stop startup? That goes for REQUIRED_SCOPES too. The README (~L1080) would need updating as well, since it says spaces work.

3. jsonwebtoken 9.3 has a known advisory, and it reaches us through nbf (auth.rs ~L696). CVE-2026-25537 / GHSA-h395-gr6q-cpjc is fixed in 10.3.0: a claim of the wrong JSON type counts as absent. exp is safe because it's a required claim, but nbf isn't. A correctly signed token with a future nbf got a 401 as a number, and a 200 as a string or as 1e30. It does need the issuer to sign such a token, so it's not an open door. It's still a gap against the documented nbf check, and our audit job doesn't flag it, though other scanners will. As far as I can tell 10.x drops ring for rust_crypto or aws_lc_rs, so I can see why you're on 9.3. Either moving up or rejecting a non-integer nbf next to the iat check works for me, your call. If we stay on 9.x, a comment explaining why would help, since people will ask (it's a bit awkward for a CVE tool).

4. One key we can't parse fails the whole JWKS (auth.rs ~L526). It's parsed as a single JwkSet, so one extra key with alg: ES512, alg: ECDH-ES, crv: X25519 or alg: ML-DSA-65 gives "not a valid key set", even when the RS256 key next to it is fine. At startup, serve refuses to run. If the issuer adds such a key later, every refresh fails with just a warning and new keys get rejected. RFC 7517 section 5 says to ignore keys you don't understand, so reading keys as Vec<Value> and skipping the ones that don't parse as Jwk should do it.

5. There's no header timeout or connection limit (https.rs ~L375). The 10 s limit only covers the TLS handshake. axum::serve sets no hyper timer, so a client that sends half a header just stays connected. Mine was still open after 40 s. I tested that on plain TCP, but it's the same hyper builder after TLS. Since the token check only runs once headers are parsed, an unauthenticated client can hold sockets until file descriptors run out. This wasn't a big deal on loopback, but now that the server is documented for network exposure I'd like a header-read timeout and a connection cap in place.

6. The new .env parser quietly cuts some quoted values (config.rs ~L198). KEY='it's' becomes it, and PASS="pa\"ss" becomes pa\. The old loader kept the text in both cases. You've documented the # rule, but PASS=#abc123 going empty is just as quiet, and since this affects everyone's existing .env, a mangled credential would be hard to track down. Could we handle \" inside double quotes and print a warning (without the value) when something gets cut?

7. The Keycloak walkthrough doesn't work when pasted (README ~L1104). The variables are plain assignments on separate lines, so they never reach HOST=0.0.0.0 cve-intel serve. OAuth stays off and the server refuses to start, telling them to turn on the OAuth they just set up. export or \ continuations, like the HTTPS example, would fix it.

Two I'd like your take on:

  • ID tokens (auth.rs ~L690): typ is only logged, so nothing confirms it's an access token. With the default audience that's fine, but if CVE_INTEL_OAUTH_AUDIENCE is the app or client ID, as the README suggests for Entra, an ID token for that client passes every check. Requiring at+jwt won't work, since as far as I know most providers don't send it by default. Could we refuse tokens carrying nonce or at_hash, or Keycloak's typ: "ID"?
  • How long stale keys are trusted (auth.rs ~L630): if refreshes keep failing, held_key trusts cached keys indefinitely, including one the issuer has withdrawn. I agree with keeping them through a short outage, but would a limit of a few TTLs, then 503, make sense?

Smaller things, none blocking:

  • Startup only checks that the JWKS has some key. A set with only use: enc keys passes and logs "1 signing key(s)", then every token gets a 401, and a normal Keycloak realm is reported as 2 signing keys (auth.rs ~L532, rest.rs ~L195). Counting with the same filter as find_key and failing on zero would fix both.
  • The discovered jwks_uri is cached for the life of the process (auth.rs ~L502), so if the issuer moves it, refreshes fail until a restart. Clearing it after a failed fetch would help.
  • serve now logs at Info with rustls allowed (cli.rs ~L706), so rustls's warn! lines for things like an illegal SNI show up by default, unlike the --debug only comment in https.rs. Scanners could fill the log with those.
  • claims_summary does t - now on an unverified claim (auth.rs ~L830). With --debug in a build with overflow checks, exp: i64::MIN panicked before any signature check. saturating_sub fixes it.
  • auth_scheme logs any first word up to 16 letters as the scheme (auth.rs ~L795), so a short alphabetic API key sent bare would land in the debug log. An allow-list of known schemes would close that.
  • Tidying: is_loopback_url duplicates the one in agent.rs ~L926, require_token calls bearer() twice and each request computes fingerprint() twice. The OPTIONS bypass also looks unreachable, since CorsLayer answers OPTIONS first.

Really nice work, especially for something this size. Looking forward to the next round!

A client that hung up while a JWKS refresh was in flight cancelled it
with the refresh time already stamped, so for 30 s no refresh could run
and a newly rotated-in key was refused. The refresh now runs in its own
task that holds the refresh lock until it finishes; requests only wait
on it. Other key-handling fixes:

- The JWKS is parsed key by key, and keys of an unknown type or
  algorithm are skipped (RFC 7517 section 5) instead of failing the set.
  Startup counts only keys that can verify tokens and fails on none.
- Held keys are trusted for at most 4 h while refreshes fail, then
  requests get 503. A failed fetch from a discovered jwks_uri runs
  discovery again and retries at once if it moved.
- jsonwebtoken goes from 9.3 to 11.1 (GHSA-h395-gr6q-cpjc: a string or
  out-of-range nbf counted as absent). Its signature checks use ring
  through a verify-only provider (jwt_crypto.rs), since the built-in
  backends would add aws-lc or the rsa crate (RUSTSEC-2023-0071).
- ID tokens are refused: a Keycloak typ other than Bearer, a Cognito
  token_use other than access, or a nonce/at_hash/c_hash/s_hash claim.

Configuration and logging:

- REQUIRED_ROLES splits on commas only, since role and group names may
  contain spaces. REQUIRED_ROLES or REQUIRED_SCOPES set but empty stops
  startup instead of turning the check off.
- The debug log names only known Authorization schemes, and
  claims_summary no longer overflows on extreme times.
- Without --debug, serve shows only its own log records; rustls, hyper
  and reqwest lines need --debug.
- is_loopback_url moves to cve_core::validators. require_token parses
  the header and computes the fingerprint once, and the OPTIONS bypass
  is gone: CorsLayer answers every OPTIONS request first.

serve now runs its own hyper HTTP/1.1 loop, plain or TLS, with a 10 s
header-read timeout and at most 512 connections, counted from accept,
so slow or silent clients can't exhaust file descriptors before the
token check runs.

The .env parser no longer cuts values silently. Inside double quotes,
\" and \\ are escapes; a quoted value ends at the first closing quote
followed by nothing, whitespace or a comment; # starts a comment only
after whitespace; export is accepted. Any cut text or ignored line
prints one warning naming the line and key, never the value.

README: the Keycloak walkthrough uses export so its settings reach
serve.
@pkilar

pkilar commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Fazmin, that's a thorough review, and the repros made it easy. Everything is addressed in the new commit. Item by item:

  1. Hung-up client freezing the key set. The JWKS refresh now runs in its own task, which holds the refresh lock until it finishes, and requests wait on that task. A client hanging up only drops its own wait. A test reproduces your attack: it starts a refresh with a random kid, hangs up mid-fetch, then sends a token from the newly rotated-in key. That token gets a 401 with the old code and a 200 now.
  2. REQUIRED_ROLES. Roles now split on commas only, so CVE Admin,/Security Team is two entries. If REQUIRED_ROLES or REQUIRED_SCOPES is set but empty ("", ,, whitespace), serve refuses to start. README and .env.example are updated.
  3. jsonwebtoken advisory. I moved to 11.1. Its built-in backends are aws-lc-rs (a second native crypto library) and RustCrypto, whose rsa crate fails cargo audit on RUSTSEC-2023-0071. So I added a small verify-only provider on ring instead (jwt_crypto.rs, about 100 lines). Its unit test checks all nine algorithms against tampered messages, wrong keys and wrong key types. A string or 1e30 nbf/exp now gets a 401 "malformed exp or nbf". The provider can't sign, so the tests sign with ring directly. use_pem is gone, which drops four crates. OSV shows no advisories for 11.1, and cargo audit is unchanged.
  4. One unparseable key failing the JWKS. Keys are now parsed one at a time, and any that don't parse are skipped (RFC 7517 §5). Tested with ES512, ECDH-ES/X25519, ML-DSA-65 and enc keys next to the RS256 one.
  5. Header timeout and connection cap. serve now runs its own hyper http1 loop for both plain HTTP and TLS.
    • Header-read timeout: 10 s. It covers a half-sent header, a connect that sends nothing, and an idle keep-alive connection.
    • Connection cap: 512, taken before accept(), so connections still in the TLS handshake count.
    • At the cap, serve stops accepting and the kernel backlog holds the rest. A warning is logged at most once a minute.
    • Measured against the real binary, all three timeout cases close at 10.0 s.
  6. .env parser. Values are no longer cut silently.
    • Inside double quotes, \" and \\ are escapes; single quotes are literal.
    • A quoted value ends at the first closing quote followed by nothing, whitespace or a comment, so 'it's' stays it's.
    • # starts a comment only after whitespace, so PASS=#abc123 keeps its value.
    • export KEY= is accepted.
    • Any cut text or ignored line prints a warning once, naming the line and key, never the value. A test checks that.
  7. Keycloak walkthrough. It uses export now.

Your two questions:

  • ID tokens. Agreed. A token is refused as "not an access token" when any of these hold:

    • it has a Keycloak typ claim other than Bearer;
    • it has a Cognito token_use other than access;
    • it has neither of those, and has a nonce, at_hash, c_hash or s_hash claim.

    When Keycloak's typ claim is present, it decides on its own, because Keycloak before 24 also put nonce in access tokens.

  • Stale keys. Agreed. Held keys are trusted for 4 h (four TTLs) while refreshes fail, then requests get 503 until the issuer is back.

Smaller things:

  • Signing-key count: keys are now counted with the same filter find_key uses (no use: enc, no symmetric keys, alg allow-listed). A set with zero usable keys fails startup, and Keycloak now reports 1 signing key.
  • Moved jwks_uri: a failed fetch from a discovered jwks_uri re-runs discovery and retries at once if the URL moved.
  • Logging: without --debug, only cve-intel's own log lines show. rustls, hyper and reqwest lines need --debug.
  • Overflow: claims_summary uses saturating_sub. A test with exp: i64::MIN panicked before.
  • Scheme logging: auth_scheme now only names schemes from an allow-list (IANA plus a few common ones).
  • Tidying:
    • is_loopback_url moved to cve_core::validators, shared with agent.rs.
    • bearer() and fingerprint() now run once per request.
    • The OPTIONS bypass is gone. You were right: tower-http's CorsLayer answers every OPTIONS request itself, and there's a test that preflights still need no token.

Each new test was checked by reverting its fix: all 13 reverts made a test fail.

--PK

@Fazmin
Fazmin merged commit 20c6958 into Fazmin:main Nov 7, 2026
5 checks passed
@Fazmin

Fazmin commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Merged, thanks so much Paul! That was a huge piece of work and a really careful round of fixes. Writing the small ring provider instead of pulling in a second crypto stack was a nice call, and the hang-up test is exactly what I was hoping for.

I'll call out the new loopback rule in the release notes, so Docker users know why their container stops starting and what the one-line fix is.

Two small things to keep in mind for later, no rush at all:

  • The ID token check can't catch one that carries none of those markers, for example an Entra ID token with no nonce when the audience is set to the client ID. It's a big improvement as it stands, but it may be worth a line in the README for that setup.
  • The 10 s limit also closes idle keep-alive connections, and a load balancer that keeps them open longer (ALB defaults to 60 s) can occasionally hit a closed one and return a 502. Making the limit configurable might be useful if anyone runs into that.

Really appreciate everything you've contributed here. Looking forward to whatever you send next!

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.

2 participants