Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release-ctx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions bins/ctx/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
109 changes: 106 additions & 3 deletions bins/ctx/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -51,21 +65,32 @@ 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. Redirects are never
/// followed, so a 302 to `http://` cannot resend `X-Lium-Api-Key`.
pub fn new(gateway: &str, lium_key: Option<String>) -> Result<Self, String> {
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());
}
// 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()
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
.map_err(|e| format!("http client: {e}"))?;
Ok(Self {
base,
lium_key: lium_key.filter(|k| !k.trim().is_empty()),
lium_key,
http,
})
}
Expand All @@ -92,7 +117,12 @@ impl Client {

async fn send(&self, req: reqwest::RequestBuilder) -> Result<Reply, String> {
// 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,
Expand Down Expand Up @@ -151,6 +181,46 @@ 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(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}"
);
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(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");
}

#[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!(
Expand All @@ -168,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);
}
}
39 changes: 29 additions & 10 deletions crates/chain-live/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
/// `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<Vec<bool>, 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.
Expand Down
Loading
Loading