Repository navigation
fix(aster-srv): 0.24.1 security fixes N1-N15 from the second review - #191
Merged
Merged
Conversation
…ng the body A non-writer CI token (an in-repo pull_request job) on a CiWriter route passed the gate, and the handler read and parsed up to 64 MiB before require_writer refused it. The gate now applies the route's writer rule (cache: ref, push event, job_workflow_ref prefix; test results: the results rule) before any body is read, using the same policy functions the handlers use. A semaphore (ASTER_SRV_MAX_LARGE_BODIES, default 8) bounds concurrent large bodies (commit, test-results upload) and answers 503. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 0c19614)
…a per-IP cap POST /v1/auth/token and POST /cli-auth read their 8 KiB body under the global 60s body timeout, and a listener allowed 1024 connections with no per-IP cap. They now use ASTER_SRV_UNAUTH_BODY_TIMEOUT_SECS (default 8s). One client IP, resolved by limits::client_ip, holds at most ASTER_SRV_MAX_CONNECTIONS_PER_IP (default 128) requests in flight, and with trusted_proxy_hops=0 also that many open connections. A test confirms header_read_timeout closes an idle keep-alive connection. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit c1e0e82)
…ning settings With ASTER_SRV_API_ADDR set the server now refuses to start when static tokens or the dev blob route are configured, when any allowlisted repository lacks a repository_owner_id pin, when the writer-workflow list is unset, or when trusted_proxy_hops is 0, and names every missing setting. OwnerIds also fails closed for an unpinned repository when the listener is on. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit bfefa1a)
…weep When a limiter's table filled, every newcomer shared one overflow bucket, so a flood of minted keys starved real clients, and each new key scanned the whole table. Overflow is now 256 buckets by a per-limiter keyed hash and the idle sweep runs at most once a second. The subject limiter has its own table, so unauthenticated floods never spend an authenticated subject's allowance. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit c7894d2)
… real key rotation Both verifiers stamped the refetch time before the JWKS request, so one failed fetch or one unknown-kid probe refused a real rotation for 60 seconds. Refetch rules now live in keyfetch: any fetch spaces the next by 10 seconds, a key a successful fetch did not find is not fetched for again for 60 seconds, and the remembered set is bounded. The auth.rs change is confined to the fetch and backoff code in Verifier::key. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit d507daf)
GET /v1/test-results/runs/:run_id/failures replied 403 naming the owning repository when the run belonged to another repository, which confirmed the id existed and whose it was. It now returns the same 404 as a missing run. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 5344495)
A 401 flood wrote one audit line (and one 'token refused' warning) per request. The first ASTER_SRV_LOG_SAMPLE_FIRST (5) per event, reason, route and client IP in each window (60s) are logged and the rest become one audit_summary line per key when the window ends. Counters are bumped before sampling and stay exact; token_mint, successful exchanges and token_revoke are never sampled. The sampler table is bounded. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 73e2563)
…and test the trusted hop A request may carry several X-Forwarded-For lines. `limits::forwarded_for` joins them in order into one list (RFC 9110), and `limits::client_ip` then takes the entry ASTER_SRV_TRUSTED_PROXY_HOPS from the right, so a spoofed prefix or an extra leading line cannot move the client. Unit tests pin the join and the per-hop walk; an end-to-end test checks the audited client_ip for one and two trusted hops across single and repeated lines. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 17acfd4)
… and test its SQL A person removed from IAP keeps a token that works for up to 12 hours, and only the person could revoke it (`aster cache logout`). The README now has the operator path, with no new route: find the stable IAP subject from the token_mint audit line, then run one psql snippet that upserts auth.revocations the same way logout does. tests/operator_revoke.rs reads that snippet from the README between its markers, runs it against Postgres, and checks the person's tokens get 401 on a /v1 read while another person's token still gets 200, and that a second run is harmless. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 19490a5)
… the kill switch The README said only "add a new first pair, wait, drop the old one". It now has a rotation section: the kid:secret list format (the first signs, all verify), key requirements and how to generate one, planned rotation (add the new kid first, wait out the 12-hour token lifetime, drop the old kid), emergency rotation (replace the list with one new kid, which signs everyone out), and the ASTER_SRV_PERSON_TOKENS=on|off kill switch, which is also added to the configuration table. The deploying repository sets the keys. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 8c502ef)
Earlier commits on this branch failed CI's lint job: a private intra-doc link from N1 (cache_writer_allowed linked to ci_reader), two format! calls in N3's hardening tests that newer clippy rejects (format! inside format! args, a format! with no arguments), and a nonminimal_bool in N9b's KidRefetch::may_fetch that clippy flags on stable and 1.88. No behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit b26cd2c)
A pull_request CI token could bump hits, last_hit_at and last_used_at on every lookup and so keep entries alive past retention. Lookups now mutate the manifest and blob rows only when Reader::may_write is true (the same policy.rs decision that gates commit). Aggregate lookup_stats still record every CI lookup as a hit-rate view. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 3e3ff7f)
BlobStore gains readable_url for lookups: a positive HEAD is remembered for an hour (blobs are content-addressed; misses are never cached), a signed GET URL is reused while 5 minutes of its 15 remain, and concurrent callers of one digest share a single HEAD and signature through a per-digest lock. HEADs share a global semaphore, and every signBlob goes through a semaphore plus token bucket (SignLimiter) that fails fast with "signing is busy" rather than queueing. delete drops the remembered digest. Tuning lives in FanoutConfig; the route is switched over in the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit a1b664f)
lookup_hits did a sequential HEAD plus signBlob per blob (256 keys, no cap), which grew with bucket size and ran past the 0.22 client's 5s timeout. Now: manifests are read in one SELECT; at most MAX_LOOKUP_BLOBS (4096) distinct blobs are admitted in request order and an entry that does not fit is a miss; blobs resolve 16 at a time through BlobStore::readable_url; a blob whose HEAD or signature fails or is busy makes its entry a miss, not a 500; hits, last_hit_at and blob last_used_at are updated in two batched statements for only the entries served. Measured with 64 keys x 8 blobs, 20ms HEAD, 10ms sign: 17.8s cold and 17.9s repeat before, 1.1s cold and 0.03s repeat after. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 6059702)
The blob cap and per-request concurrency bound the work but not the reply time: a cold lookup at the cap (4096 blobs, 20ms HEAD, 10ms sign, signBlob held to 200/s) still ran for many seconds, past the 0.22 client's 5s request timeout, which then loses every hit in the batch. Blob resolution now stops at FanoutConfig::lookup_budget (3s); an entry with a blob not resolved by then is a miss and is not counted, and the in-flight HEADs and signatures are cancelled. The resolved blobs are remembered, so a repeat gets further. A blob missing from the resolved set is now always treated as a failure, never as served. The cap test now lifts the signing rate and budget so it tests only the cap. The README documents the cap, budget, remembered answers and the per-replica signBlob and HEAD limits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 061c044)
The N13 test now also has a signed-in person look the key up and checks that hits, last_hit_at and last_used_at stay put. The README states that only a writer-eligible CI token's lookup counts hits and refreshes last_used_at (the commit writer rules), while every CI lookup still feeds cache.lookup_stats. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit 6b1a27a)
…oopback The sign-in already refused plain http to another host; the CI cache backend, test-result uploader, reads, quarantine, balance and summary did not and built agents with https_only(false). They now share srv_url::checked_origin, refuse a non-loopback http URL before any request, use https_only agents, and a loopback http agent follows no redirects, so a redirect cannot downgrade or leave the machine. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit fe090aa)
…st sharing codes The confirm page now says the device name is reported by the requesting computer and not verified, and labels the address and user agent as the browser's own. Copy mode warns on the confirm page, on the code page (never send this code) and in the CLI prompt (never paste a code someone sent you). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit f1ffa25)
Ingest binds an upload's run id to the job's GitHub run and attempt; the attempt was not read from the token. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit acab0f9)
Ingest checked only the repository. The run id (the runs table's global key), sha, branch and pr were the client's to choose, so a job could take a later run's id first (its real upload then became a "duplicate"), file results under another repository's id, or claim any commit and branch. For OIDC tokens the run id must start gh:<repo>:<run_id>:<run_attempt>: for the token's own repository, run and attempt (the shape Aster's client builds); sha must match the token's; branch, pr and attempt come from the ref and run_attempt claims. A static token's gh: ids must name its own repository, and its other ids are stored as static:<repo>:<id>, so no two repositories' ids can collide. Migration v7 records each run's source (oidc or static) and its ref and event_name claims for the classifier to trust instead of the record. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit a8d3cc9)
…dence Quarantine entered on definite flakes from any branch, so a pull request job (which runs its author's code with a genuine token) could upload two forged flips and quarantine a test, or upload a pass on the inputs of a real main failure and make that failure count as a flake. A run is on the default branch by its credentials: for an OIDC upload, ref refs/heads/<default> and event push, workflow_dispatch or schedule (a pull request's record may say main; pull_request_target runs on the default ref for a pull request); for a static token, its branch. Runs stored before sources were recorded are never trusted. Likely events and broken streaks use the same test. Each flake event records whether both of its runs are trusted, and only trusted events count toward quarantine and its run cap. Pull request flakes still score. A trusted run is also compared with the nearest earlier trusted run of its inputs, so pull request runs in between cannot hide a flake main saw itself, and a trusted comparison found later upgrades an untrusted one. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> (cherry picked from commit f3ff547)
…ode enforces Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
calvin-archastro
marked this pull request as draft
October 23, 2026 23:37
The dashboard e2e uploaded three runs with ids e2e-run-N under one OIDC token. N2 now requires an OIDC upload's run id to start with gh:<repo>:<run_id>:<run_attempt>: and its sha to match the token, so the uploads were refused with 403. Each run now has its own token (run_id, run_attempt, sha) and a matching run id; the run-failures read percent-encodes the id's '/'. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
calvin-archastro
marked this pull request as ready for review
October 23, 2026 23:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review on ArchCode
Merge order: this is the only 0.24.1 PR. After it merges, a version-bump PR, then tag
v0.24.1. v0.24.0 will not be deployed.Problem and intent
The second independent security review of v0.23.1..v0.24.0, plus firstlanding #19856, returned a conditional GO with no critical or high findings. Calvin asked for every finding to be fixed before aster-srv's
/v1channel goes live without IAP. This PR fixes N1 and N2-N15 on top of v0.24.0, one commit per finding (or a small group), so it can be reviewed commit by commit.The two medium findings that matter most:
pull_requestjob, still made the server read and parse up to 64 MiB on/v1/cache/commitbeforerequire_writerrefused it.lookup_hitsran a GCS HEAD and an IAMsignBlobone blob at a time for up to 256 keys. Reply time grew with the bucket. This is also the likely server-side cause of the observed remote-cache read misses: the 0.22 client times out after 5s and, before feat(cache): one-line remote cache summary; batch lookup timeout no longer disables the remote #183, silently turned the remote off for the whole run (Stable CI job 120151277785: 0 cached of 127 targets whose keys hadn't changed).What changed (before → after)
event_name=push,ci.yml@prefix) before reading any body, using one shared function,policy::cache_writer_allowed. A semaphore bounds concurrent large-body requests.gh:<repo>:<run>:<attempt>:prefix, sha must match the token, branch/attempt taken from the claims). Quarantine uses only trusted default-branch evidence. Migrationtest_resultsv7.header_read_timeout(tested)ASTER_SRV_API_ADDRset, the server refuses to start on static tokens, the dev blob route, an unpinned allowlisted repo, an unset writer-workflow list, ortrusted_proxy_hops=0. The owner check fails closed.signBlobper blobsignBlobbound (16 in flight, 200/s, burst 512, fail fast)aster srvclients acceptedhttp://to any hostchecked_origin: https unless loopback. Agents are https-only, and loopback agents follow no redirects.ASTER_SRV_CLI_TOKEN_KEYS;ASTER_SRV_PERSON_TOKENSadded to the env tablelastand suppressed a real rotation for 60slastis set only on success; failures back off separately (keyfetch::may_fetch, in both auth.rs and iap.rs)pull_requestlookups bumped hits and last-usedUnchanged: the CI OIDC write path for main pushes, the person read allowlist, the accepted risks recorded in #190, and the client cache protocol.
Also
12e7198: theASTER_SRV_CLI_TOKEN_TTLdoc comment said 604800s; the code caps it at 43200.Security checklist
Paths are under
crates/aster-srv/unless they start withsrc/(the client crate).src/routes.rswriter_refusal;src/cache/policy.rscache_writer_allowed;src/http.rslarge_body_permittests/routes.rs:a_non_writer_ci_token_is_refused_before_its_body_is_read,the_large_body_semaphore_bounds_concurrent_commits_and_uploadssrc/auth.rsrun_attempt;src/test_results/ingest.rsbind; migrationtest_resultsv7;src/test_results/classifier.rson_default/trusted;src/test_results/quarantine.rstests/server.rs,tests/incidents.rs,tests/classifier_model.rs,tests/quarantine.rs,tests/cli_auth.rs(tab 7)src/http.rsunauth_body_timeout;src/limits.rsConcurrency(per-IP cap)tests/hardening.rs:unauthenticated_bodies_time_out_in_seconds_while_authenticated_ones_keep_the_long_timeout,an_idle_keep_alive_connection_is_closed_after_the_header_timeout,one_client_ip_cannot_hold_more_than_its_share_of_requests_open,without_a_proxy_one_peer_cannot_hold_more_connections_than_its_share; unitlimits::a_key_holds_at_most_its_cap_and_a_dropped_slot_frees_itsrc/config.rsAPI-listener checks;OwnerIds::acceptsfails closedconfig::the_api_listener_refuses_to_start_without_each_hardening_setting,every_allowlisted_repository_needs_an_owner_id_pin_when_the_api_listener_is_on,owner_ids_fail_closed_for_an_unpinned_repository_when_the_api_listener_is_on;tests/hardening.rs:the_api_listener_refuses_tokens_without_the_pinned_owner_idsrc/cache/routes.rsMAX_LOOKUP_BLOBS, per-request concurrency and budget;src/cache/store.rsreadable_url, existence memory, digest cap;src/cache/signing.rssignBlob boundtests/lookup_fanout.rs:entries_over_the_blob_cap_are_misses_and_are_not_counted,a_wide_lookup_finishes_in_bounded_time_and_a_repeat_is_free,a_slow_lookup_answers_within_its_budget_and_the_rest_are_misses; store unitsa_warm_digest_costs_no_head_and_no_signature,a_missing_blob_is_never_cached,a_remembered_blob_is_asked_about_again_after_its_ttl,delete_forgets_a_remembered_blob,a_url_without_enough_lifetime_left_is_signed_again,concurrent_lookups_of_one_digest_share_one_head_and_signature,idle_slots_are_dropped_past_the_cap; signing unitsconcurrent_signatures_never_exceed_the_limit,an_exhausted_bucket_fails_fast_instead_of_queueing,the_bucket_refills_over_time,a_saturated_signer_turns_entries_into_misses_not_errors; V4 conformancematches_googles_v4_signing_vectorsstill passessrc/srv_url.rschecked_origin,scheme_policy;src/cache/aster_srv.rsfrom_env/from_parts/build_agent;src/cache/backend.rsselect_remote;src/test_results/{client,read,upload}.rssrv_url::tests::{checked_origin_refuses_cleartext_to_another_host, the_scheme_policy_blocks_downgrades, only_https_or_this_machine_is_secure};aster_srv::tests::{a_cleartext_url_on_another_host_is_refused_and_https_and_loopback_are_not, a_cleartext_blob_base_is_ignored};backend::tests::select_remote_refuses_a_cleartext_aster_srv_url_on_another_host;client::tests::a_cleartext_server_on_another_host_is_refused_before_any_request;read::tests::a_cleartext_server_on_another_host_is_refused_for_reads;upload::tests::{upload_refuses_cleartext_to_another_host_before_any_request, an_https_agent_refuses_plain_http, a_loopback_http_agent_does_not_follow_a_redirect_to_another_server}src/limits.rsoverflow_keylimits::a_flood_of_minted_keys_does_not_starve_a_real_newcomer,a_full_table_is_swept_at_most_once_per_interval,a_flood_against_the_ip_limiter_leaves_subjects_untouchedREADME.md"Revoking a departed person's tokens" (SQL betweenrevoke-sqlmarkers)tests/operator_revoke.rs:the_documented_revoke_sql_logs_out_a_departed_person_and_nobody_elseREADME.md"Rotating the signing keys";ASTER_SRV_PERSON_TOKENSenv rowconfigunits andtests/cli_auth_copy.rssrc/keyfetch.rsmay_fetch, used insrc/auth.rsandsrc/iap.rstests/jwks_refetch.rs:a_failed_key_fetch_does_not_block_the_next_real_rotation,an_unknown_key_flood_does_not_cause_a_refetch_storm_or_hide_a_rotation,the_iap_verifier_follows_the_same_refetch_rules;keyfetch::testssrc/test_results/query.rsrun failurestests/query.rsreports_a_runs_failures_with_their_state(asserts 404)src/cli_auth.rsconfirm and code pages;src/cache/login.rsPASTE_WARNINGtests/cli_auth_copy.rscopy_mode_shows_a_one_time_code_and_never_redirects_or_shows_a_token;login::tests::the_paste_prompt_warns_against_a_code_someone_sentsrc/limits.rsforwarded_for,client_ip;src/http.rshandlelimits::repeated_forwarded_for_lines_are_one_list_and_cannot_move_the_client,limits::the_client_moves_left_one_entry_per_trusted_hop;tests/hardening.rsthe_client_ip_comes_from_all_forwarded_for_lines_and_the_trusted_hopssrc/cache/routes.rs(counts = Ci && may_write)tests/cache.rsa_pull_request_lookup_does_not_count_but_a_main_push_does(pull_request, person, main push)src/audit.rsSampler,admittests/hardening.rsa_401_flood_is_sampled_and_summarised_while_counters_and_the_success_trail_stay_exact; unitsaudit::the_first_few_per_key_are_admitted_and_the_rest_summarised_when_the_window_ends,a_flood_of_distinct_addresses_cannot_grow_the_tableLive verification after the ingress flip (N12, multiple X-Forwarded-For headers, client IP taken from the right hop) is added to firstlanding's
live-negative-checks.pyin #19927.Scope
Server (aster-srv) and the aster client. Backend only.
Risk: medium
These are auth-boundary and resource-limit changes, so they're the riskiest part of the release, but every finding has a test that fails without its fix (spot-checked per commit by the authors).
ASTER_SRV_API_ADDRis set. firstlanding #19856 sets every required setting (owner-id pin, writer workflows, trusted hops).test_resultsv7 is additive.User impact
Laptop and CI users shouldn't see a change, apart from clearer confirm-page labels and the paste warning. A cold lookup on a large CI build now answers within about 3s, with partial hits instead of timing out.
Testing
Canonical end-to-end proofs:
tests/cache_login_e2e.rs:a_laptop_signs_in_through_aster_srv_and_restores_what_ci_wrote,a_remote_shell_signs_in_by_pasting_a_code_and_restores_what_ci_wrote, anda_signed_in_laptop_reads_every_dashboard_dataset_with_aster_srv. These run the realasterbinary, server and Postgres.tests/cache_remote_e2e.rsremote_restore_across_checkouts_through_aster_srv.Latency (N5), measured on real routes and Postgres, with in-memory GCS at 20ms per HEAD and 10ms per signBlob:
Integration matrix:
/v1/cache/commit,/upload/v1/cache/lookup/v1/test-results/runs/v1/auth/token,/cli-authFidelity:
asterbinary, JWT signing.Not proven here:
Local checks on the assembled head (3db4301): fmt; clippy
-D warnings; rustdoc-D warnings; fullcargo test -p aster-srvagainst Postgres 17;cache_login_e2e(3) andcache_remote_e2e(2); root lib and bins (916 + 20). All pass.3db4301 (test only): the dashboard e2e now gives each simulated CI run its own OIDC token and a matching
gh:<repo>:<run>:<attempt>:run id, because N2 rightly refused the olde2e-run-Nuploads with 403.Follow-ups
hardening.rscan push it over its limit.🤖 Generated with Claude Code