From 44c8b02f01abe4bf08a14fc0c21651c3e3850810 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 08:38:08 +0000 Subject: [PATCH 1/3] fix(security): permit fail-closed + ban http API keys + pin rust-toolchain Permit fetch/decode errors now fail metagraph_at instead of an empty map that would let a pure vector through. ctx refuses X-Lium-Api-Key on http://. release-ctx pins dtolnay/rust-toolchain by commit SHA. Co-authored-by: Mathis --- .github/workflows/release-ctx.yml | 3 +- bins/ctx/src/api.rs | 70 +++++++++++- crates/chain-live/src/lib.rs | 39 +++++-- crates/chain-live/src/tests.rs | 171 +++++++++++++++++++++++++++++- crates/chain/src/lib.rs | 3 +- 5 files changed, 268 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-ctx.yml b/.github/workflows/release-ctx.yml index ded468ac3..d17de4561 100644 --- a/.github/workflows/release-ctx.yml +++ b/.github/workflows/release-ctx.yml @@ -54,7 +54,8 @@ jobs: ref: ${{ inputs.tag || github.ref }} - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master + # Pin by commit SHA. Never @master under contents:write. + uses: dtolnay/rust-toolchain@d1031067263f94b142dd6c0ce24c5eb9d02d52a0 with: toolchain: "1.96.0" targets: ${{ matrix.target }} diff --git a/bins/ctx/src/api.rs b/bins/ctx/src/api.rs index 0150c122e..2f8cbe5f6 100644 --- a/bins/ctx/src/api.rs +++ b/bins/ctx/src/api.rs @@ -17,6 +17,20 @@ pub const DEFAULT_GATEWAY: &str = "https://network.cortex.foundation"; /// Per-request timeout. Submits rent nothing synchronously, so this is short. const TIMEOUT_SECS: u64 = 60; +/// Clear fail-closed message when a miner key would go out over cleartext HTTP. +const KEYED_HTTP_REFUSAL: &str = + "refusing to send X-Lium-Api-Key over http:// — keyed calls require an https:// gateway"; + +fn is_https_url(url: &str) -> bool { + url.get(..8) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://")) +} + +fn is_http_url(url: &str) -> bool { + url.get(..7) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("http://")) +} + /// One gateway reply: HTTP status plus a decoded JSON body. pub struct Reply { /// HTTP status code. @@ -51,13 +65,20 @@ pub struct Client { impl Client { /// Build a client for one gateway base URL. + /// + /// A Lium API key is attached only on `https://`. `http://` plus a key is + /// refused so the header never goes out in cleartext. pub fn new(gateway: &str, lium_key: Option) -> Result { let base = gateway.trim().trim_end_matches('/').to_owned(); - if !(base.starts_with("https://") || base.starts_with("http://")) { + if !(is_https_url(&base) || is_http_url(&base)) { return Err(format!( "gateway must be an http(s) URL, got {base:?} (default is {DEFAULT_GATEWAY})" )); } + let lium_key = lium_key.filter(|k| !k.trim().is_empty()); + if lium_key.is_some() && !is_https_url(&base) { + return Err(KEYED_HTTP_REFUSAL.to_owned()); + } let http = reqwest::Client::builder() .timeout(Duration::from_secs(TIMEOUT_SECS)) .user_agent(concat!("ctx/", env!("CARGO_PKG_VERSION"))) @@ -65,7 +86,7 @@ impl Client { .map_err(|e| format!("http client: {e}"))?; Ok(Self { base, - lium_key: lium_key.filter(|k| !k.trim().is_empty()), + lium_key, http, }) } @@ -92,7 +113,12 @@ impl Client { async fn send(&self, req: reqwest::RequestBuilder) -> Result { // Miner BYOK. Accepted and never logged by the challenge services, and - // never printed by this CLI. + // never printed by this CLI. HTTPS is required whenever a key is set + // (`Client::new` already refuses http + key); this guard is fail-closed + // if a future constructor forgets that check. + if self.lium_key.is_some() && !is_https_url(&self.base) { + return Err(KEYED_HTTP_REFUSAL.to_owned()); + } let req = match &self.lium_key { Some(key) => req.header("X-Lium-Api-Key", key), None => req, @@ -151,6 +177,44 @@ mod tests { assert!(c.lium_key.is_none()); } + #[test] + fn http_gateway_without_key_is_allowed() { + let c = Client::new("http://127.0.0.1:8090", None).expect("http without key"); + assert_eq!(c.gateway(), "http://127.0.0.1:8090"); + assert!(c.lium_key.is_none()); + } + + #[test] + fn http_gateway_with_lium_key_is_refused() { + let err = Client::new("http://127.0.0.1:8090", Some("sk-test-key".into())) + .expect_err("http + key must fail closed"); + assert!( + err.contains("https://"), + "error must name https as the requirement: {err}" + ); + assert!( + err.contains("X-Lium-Api-Key") || err.contains("http://"), + "{err}" + ); + assert!( + !err.contains("sk-test-key"), + "must not echo the API key: {err}" + ); + } + + #[test] + fn http_scheme_is_matched_case_insensitively() { + let err = Client::new("HTTP://127.0.0.1:8090", Some("sk-test-key".into())) + .expect_err("HTTP:// + key must fail closed"); + assert!(!err.contains("sk-test-key"), "must not echo the API key"); + } + + #[test] + fn https_gateway_accepts_lium_key() { + let c = Client::new(DEFAULT_GATEWAY, Some("sk-test-key".into())).expect("https + key"); + assert!(c.lium_key.is_some()); + } + #[test] fn challenge_paths_are_proxy_paths() { assert_eq!( diff --git a/crates/chain-live/src/lib.rs b/crates/chain-live/src/lib.rs index 8d0edd799..1943df857 100644 --- a/crates/chain-live/src/lib.rs +++ b/crates/chain-live/src/lib.rs @@ -177,23 +177,42 @@ impl LiveChainClient { let keys = self.enumerate_hotkeys(netuid, at)?; let coldkeys = self.fetch_coldkeys_for_hotkeys(&keys, at)?; let owner = self.read_owner_hotkey(netuid, at)?; + let permits = self.fetch_validator_permit(netuid, at)?; + // An empty or short map is indistinguishable from "no UID is a + // validator", which would let a pure burn/vector through. Length must + // match hotkeys so missing flags cannot be treated as `false`. + if permits.len() != keys.len() { + return Err(ChainError::Other(format!( + "{PALLET_SUBTENSOR}.ValidatorPermit({netuid}) has {} flags for {} hotkeys — \ + fail-closed, refusing empty or short permit map", + permits.len(), + keys.len() + ))); + } let mut mg = storage::decode_metagraph(keys, coldkeys, owner, netuid); - mg.validator_permit = self.fetch_validator_permit(netuid, at); + mg.validator_permit = permits; Ok(mg) } - /// Best-effort `ValidatorPermit(netuid)`. Missing or undecodable storage - /// is treated as no permits so a permit-map fault cannot drop the metagraph. - fn fetch_validator_permit(&self, netuid: u16, at: Option<&[u8; 32]>) -> Vec { + /// `ValidatorPermit(netuid)`. Fetch or SCALE-decode failure is an error — + /// never an empty vec that would look like "not permitted". + fn fetch_validator_permit( + &self, + netuid: u16, + at: Option<&[u8; 32]>, + ) -> Result, ChainError> { let key = storage::storage_map_key_u16(PALLET_SUBTENSOR, "ValidatorPermit", netuid); let raw = match at { - Some(h) => self.rpc.state_get_storage_at(&key, h), - None => self.rpc.state_get_storage(&key), + Some(h) => self.rpc.state_get_storage_at(&key, h)?, + None => self.rpc.state_get_storage(&key)?, }; - match raw { - Ok(Some(bytes)) => storage::decode_vec_bool(&bytes).unwrap_or_default(), - Ok(None) | Err(_) => Vec::new(), - } + let bytes = raw.ok_or_else(|| { + ChainError::Other(format!( + "{PALLET_SUBTENSOR}.ValidatorPermit({netuid}) missing — fail-closed, \ + refusing empty permit map" + )) + })?; + storage::decode_vec_bool(&bytes) } /// Bulk-read `SubtensorModule.Owner(hotkey) → coldkey` for every hotkey. diff --git a/crates/chain-live/src/tests.rs b/crates/chain-live/src/tests.rs index 92b8c2177..39feadf60 100644 --- a/crates/chain-live/src/tests.rs +++ b/crates/chain-live/src/tests.rs @@ -6,7 +6,7 @@ use std::time::Duration; use crate::{ commit_timelocked_call, decode_axon_info, decode_bool, decode_double_map_account_k2, - decode_double_map_k2, decode_hotkey, decode_metagraph, decode_u16, decode_u64, + decode_double_map_k2, decode_hotkey, decode_metagraph, decode_u16, decode_u64, decode_vec_bool, decode_vec_vec_u8, serve_axon_call, set_weights_call, storage_double_map_key_u16_account, storage_double_map_key_u16_u16, storage_double_map_prefix_u16, storage_key, storage_map_key_twox64, storage_map_key_u16, ChainClient, ChainError, Era, LiveChainClient, @@ -106,6 +106,18 @@ fn decode_bool_true_false() { assert!(!decode_bool(&[0x00]).unwrap()); } +#[test] +fn decode_vec_bool_known_vector() { + let flags = vec![true, false, true]; + assert_eq!(decode_vec_bool(&flags.encode()).unwrap(), flags); +} + +#[test] +fn decode_vec_bool_rejects_truncated() { + // Compact(2) with no bool bytes — decode must fail, not become empty. + assert!(decode_vec_bool(&[0x08]).is_err()); +} + #[test] fn decode_vec_vec_u8_known_vector() { let data: Vec> = vec![vec![0xAA; 32], vec![0xBB; 32]]; @@ -651,6 +663,20 @@ async fn mock_metagraph_at() { .mount(&server) .await; + let permit_key_hex = validator_permit_key_hex(1); + let permit_hex = format!("0x{}", hex::encode(vec![false, true].encode())); + Mock::given(method("POST")) + .and(body_partial_json(json!({ + "method": "state_getStorage", + "params": [permit_key_hex] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": permit_hex + }))) + .mount(&server) + .await; + let uri = server.uri(); let mg = tokio::task::spawn_blocking(move || { let client = LiveChainClient::connect(&uri)?; @@ -667,10 +693,46 @@ async fn mock_metagraph_at() { // Owner mock returns Keys-shaped changes; unmatched Owner keys stay zero. assert_eq!(mg.coldkeys.len(), 2); assert_eq!(mg.owner_hotkey, vec![0xCC; 32]); + assert_eq!(mg.validator_permit, vec![false, true]); } -/// Mount the bulk `Keys` + `SubnetOwnerHotkey` responses used by `metagraph_at`. +/// Mount the bulk `Keys` + `SubnetOwnerHotkey` + `ValidatorPermit` responses used by `metagraph_at`. async fn mount_metagraph_mocks(server: &MockServer, keys_paged_times: u64) { + mount_metagraph_keys_and_owner(server, keys_paged_times).await; + mount_validator_permit_ok(server, &[false, true], keys_paged_times).await; +} + +fn validator_permit_key_hex(netuid: u16) -> String { + format!( + "0x{}", + hex::encode(storage_map_key_u16( + "SubtensorModule", + "ValidatorPermit", + netuid + )) + ) +} + +async fn mount_validator_permit_ok(server: &MockServer, flags: &[bool], times: u64) { + let key_hex = validator_permit_key_hex(1); + let value_hex = format!("0x{}", hex::encode(flags.to_vec().encode())); + Mock::given(method("POST")) + .and(body_partial_json(json!({ + "method": "state_getStorage", + "params": [key_hex] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": value_hex + }))) + .expect(times) + .mount(server) + .await; +} + +/// Keys + owner only — used by permit fail-closed tests that supply their own +/// `ValidatorPermit` mock (or omit it to prove a missing map cannot succeed). +async fn mount_metagraph_keys_and_owner(server: &MockServer, keys_paged_times: u64) { let uid0_key = storage_double_map_key_u16_u16("SubtensorModule", "Keys", 1, 0); let uid1_key = storage_double_map_key_u16_u16("SubtensorModule", "Keys", 1, 1); let uid0_hex = format!("0x{}", hex::encode(&uid0_key)); @@ -686,7 +748,6 @@ async fn mount_metagraph_mocks(server: &MockServer, keys_paged_times: u64) { .mount(server) .await; - // Two batched reads per refresh: Keys values, then Owner(hotkey) coldkeys. Mock::given(method("POST")) .and(body_partial_json(json!({"method": "state_queryStorageAt"}))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -784,6 +845,110 @@ async fn metagraph_cache_singleflight_under_concurrency() { .expect("spawn_blocking"); } +async fn metagraph_at_err(uri: String) -> ChainError { + tokio::task::spawn_blocking(move || { + let client = LiveChainClient::connect(&uri).expect("connect"); + client + .metagraph_at(&[0_u8; 32]) + .expect_err("must fail closed") + }) + .await + .expect("spawn_blocking") +} + +#[tokio::test] +async fn metagraph_fails_closed_when_validator_permit_missing() { + let server = MockServer::start().await; + mount_metagraph_keys_and_owner(&server, 1).await; + let key_hex = validator_permit_key_hex(1); + Mock::given(method("POST")) + .and(body_partial_json(json!({ + "method": "state_getStorage", + "params": [key_hex] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": null + }))) + .expect(1) + .mount(&server) + .await; + + let err = metagraph_at_err(server.uri()).await; + let msg = err.to_string(); + assert!( + msg.contains("ValidatorPermit") && msg.contains("fail-closed"), + "missing permit must fail closed, got {msg}" + ); +} + +#[tokio::test] +async fn metagraph_fails_closed_when_validator_permit_rpc_errors() { + let server = MockServer::start().await; + mount_metagraph_keys_and_owner(&server, 1).await; + let key_hex = validator_permit_key_hex(1); + Mock::given(method("POST")) + .and(body_partial_json(json!({ + "method": "state_getStorage", + "params": [key_hex] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "error": {"code": -32000, "message": "storage unavailable"} + }))) + .expect(1) + .mount(&server) + .await; + + let err = metagraph_at_err(server.uri()).await; + let msg = err.to_string(); + assert!( + msg.contains("state_getStorage") || msg.contains("storage unavailable"), + "rpc error must fail closed, got {msg}" + ); +} + +#[tokio::test] +async fn metagraph_fails_closed_when_validator_permit_undecodable() { + let server = MockServer::start().await; + mount_metagraph_keys_and_owner(&server, 1).await; + let key_hex = validator_permit_key_hex(1); + Mock::given(method("POST")) + .and(body_partial_json(json!({ + "method": "state_getStorage", + "params": [key_hex] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": "0x08" + }))) + .expect(1) + .mount(&server) + .await; + + let err = metagraph_at_err(server.uri()).await; + let msg = err.to_string(); + assert!( + msg.contains("decode Vec"), + "decode error must fail closed, got {msg}" + ); +} + +#[tokio::test] +async fn metagraph_fails_closed_when_validator_permit_empty_for_hotkeys() { + let server = MockServer::start().await; + mount_metagraph_keys_and_owner(&server, 1).await; + // Compact(0) — a successful decode of "no permits" that must not submit. + mount_validator_permit_ok(&server, &[], 1).await; + + let err = metagraph_at_err(server.uri()).await; + let msg = err.to_string(); + assert!( + msg.contains("fail-closed") && msg.contains("hotkeys"), + "empty permit map must fail closed, got {msg}" + ); +} + // --------------------------------------------------------------------------- // Axons: storage key, value decode, serve_axon call // --------------------------------------------------------------------------- diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index f99e1ea4b..5eee844d9 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -70,7 +70,8 @@ pub struct Metagraph { /// Owner hotkey for the subnet (may equal first neuron or a dedicated owner). pub owner_hotkey: Vec, /// `SubtensorModule.ValidatorPermit` flags, UID-aligned with [`Self::hotkeys`]. - /// Empty when the backend did not resolve permits (treated as all `false`). + /// Live backends fail closed on a missing or undecodable map rather than + /// substituting empty/`false` (which would allow a pure vector through). pub validator_permit: Vec, } From 3df213f83585d941bfe61fcd168f4323c55e7ded Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 08:40:09 +0000 Subject: [PATCH 2/3] fix(ctx): fail-closed http API-key tests without Debug on Client Co-authored-by: Mathis --- bins/ctx/src/api.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bins/ctx/src/api.rs b/bins/ctx/src/api.rs index 2f8cbe5f6..02a867821 100644 --- a/bins/ctx/src/api.rs +++ b/bins/ctx/src/api.rs @@ -186,8 +186,9 @@ mod tests { #[test] fn http_gateway_with_lium_key_is_refused() { - let err = Client::new("http://127.0.0.1:8090", Some("sk-test-key".into())) - .expect_err("http + key must fail closed"); + let Err(err) = Client::new("http://127.0.0.1:8090", Some("sk-test-key".into())) else { + panic!("http + key must fail closed"); + }; assert!( err.contains("https://"), "error must name https as the requirement: {err}" @@ -204,8 +205,9 @@ mod tests { #[test] fn http_scheme_is_matched_case_insensitively() { - let err = Client::new("HTTP://127.0.0.1:8090", Some("sk-test-key".into())) - .expect_err("HTTP:// + key must fail closed"); + let Err(err) = Client::new("HTTP://127.0.0.1:8090", Some("sk-test-key".into())) else { + panic!("HTTP:// + key must fail closed"); + }; assert!(!err.contains("sk-test-key"), "must not echo the API key"); } From 826daf93e48f7773ce977d6ddcbae478e8c1e4b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 09:06:28 +0000 Subject: [PATCH 3/3] fix(ctx): disable redirects so X-Lium-Api-Key cannot leak reqwest follows redirects by default and resends headers to Location, including http://. Keyed gateway calls now use Policy::none() so a 302 cannot put the API key on cleartext. Regression test: 302 to a second origin is returned and the target is never contacted. Co-authored-by: Mathis --- Cargo.lock | 1 + bins/ctx/Cargo.toml | 3 +++ bins/ctx/src/api.rs | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f379bebdb..ac6f5de1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1316,6 +1316,7 @@ dependencies = [ "reqwest 0.12.28", "serde_json", "tokio", + "wiremock", ] [[package]] diff --git a/bins/ctx/Cargo.toml b/bins/ctx/Cargo.toml index 284f5a554..b15b62eb3 100644 --- a/bins/ctx/Cargo.toml +++ b/bins/ctx/Cargo.toml @@ -21,5 +21,8 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +[dev-dependencies] +wiremock = "0.6" + [lints] workspace = true diff --git a/bins/ctx/src/api.rs b/bins/ctx/src/api.rs index 02a867821..0c69a4371 100644 --- a/bins/ctx/src/api.rs +++ b/bins/ctx/src/api.rs @@ -67,7 +67,8 @@ impl Client { /// Build a client for one gateway base URL. /// /// A Lium API key is attached only on `https://`. `http://` plus a key is - /// refused so the header never goes out in cleartext. + /// refused so the header never goes out in cleartext. Redirects are never + /// followed, so a 302 to `http://` cannot resend `X-Lium-Api-Key`. pub fn new(gateway: &str, lium_key: Option) -> Result { let base = gateway.trim().trim_end_matches('/').to_owned(); if !(is_https_url(&base) || is_http_url(&base)) { @@ -79,9 +80,12 @@ impl Client { if lium_key.is_some() && !is_https_url(&base) { return Err(KEYED_HTTP_REFUSAL.to_owned()); } + // Default reqwest policy resends headers (including X-Lium-Api-Key) to + // any Location, including http://. Never auto-follow. let http = reqwest::Client::builder() .timeout(Duration::from_secs(TIMEOUT_SECS)) .user_agent(concat!("ctx/", env!("CARGO_PKG_VERSION"))) + .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| format!("http client: {e}"))?; Ok(Self { @@ -234,4 +238,37 @@ mod tests { assert!(!r.ok()); assert_eq!(r.message(), "scoring unconfigured"); } + + /// A 302 to a second origin must not be followed. Auto-follow would resend + /// `X-Lium-Api-Key` to whatever `Location` the gateway returns, including + /// `http://`. Keyed clients require `https://` (not locally mockable here); + /// the no-redirect policy is the same builder used for keyed calls. + #[tokio::test] + async fn does_not_follow_redirect_to_http_target() { + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let target = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_string("leaked")) + .expect(0) + .mount(&target) + .await; + + let source = MockServer::start().await; + let location = format!("{}/leaked", target.uri()); + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(302).insert_header("Location", location)) + .mount(&source) + .await; + + let c = Client::new(&source.uri(), None).expect("http without key"); + let reply = c.get("/v1/weights/latest").await.expect("response"); + assert_eq!( + reply.status, 302, + "must surface the 302 instead of following it" + ); + drop(source); + drop(target); + } }