diff --git a/.github/workflows/sdk.yaml b/.github/workflows/sdk.yaml index cb4a71c61..945be4611 100644 --- a/.github/workflows/sdk.yaml +++ b/.github/workflows/sdk.yaml @@ -30,6 +30,11 @@ jobs: # This additional target is needed for wasm32 compatibility check. targets: wasm32-unknown-unknown, thumbv6m-none-eabi + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: SDK tests run: cd sdk && ./run-tests.sh diff --git a/Cargo.lock b/Cargo.lock index a057aae14..17f47c4c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,9 +895,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -906,14 +906,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -2583,6 +2584,7 @@ version = "0.5.12" dependencies = [ "aes-gcm", "anyhow", + "base64 0.22.1", "bollard", "cc-eventlog", "cert-client", @@ -2932,7 +2934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4175,7 +4177,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5336,6 +5338,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "plain" version = "0.2.3" @@ -5782,8 +5790,11 @@ dependencies = [ "reqwest", "rocket", "rocket-vsock-listener", + "rustls", "serde", "serde_json", + "tokio", + "tokio-rustls", "tracing", "x509-parser", ] @@ -6462,7 +6473,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6514,9 +6525,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -7596,7 +7607,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1842131a0..4375ff550 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,7 +188,7 @@ rsa = "0.9" ring = "0.17.14" rustls = "0.23.23" rustls-pki-types = "1.13.1" -rustls-webpki = "0.103.10" +rustls-webpki = "0.103.16" schnorrkel = "0.11.4" sha2 = { version = "0.10.8", default-features = false } sha3 = "0.10.8" diff --git a/dstack-util/Cargo.toml b/dstack-util/Cargo.toml index 6bb1d2377..1bd1fb1bd 100644 --- a/dstack-util/Cargo.toml +++ b/dstack-util/Cargo.toml @@ -57,6 +57,7 @@ ez-hash.workspace = true cc-eventlog.workspace = true safe-write.workspace = true errify.workspace = true +base64.workspace = true [dev-dependencies] rand.workspace = true diff --git a/dstack-util/src/system_setup.rs b/dstack-util/src/system_setup.rs index c97a8d2d9..7b7d02986 100644 --- a/dstack-util/src/system_setup.rs +++ b/dstack-util/src/system_setup.rs @@ -5,6 +5,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, fmt::Display, + net::{Ipv4Addr, Ipv6Addr}, ops::Deref, path::{Path, PathBuf}, process::Command, @@ -370,6 +371,43 @@ impl HostShared { } } +// The gateway's WireGuard fields are rendered into a wg-quick config, and +// wg-quick runs its `PreUp`/`PostUp` hooks as root. Accept only values that +// cannot carry config syntax, so the gateway cannot inject lines. +fn parse_wg_ip(ip: &str) -> Result { + ip.parse() + .with_context(|| format!("invalid WireGuard address: {ip:?}")) +} + +fn parse_wg_public_key(key: &str) -> Result { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + let decoded = STANDARD + .decode(key) + .ok() + .filter(|decoded| decoded.len() == 32) + .with_context(|| format!("invalid WireGuard public key: {key:?}"))?; + Ok(STANDARD.encode(decoded)) +} + +fn validate_wg_endpoint(endpoint: &str) -> Result<&str> { + let valid = endpoint.rsplit_once(':').is_some_and(|(host, port)| { + let host_ok = match host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) { + Some(v6) => v6.parse::().is_ok(), + None => { + !host.is_empty() + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') + } + }; + host_ok && port.parse::().is_ok() + }); + if !valid { + bail!("invalid WireGuard endpoint: {endpoint:?}"); + } + Ok(endpoint) +} + const GATEWAY_CACHE_PATH: &str = "/run/dstack/gateway-cache.json"; const WG_CONFIG_PATH: &str = "/etc/wireguard/dstack-wg0.conf"; /// Certificate validity period in seconds (10 days) @@ -628,7 +666,7 @@ impl<'a> GatewayContext<'a> { }; let mut wg_info = response.wg.context("Missing wg info")?; - let client_ip = &wg_info.client_ip; + let client_ip = parse_wg_ip(&wg_info.client_ip)?; // Sort peers by public key for consistent config generation wg_info.servers.sort_by(|a, b| a.pk.cmp(&b.pk)); @@ -643,7 +681,9 @@ impl<'a> GatewayContext<'a> { key_store.wg_sk ); for WireGuardPeer { pk, ip, endpoint } in &wg_info.servers { - let ip = ip.split('/').next().unwrap_or_default(); + let pk = parse_wg_public_key(pk)?; + let ip = parse_wg_ip(ip.split('/').next().unwrap_or_default())?; + let endpoint = validate_wg_endpoint(endpoint)?; new_config.push_str(&format!( "[Peer]\n\ PublicKey = {pk}\n\ @@ -1449,12 +1489,11 @@ impl Stage1<'_> { ciphertext: &[u8], allowed: &BTreeSet, ) -> Result> { - let vars = if !key.is_empty() && !ciphertext.is_empty() { + let vars = if !ciphertext.is_empty() { info!("Processing encrypted env"); - let env_crypt_key: [u8; 32] = key - .try_into() - .ok() - .context("Invalid env crypt key length")?; + let env_crypt_key: [u8; 32] = key.try_into().ok().context( + "encrypted env is present but the key provider gave no valid env crypt key", + )?; let decrypted_json = dh_decrypt(env_crypt_key, ciphertext).context("Failed to decrypt env file")?; crate::parse_env_file::parse_env(&decrypted_json, allowed)? @@ -1872,3 +1911,32 @@ fn test_validate_luks2_header_rejects_keyslot_area_that_overflows() { "{error:#}" ); } + +#[test] +fn wireguard_fields_reject_config_syntax() { + for endpoint in [ + "gateway.example.com:51820", + "192.0.2.1:51821", + "[2001:db8::1]:51822", + ] { + assert_eq!(validate_wg_endpoint(endpoint).unwrap(), endpoint); + } + for endpoint in [ + "", + "192.0.2.1", + "192.0.2.1:99999", + "0.0.0.0/0:1", + "192.0.2.1:51820\n[Interface]\nPostUp = id", + "[not-v6]:51820", + ] { + assert!(validate_wg_endpoint(endpoint).is_err(), "{endpoint:?}"); + } + + let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + assert_eq!(parse_wg_public_key(key).unwrap(), key); + assert!(parse_wg_public_key(&format!("{key}\nPostUp = id")).is_err()); + assert!(parse_wg_public_key("AAAA").is_err()); + + assert!(parse_wg_ip("10.0.0.2").is_ok()); + assert!(parse_wg_ip("10.0.0.2/32\n[Interface]").is_err()); +} diff --git a/guest-agent/src/http_routes.rs b/guest-agent/src/http_routes.rs index 636ea809a..c56dbb22f 100644 --- a/guest-agent/src/http_routes.rs +++ b/guest-agent/src/http_routes.rs @@ -203,9 +203,9 @@ mod docker_logs { let seconds = match unit { "s" => value, - "m" => value * 60, - "h" => value * 3600, - "d" => value * 24 * 3600, + "m" => value.saturating_mul(60), + "h" => value.saturating_mul(3600), + "d" => value.saturating_mul(24 * 3600), _ => { anyhow::bail!("Invalid time unit. Use s, m, h, or d"); } @@ -358,6 +358,19 @@ mod docker_logs { ); } + #[test] + fn an_oversized_duration_reaches_back_to_the_epoch() { + let now = SystemTime::now(); + for unit in ["s", "m", "h", "d"] { + let duration = format!("{}{unit}", u64::MAX); + assert_eq!( + parse_duration_inner(&duration, now).unwrap(), + 0, + "{duration}" + ); + } + } + #[test] fn test_parse_duration_errors() { // Invalid unit diff --git a/ra-rpc/Cargo.toml b/ra-rpc/Cargo.toml index 2ec2b636b..f21a6fb2d 100644 --- a/ra-rpc/Cargo.toml +++ b/ra-rpc/Cargo.toml @@ -24,9 +24,14 @@ serde.workspace = true x509-parser.workspace = true prost-types = { workspace = true, optional = true } or-panic.workspace = true +rustls = { workspace = true, optional = true } + +[dev-dependencies] +tokio-rustls.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util"] } [features] default = ["rocket", "client"] rocket = ["dep:rocket", "dep:rocket-vsock-listener"] -client = ["reqwest"] +client = ["reqwest", "dep:rustls"] openapi = ["dep:prost-types"] diff --git a/ra-rpc/src/client.rs b/ra-rpc/src/client.rs index 3d91abe7c..65a8854ae 100644 --- a/ra-rpc/src/client.rs +++ b/ra-rpc/src/client.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use anyhow::{bail, Context, Result}; use prpc::{ @@ -46,7 +46,6 @@ impl RaClientConfig { pub fn into_client(self) -> Result { let mut builder = Client::builder() .tls_sni(true) - .danger_accept_invalid_certs(self.tls_no_check) .danger_accept_invalid_hostnames(self.tls_no_check_hostname) .tls_built_in_root_certs(self.tls_built_in_root_certs) .connect_timeout(Duration::from_secs(5)) @@ -54,7 +53,13 @@ impl RaClientConfig { if self.cert_validator.is_some() { builder = builder.tls_info(true); } - if let (Some(cert_pem), Some(key_pem)) = (self.tls_client_cert, self.tls_client_key) { + if self.tls_no_check { + builder = builder.use_preconfigured_tls(attestation_tls_config( + self.tls_client_cert.as_deref(), + self.tls_client_key.as_deref(), + )?); + } else if let (Some(cert_pem), Some(key_pem)) = (self.tls_client_cert, self.tls_client_key) + { let identity_pem = format!("{cert_pem}\n{key_pem}"); let identity = Identity::from_pem(identity_pem.as_bytes()).context("Failed to parse identity")?; @@ -75,6 +80,77 @@ impl RaClientConfig { } } +/// Attestation is checked on the response, but TLS must still prove possession +/// of the attested certificate key. Reqwest's no-verifier also skips that proof. +#[derive(Debug)] +struct AttestationServerVerifier(Arc); + +impl rustls::client::danger::ServerCertVerifier for AttestationServerVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + signature: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + signature, + &self.0.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + signature: &rustls::DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + signature, + &self.0.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +fn attestation_tls_config(cert: Option<&str>, key: Option<&str>) -> Result { + use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer}; + let provider = rustls::crypto::CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider())); + let builder = rustls::ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions()? + .dangerous() + .with_custom_certificate_verifier(Arc::new(AttestationServerVerifier(provider))); + match (cert, key) { + (Some(cert), Some(key)) => builder + .with_client_auth_cert( + CertificateDer::pem_slice_iter(cert.as_bytes()).collect::, _>>()?, + PrivateKeyDer::from_pem_slice(key.as_bytes())?, + ) + .context("invalid RA-TLS client identity"), + (None, None) => Ok(builder.with_no_client_auth()), + _ => bail!("RA-TLS client identity requires both certificate and key"), + } +} + pub struct RaClient { remote_uri: String, pccs_url: Option, diff --git a/ra-rpc/src/openapi.rs b/ra-rpc/src/openapi.rs index 7eb0a0e92..a33d402bd 100644 --- a/ra-rpc/src/openapi.rs +++ b/ra-rpc/src/openapi.rs @@ -211,15 +211,24 @@ pub(crate) struct RenderedDoc { pub struct SwaggerUiConfig { pub title: String, pub dark_mode: bool, - pub swagger_ui_dist: String, } +// Pinned with SRI so a CDN or upstream compromise cannot run script on the +// page, which shares an origin with the API it documents. Hashes are of the +// files in the npm tarball for this version. +const SWAGGER_UI_DIST: &str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.15"; +const SWAGGER_UI_CSS_SRI: &str = + "sha384-fgyWYkUAamzuI8mJFu/xpRP0JWCJRwkwUwsYDoOYVHUJ8NQE5cENn8ib3ppwFFSX"; +const SWAGGER_UI_BUNDLE_SRI: &str = + "sha384-m7zaGj7MPzU+G4lz2eyy73GxK9bbRDr9bB2CSdj8wodg2wu/Wnt6wsoLP3JD+RS9"; +const SWAGGER_UI_PRESET_SRI: &str = + "sha384-9rDX8vR4ir9/JIiV/XvxMpb5T9tVyFbssk49PV935hrdwmkVNJ7VZMM0RXAm184h"; + impl Default for SwaggerUiConfig { fn default() -> Self { Self { title: "pRPC Explorer".to_string(), dark_mode: true, - swagger_ui_dist: "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5".to_string(), } } } @@ -996,18 +1005,6 @@ fn field_type(field: &FieldDescriptorProto) -> FieldType { fn build_swagger_ui_html(spec_url: &str, cfg: &SwaggerUiConfig) -> String { let spec = spec_url.replace('\'', "\\'"); - let css = format!( - "{}/swagger-ui.css", - cfg.swagger_ui_dist.trim_end_matches('/') - ); - let bundle = format!( - "{}/swagger-ui-bundle.js", - cfg.swagger_ui_dist.trim_end_matches('/') - ); - let preset = format!( - "{}/swagger-ui-standalone-preset.js", - cfg.swagger_ui_dist.trim_end_matches('/') - ); let background = if cfg.dark_mode { "#0b0d10" } else { "#fafafa" }; format!( r#" @@ -1015,7 +1012,7 @@ fn build_swagger_ui_html(spec_url: &str, cfg: &SwaggerUiConfig) -> String { {title} - +