Skip to content

fix(deps): bump rustls to 0.23.45 for RUSTSEC-2026-0285 - #238

Merged
alukach merged 1 commit into
mainfrom
fix/rustls-rustsec-2026-0285
Sep 25, 2026
Merged

alukach merged 1 commit into
mainfrom
fix/rustls-rustsec-2026-0285

Conversation

@alukach

@alukach alukach commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

What I'm changing

cargo audit fails on main, and so the Security Audit check fails on every open PR, including the machine-identity PRs (#232, #235). The cause is a new advisory against rustls 0.23.42, RUSTSEC-2026-0285: TLS 1.3 handshake messages were accepted across encryption-level boundaries. It is patched in 0.23.45. This bumps the lockfile to it.

How I did it

cargo update -p rustls --precise 0.23.45. A plain cargo update -p rustls stops at 0.23.43; the precise bump moves aws-lc-rs to 1.18.1, aws-lc-sys to 0.45.0 and rustls-webpki to 0.103.15 with it. Cargo.lock only.

rustls never reaches the Worker. cargo tree -i rustls --target wasm32-unknown-unknown prints nothing; natively it comes in through multistore → reqwest → hyper-rustls, which the native tests use. Production was not exposed.

How to test it

  • cargo audit: no vulnerabilities, with the one allowed warning (chacha20) CI already allows.
  • The pre-commit hook: cargo fmt --check, cargo clippy --target wasm32-unknown-unknown -- -D warnings, cargo check --target wasm32-unknown-unknown and cargo test, all passing.

PR Checklist

  • This PR has no breaking changes.
  • I have updated or added new tests to cover the changes in this PR. (None apply: lockfile only.)
  • This PR does not affect the Source Cooperative Frontend & API.

Related Issues

Unblocks the Security Audit check on #232, #235 and the PRs stacked on #235 (#236, #237); their pull-request runs check out the merge with main, so a re-run passes once this lands. #220 would catch the next one on a schedule. Part of source-cooperative/source.coop#491 only in that it clears CI for it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

cargo audit fails on main: rustls 0.23.42 accepted TLS 1.3 handshake messages across encryption-level boundaries (RUSTSEC-2026-0285, patched in 0.23.45). rustls reaches the lockfile only through the native build's reqwest, which the tests use; the Worker's wasm build fetches through the runtime, so production was not exposed. The bump brings aws-lc-rs 1.18.1, aws-lc-sys 0.45.0 and rustls-webpki 0.103.15 with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd
@claude

claude Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 32s —— View job


Review complete

  • Gather context (PR body, diff)
  • Review Cargo.lock diff for correctness/security issues
  • Apply ponytail-review pass
  • Post final findings

✅ No blocking issues — safe to merge.

This is a mechanically-generated Cargo.lock-only diff (cargo update -p rustls --precise 0.23.45), pulling in the coupled bumps to aws-lc-rs 1.18.1, aws-lc-sys 0.45.0, rustls-webpki 0.103.15, and windows-sys 0.61.2 for a couple of transitive deps. Verified:

  • rustls isn't a direct dependency in any Cargo.toml — it's pulled in transitively (native-only, via reqwest/hyper-rustls), matching the PR's cargo tree finding that it's absent from the wasm32-unknown-unknown target and never reaches the Worker.
  • The lockfile stays internally consistent — two windows-sys versions (0.52.0 and 0.61.2) coexist, which is normal when different transitive deps pin different majors.
  • The version bump directly addresses RUSTSEC-2026-0285 (TLS 1.3 handshake messages accepted across encryption-level boundaries), patched upstream in 0.23.45.

No code changes, no new dependencies, nothing to simplify.

Simplify (ponytail): nothing to flag — pure lockfile bump, no hand-written code in the diff.


💰 Estimated review cost: $0.14 · 0m31s · 9 turns

@github-actions

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-238.source-coop.workers.dev

  • Date: 2026-09-25T21:38:51Z
  • Commit: 13251f9

@alukach
alukach marked this pull request as ready for review September 25, 2026 22:55
@alukach
alukach merged commit 188842b into main Sep 25, 2026
23 checks passed
@alukach
alukach deleted the fix/rustls-rustsec-2026-0285 branch September 25, 2026 22:55
alukach added a commit that referenced this pull request Sep 30, 2026
Supersedes #233. Implements the proxy half of API keys as recorded in
the revised ADR-013 (#234, stacked on #232). Pairs with
source-cooperative/source.coop#580 (into
source-cooperative/source.coop#570's branch), which serves the route
this calls. Closes #231. Part of source-cooperative/source.coop#491.

## What I'm changing

A service account's API key is an opaque `sck_` secret, 30 random base62
characters and a six-character CRC-32 checksum of them (ADR-013, #242),
that source.coop stores only as a SHA-256 hash. Nothing signs it.
`/.sts` now accepts one as `WebIdentityToken` and resolves it by asking
source.coop:

- **Body only.** A key is read from the POST form body. One in the query
string is refused before any lookup with `API key must be sent in the
request body, not the URL (request id …)`, because Cloudflare logs
request URLs. A JWT in the query string still goes to the STS route as
before.
- **Local checks first.** Trim surrounding whitespace, since every
hand-made token file ends in a newline, then require exactly `sck_` + 36
base62 characters whose last six are the CRC-32 of the thirty before
them (IEEE, as zlib computes it, written out in a few lines rather than
a crate). Anything else with the `sck_` prefix is refused without a
lookup.
- **One lookup, cached.** `POST
{SOURCE_API_URL}/api/v1/service-account-keys/exchanges` with
`{"key_hash"}`, authenticated as the proxy itself (sentinel subject
`urn:source:data-proxy`, the one route ADR-005 now lets the proxy call
as itself). The route always answers 200: `{account_id, key_id, active:
true}` or `{active: false}`. Both are cached for 60 seconds, so
revocation takes effect within a minute and an unknown key costs one
lookup a minute. A non-200 or network failure fails closed as a 500
`InternalError`, which SDKs retry, and caches nothing.
- **One refusal for the key, and one for a mangled one.** A key that
fails its shape or checksum was cut short or mistyped, and reads
`InvalidIdentityToken: API key is malformed; check that it was copied
whole (request id …)`; the format is public, so this reveals nothing,
and it still counts against the rate limit. Unknown, revoked, expired
and disabled all read `InvalidIdentityToken: API key was not accepted
(request id …)`, with the id also in `x-amzn-requestid`. The id is in
the message because SDKs show the user nothing else. The proxy logs one
WARN line per refusal with the reason it knows (`malformed`, `inactive`,
`query_string`, `rate_limited`) plus the key id and an 8-hex hash
prefix; source.coop logs which of unknown, revoked, expired or disabled
under the same request id.
- **Minting.** Credentials for the named account under the `_default`
role, sealed like every other session, with the same 900s floor, 3600s
default and `STS_MAX_SESSION_DURATION_SECS` cap. `RoleArn`'s account
segment is ignored, as for an Ory token. `ReadOnly`/`FullAccess` arrive
with #221.
- **Rate limit.** A new `KEY_EXCHANGE_LIMIT` ratelimit binding, 100
attempts a minute per client IP, in every wrangler config. A client
exchanges about once a session, so a cluster behind one NAT stays far
under it. A deployment without the binding logs an error and does not
refuse traffic.

**Decisions to flag**

- **The limit applies to every `sck_` attempt, not only cache misses.**
The cache sits inside the fetch helper, and at 100 a minute per IP the
distinction makes no difference to legitimate traffic. ADR-013's wording
is updated in #234 to match.
- **No `<RequestId>` element in the STS error XML.** That lives in
multistore-sts's `build_sts_error_response`; the header plus the message
cover what SDKs surface, so no upstream change is needed now.
- **`namespace_id`s `1001` (production), `1002` (staging) and `1003`
(previews)** for the ratelimit binding. They only need to be unique
within the Cloudflare account.
- **CodeQL flags `key_hash` as weak password hashing (`src/keys.rs`),
and it should be dismissed as a false positive.** The rule matches on
the name: it takes the key for a password. A key is 256 random bits, so
a slow hash or salt adds nothing, and the lookup is a key get, so there
is no comparison to time. This is how GitHub stores its own tokens, and
ADR-013 (#234) records it so nobody later "fixes" it to bcrypt. I have
not dismissed the alert; that is the repo owner's call.
- **Security Audit.** It failed here because `main`'s lockfile carried a
rustls advisory (RUSTSEC-2026-0285). #238 fixed that on `main`, and this
branch is rebased onto the fix.

## How I did it

- `src/keys.rs` (new, wasm-free): `parse_api_key`, `looks_like_api_key`,
`key_hash`, `KeyStanding`, `credentials_for`.
- `src/lib.rs`: `api_key_exchange` runs after `ApiAuth` is built and
before the router, and returns `None` for anything that isn't an `sck_`
exchange so the STS route handles it unchanged. `exchange_api_key` does
the lookup and minting; `key_refusal` builds the uniform refusal;
`finish` adds CORS and the request-id headers to a pre-gateway response;
`within_rate_limit` wraps the binding.
- `src/source_api/auth.rs`: `PROXY_SELF_SUBJECT`, `ApiCaller`
(anonymous, an account, or the proxy), `authorization_header_as_self`;
`authorization_header` refuses the sentinel so no request can claim it.
- `src/source_api/cache.rs`: `get_or_fetch_key_standing`; `cached_fetch`
takes a method, an optional JSON body and an `ApiCaller`. The cache key
is `{api_url}?key_hash={hash}`, so it is a URL, as the Cache API
requires, and is scoped to the environment's API.
- `src/sts.rs`: `default_role` factored out of
`StsCredentialRegistry::new`.
- `wrangler.toml`, `wrangler.preview.toml`: the binding per environment.
`README.md`: a Bindings table and an API keys section.

From #233 this keeps `finish`, the shape of
`api_key_exchange`/`exchange_api_key`, `credentials_for` and the method
argument to `cached_fetch`. It drops `/.keys`, minting,
self-verification against the proxy's own JWKS, the `api_key` role,
`chrono`/`rsa` and the `[patch.crates-io]` pin on multistore;
developmentseed/multistore#147 is not needed.

## How to test it

- `cargo test`: all suites, including the new `tests/keys.rs` (a key's
shape, trimming `\n` and `\r\n`, rejection of short, long, wrong-case,
mistyped, wrong-checksum, non-base62, checksum-less 47-character and JWT
tokens, checksum vectors computed independently with Python's zlib (a
CRC above 2^31, one with a leading zero), assembled with `concat!` so
scanners don't flag the file, the SHA-256 test vector, and credentials
sealed for the account within floor, default and cap). Run by the
pre-commit hook, along with `cargo clippy --target
wasm32-unknown-unknown -- -D warnings` and `cargo check --target
wasm32-unknown-unknown`.
- `pytest tests/test_api_keys.py` against `wrangler dev` and
`tests/stub_api.py`, which gains the exchanges route keyed by the hash
of fixed test keys, with a per-hash call counter. Nine tests, all
passing locally with wrangler 3.114: a live key exchanges; **an
unmodified boto3, configured only by `AWS_ROLE_ARN`,
`AWS_WEB_IDENTITY_TOKEN_FILE` (a file holding the key and a trailing
newline) and `AWS_ENDPOINT_URL_STS`, acquires credentials**; the second
exchange within 60s never reaches the API; a trailing newline is
harmless; unknown and revoked keys get byte-identical refusals apart
from the id, and the refusal is cached; a key in the query string is
refused without a lookup; malformed keys, including a mistyped one whose
checksum fails, are refused without a lookup and with the malformed
message; a wrong role is reported as such; an API 500 fails closed and
is not cached. `test_control_plane.py` and `test_writes.py` still pass;
their credentialed tests need CI's GitHub token and were skipped
locally. The checksum commits (d6745e0, 30ad22f) were run by CI's
Integration Tests job, which exchanges the new-format keys against the
worker; it passed on both.
- End to end, once source.coop#580 is on a deployment this preview
points at: issue a key from a service account's page, save it to a file,
then

  ```sh
AWS_WEB_IDENTITY_TOKEN_FILE=./key
AWS_ROLE_ARN=arn:aws:iam::000000000000:role/_default \
AWS_ENDPOINT_URL_STS=https://<preview>/.sts
AWS_ENDPOINT_URL_S3=https://<preview> AWS_REGION=us-east-1 \
    aws s3 ls s3://<owner>/<product>/
  ```

Revoke the key and see the next exchange refused within 60s, then quote
the printed request id to find the proxy's and source.coop's log lines.
Not run here: it needs source-cooperative/source.coop#580 deployed.

## PR Checklist

- [x] This PR has **no** breaking changes. (JWT exchanges at `/.sts` are
unchanged; the new binding is additive.)
- [x] I have updated or added new tests to cover the changes in this PR.
- [x] This PR affects the [Source Cooperative Frontend &
API](https://github.com/source-cooperative/source.coop), and I have
opened issue/PR source-cooperative/source.coop#580 to track the change.

## Related Issues

Closes #231. Supersedes #233. ADR: #234 (revises ADR-013, amends
ADR-005). Route: source-cooperative/source.coop#580, into
source-cooperative/source.coop#570. Epic:
source-cooperative/source.coop#491.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
preview — 0e5fa468 Deployed Sep 25, 2026 by alukach via Deploy & Test / Deploy #363
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant