Skip to content

Commit ed2f77e

Browse files
authored
Merge pull request #1571 from Dstack-TEE/backport/v0.5.x-security-2026-11
backport(v0.5.x): guest security fixes from next
2 parents 43f69f4 + 4aa65c2 commit ed2f77e

12 files changed

Lines changed: 352 additions & 46 deletions

File tree

‎.github/workflows/sdk.yaml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ jobs:
3030
# This additional target is needed for wasm32 compatibility check.
3131
targets: wasm32-unknown-unknown, thumbv6m-none-eabi
3232

33+
- name: Set up Python
34+
uses: actions/setup-python@v5
35+
with:
36+
python-version: '3.11'
37+
3338
- name: SDK tests
3439
run: cd sdk && ./run-tests.sh
3540

‎Cargo.lock‎

Lines changed: 21 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ rsa = "0.9"
188188
ring = "0.17.14"
189189
rustls = "0.23.23"
190190
rustls-pki-types = "1.13.1"
191-
rustls-webpki = "0.103.10"
191+
rustls-webpki = "0.103.16"
192192
schnorrkel = "0.11.4"
193193
sha2 = { version = "0.10.8", default-features = false }
194194
sha3 = "0.10.8"

‎dstack-util/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ ez-hash.workspace = true
5757
cc-eventlog.workspace = true
5858
safe-write.workspace = true
5959
errify.workspace = true
60+
base64.workspace = true
6061

6162
[dev-dependencies]
6263
rand.workspace = true

‎dstack-util/src/system_setup.rs‎

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use std::{
66
collections::{BTreeMap, BTreeSet},
77
fmt::Display,
8+
net::{Ipv4Addr, Ipv6Addr},
89
ops::Deref,
910
path::{Path, PathBuf},
1011
process::Command,
@@ -370,6 +371,43 @@ impl HostShared {
370371
}
371372
}
372373

374+
// The gateway's WireGuard fields are rendered into a wg-quick config, and
375+
// wg-quick runs its `PreUp`/`PostUp` hooks as root. Accept only values that
376+
// cannot carry config syntax, so the gateway cannot inject lines.
377+
fn parse_wg_ip(ip: &str) -> Result<Ipv4Addr> {
378+
ip.parse()
379+
.with_context(|| format!("invalid WireGuard address: {ip:?}"))
380+
}
381+
382+
fn parse_wg_public_key(key: &str) -> Result<String> {
383+
use base64::{engine::general_purpose::STANDARD, Engine as _};
384+
let decoded = STANDARD
385+
.decode(key)
386+
.ok()
387+
.filter(|decoded| decoded.len() == 32)
388+
.with_context(|| format!("invalid WireGuard public key: {key:?}"))?;
389+
Ok(STANDARD.encode(decoded))
390+
}
391+
392+
fn validate_wg_endpoint(endpoint: &str) -> Result<&str> {
393+
let valid = endpoint.rsplit_once(':').is_some_and(|(host, port)| {
394+
let host_ok = match host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
395+
Some(v6) => v6.parse::<Ipv6Addr>().is_ok(),
396+
None => {
397+
!host.is_empty()
398+
&& host
399+
.chars()
400+
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
401+
}
402+
};
403+
host_ok && port.parse::<u16>().is_ok()
404+
});
405+
if !valid {
406+
bail!("invalid WireGuard endpoint: {endpoint:?}");
407+
}
408+
Ok(endpoint)
409+
}
410+
373411
const GATEWAY_CACHE_PATH: &str = "/run/dstack/gateway-cache.json";
374412
const WG_CONFIG_PATH: &str = "/etc/wireguard/dstack-wg0.conf";
375413
/// Certificate validity period in seconds (10 days)
@@ -628,7 +666,7 @@ impl<'a> GatewayContext<'a> {
628666
};
629667
let mut wg_info = response.wg.context("Missing wg info")?;
630668

631-
let client_ip = &wg_info.client_ip;
669+
let client_ip = parse_wg_ip(&wg_info.client_ip)?;
632670

633671
// Sort peers by public key for consistent config generation
634672
wg_info.servers.sort_by(|a, b| a.pk.cmp(&b.pk));
@@ -643,7 +681,9 @@ impl<'a> GatewayContext<'a> {
643681
key_store.wg_sk
644682
);
645683
for WireGuardPeer { pk, ip, endpoint } in &wg_info.servers {
646-
let ip = ip.split('/').next().unwrap_or_default();
684+
let pk = parse_wg_public_key(pk)?;
685+
let ip = parse_wg_ip(ip.split('/').next().unwrap_or_default())?;
686+
let endpoint = validate_wg_endpoint(endpoint)?;
647687
new_config.push_str(&format!(
648688
"[Peer]\n\
649689
PublicKey = {pk}\n\
@@ -1449,12 +1489,11 @@ impl Stage1<'_> {
14491489
ciphertext: &[u8],
14501490
allowed: &BTreeSet<String>,
14511491
) -> Result<BTreeMap<String, String>> {
1452-
let vars = if !key.is_empty() && !ciphertext.is_empty() {
1492+
let vars = if !ciphertext.is_empty() {
14531493
info!("Processing encrypted env");
1454-
let env_crypt_key: [u8; 32] = key
1455-
.try_into()
1456-
.ok()
1457-
.context("Invalid env crypt key length")?;
1494+
let env_crypt_key: [u8; 32] = key.try_into().ok().context(
1495+
"encrypted env is present but the key provider gave no valid env crypt key",
1496+
)?;
14581497
let decrypted_json =
14591498
dh_decrypt(env_crypt_key, ciphertext).context("Failed to decrypt env file")?;
14601499
crate::parse_env_file::parse_env(&decrypted_json, allowed)?
@@ -1872,3 +1911,32 @@ fn test_validate_luks2_header_rejects_keyslot_area_that_overflows() {
18721911
"{error:#}"
18731912
);
18741913
}
1914+
1915+
#[test]
1916+
fn wireguard_fields_reject_config_syntax() {
1917+
for endpoint in [
1918+
"gateway.example.com:51820",
1919+
"192.0.2.1:51821",
1920+
"[2001:db8::1]:51822",
1921+
] {
1922+
assert_eq!(validate_wg_endpoint(endpoint).unwrap(), endpoint);
1923+
}
1924+
for endpoint in [
1925+
"",
1926+
"192.0.2.1",
1927+
"192.0.2.1:99999",
1928+
"0.0.0.0/0:1",
1929+
"192.0.2.1:51820\n[Interface]\nPostUp = id",
1930+
"[not-v6]:51820",
1931+
] {
1932+
assert!(validate_wg_endpoint(endpoint).is_err(), "{endpoint:?}");
1933+
}
1934+
1935+
let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
1936+
assert_eq!(parse_wg_public_key(key).unwrap(), key);
1937+
assert!(parse_wg_public_key(&format!("{key}\nPostUp = id")).is_err());
1938+
assert!(parse_wg_public_key("AAAA").is_err());
1939+
1940+
assert!(parse_wg_ip("10.0.0.2").is_ok());
1941+
assert!(parse_wg_ip("10.0.0.2/32\n[Interface]").is_err());
1942+
}

‎guest-agent/src/http_routes.rs‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,9 @@ mod docker_logs {
203203

204204
let seconds = match unit {
205205
"s" => value,
206-
"m" => value * 60,
207-
"h" => value * 3600,
208-
"d" => value * 24 * 3600,
206+
"m" => value.saturating_mul(60),
207+
"h" => value.saturating_mul(3600),
208+
"d" => value.saturating_mul(24 * 3600),
209209
_ => {
210210
anyhow::bail!("Invalid time unit. Use s, m, h, or d");
211211
}
@@ -358,6 +358,19 @@ mod docker_logs {
358358
);
359359
}
360360

361+
#[test]
362+
fn an_oversized_duration_reaches_back_to_the_epoch() {
363+
let now = SystemTime::now();
364+
for unit in ["s", "m", "h", "d"] {
365+
let duration = format!("{}{unit}", u64::MAX);
366+
assert_eq!(
367+
parse_duration_inner(&duration, now).unwrap(),
368+
0,
369+
"{duration}"
370+
);
371+
}
372+
}
373+
361374
#[test]
362375
fn test_parse_duration_errors() {
363376
// Invalid unit

‎ra-rpc/Cargo.toml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ serde.workspace = true
2424
x509-parser.workspace = true
2525
prost-types = { workspace = true, optional = true }
2626
or-panic.workspace = true
27+
rustls = { workspace = true, optional = true }
28+
29+
[dev-dependencies]
30+
tokio-rustls.workspace = true
31+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util"] }
2732

2833
[features]
2934
default = ["rocket", "client"]
3035
rocket = ["dep:rocket", "dep:rocket-vsock-listener"]
31-
client = ["reqwest"]
36+
client = ["reqwest", "dep:rustls"]
3237
openapi = ["dep:prost-types"]

0 commit comments

Comments
 (0)