Skip to content

Commit acefaad

Browse files
committed
fix(bootstrap): validate gateway names before path joins
1 parent eba301f commit acefaad

5 files changed

Lines changed: 282 additions & 24 deletions

File tree

crates/openshell-bootstrap/src/edge_token.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@
77
//! `$XDG_CONFIG_HOME/openshell/gateways/<name>/edge_token`.
88
//! The token is a plain-text JWT string with `0600` permissions.
99
10-
use crate::paths::user_gateways_dir;
10+
use crate::paths::user_gateway_dir;
1111
use miette::{IntoDiagnostic, Result, WrapErr};
1212
use openshell_core::paths::{ensure_parent_dir_restricted, set_file_owner_only};
1313
use std::path::PathBuf;
1414

1515
/// Path to the stored edge auth token for a gateway.
1616
pub fn edge_token_path(gateway_name: &str) -> Result<PathBuf> {
17-
Ok(user_gateways_dir()?.join(gateway_name).join("edge_token"))
17+
Ok(user_gateway_dir(gateway_name)?.join("edge_token"))
1818
}
1919

2020
/// Legacy path used before the rename to `edge_token`.
2121
fn legacy_token_path(gateway_name: &str) -> Result<PathBuf> {
22-
Ok(user_gateways_dir()?.join(gateway_name).join("cf_token"))
22+
Ok(user_gateway_dir(gateway_name)?.join("cf_token"))
2323
}
2424

2525
/// Store an edge authentication token for a gateway.
@@ -158,6 +158,15 @@ mod tests {
158158
});
159159
}
160160

161+
#[test]
162+
fn edge_token_paths_reject_multi_component_gateway_names() {
163+
let tmp = tempfile::tempdir().unwrap();
164+
with_tmp_xdg(tmp.path(), || {
165+
assert!(store_edge_token("../escape", "token").is_err());
166+
assert_eq!(load_edge_token("../escape"), None);
167+
assert!(remove_edge_token("../escape").is_err());
168+
});
169+
}
161170
#[test]
162171
fn load_edge_token_trims_whitespace() {
163172
let tmp = tempfile::tempdir().unwrap();

crates/openshell-bootstrap/src/metadata.rs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
use crate::paths::{
5-
last_sandbox_path, system_active_gateway_path, system_gateways_dir, user_active_gateway_path,
6-
user_gateways_dir,
5+
last_sandbox_path, system_active_gateway_path, system_gateway_dir, system_gateways_dir,
6+
user_active_gateway_path, user_gateway_dir, user_gateways_dir, validated_gateway_name,
77
};
88
use miette::{IntoDiagnostic, Result, WrapErr};
99
use openshell_core::paths::ensure_parent_dir_restricted;
@@ -98,11 +98,11 @@ pub struct ListedGateway {
9898
}
9999

100100
fn user_gateway_metadata_path(name: &str) -> Result<PathBuf> {
101-
Ok(user_gateways_dir()?.join(name).join("metadata.json"))
101+
Ok(user_gateway_dir(name)?.join("metadata.json"))
102102
}
103103

104-
fn system_gateway_metadata_path(name: &str) -> PathBuf {
105-
system_gateways_dir().join(name).join("metadata.json")
104+
fn system_gateway_metadata_path(name: &str) -> Result<PathBuf> {
105+
Ok(system_gateway_dir(name)?.join("metadata.json"))
106106
}
107107

108108
fn resolve_gateway_metadata_path(name: &str) -> Result<Option<(PathBuf, GatewayMetadataSource)>> {
@@ -111,7 +111,7 @@ fn resolve_gateway_metadata_path(name: &str) -> Result<Option<(PathBuf, GatewayM
111111
return Ok(Some((user, GatewayMetadataSource::User)));
112112
}
113113

114-
let system = system_gateway_metadata_path(name);
114+
let system = system_gateway_metadata_path(name)?;
115115
if system.exists() {
116116
return Ok(Some((system, GatewayMetadataSource::System)));
117117
}
@@ -212,7 +212,7 @@ pub fn gateway_metadata_source(name: &str) -> Result<Option<GatewayMetadataSourc
212212

213213
pub fn load_gateway_metadata(name: &str) -> Result<GatewayMetadata> {
214214
let primary = user_gateway_metadata_path(name)?;
215-
let system = system_gateway_metadata_path(name);
215+
let system = system_gateway_metadata_path(name)?;
216216
let Some((path, _source)) = resolve_gateway_metadata_path(name)? else {
217217
return Err(miette::miette!(
218218
"no metadata found for gateway '{name}' (looked in {} and {})",
@@ -230,13 +230,25 @@ pub fn get_gateway_metadata(name: &str) -> Option<GatewayMetadata> {
230230

231231
/// Save the active gateway name to persistent storage.
232232
pub fn save_active_gateway(name: &str) -> Result<()> {
233+
validated_gateway_name(name)?;
233234
let path = user_active_gateway_path()?;
234235
ensure_parent_dir_restricted(&path)?;
235236
std::fs::write(&path, name)
236237
.into_diagnostic()
237238
.wrap_err_with(|| format!("failed to write active gateway to {}", path.display()))?;
238239
Ok(())
239240
}
241+
242+
fn read_gateway_name(path: &Path) -> Option<String> {
243+
let value = read_nonempty_trimmed(path)?;
244+
match validated_gateway_name(&value) {
245+
Ok(_) => Some(value),
246+
Err(err) => {
247+
tracing::warn!(path = %path.display(), error = %err, "ignoring invalid active gateway name");
248+
None
249+
}
250+
}
251+
}
240252
fn read_nonempty_trimmed(path: &Path) -> Option<String> {
241253
let contents = std::fs::read_to_string(path).ok()?;
242254
let value = contents.trim();
@@ -250,7 +262,7 @@ pub fn load_user_active_gateway() -> Option<String> {
250262
user_active_gateway_path()
251263
.ok()
252264
.as_deref()
253-
.and_then(read_nonempty_trimmed)
265+
.and_then(read_gateway_name)
254266
}
255267

256268
/// Load the active gateway name from persistent storage.
@@ -259,7 +271,7 @@ pub fn load_user_active_gateway() -> Option<String> {
259271
/// system-level active gateway file when no per-user selection exists, so
260272
/// installer-provided defaults can take effect on a fresh system.
261273
pub fn load_active_gateway() -> Option<String> {
262-
load_user_active_gateway().or_else(|| read_nonempty_trimmed(&system_active_gateway_path()))
274+
load_user_active_gateway().or_else(|| read_gateway_name(&system_active_gateway_path()))
263275
}
264276

265277
/// Save the last-used sandbox name for a gateway to persistent storage.
@@ -899,4 +911,34 @@ mod tests {
899911
assert_eq!(gateways[0].source, GatewayMetadataSource::System);
900912
});
901913
}
914+
915+
#[test]
916+
fn gateway_names_must_be_single_path_components() {
917+
let user = tempfile::tempdir().unwrap();
918+
let system = tempfile::tempdir().unwrap();
919+
with_tmp_xdg_and_system(user.path(), system.path(), || {
920+
let meta = GatewayMetadata {
921+
name: "shared".to_string(),
922+
gateway_endpoint: "https://example.com".to_string(),
923+
..Default::default()
924+
};
925+
assert!(store_gateway_metadata("../escape", &meta).is_err());
926+
assert!(load_gateway_metadata("../escape").is_err());
927+
assert!(save_last_sandbox("../escape", "sb-123").is_err());
928+
assert!(save_active_gateway("../escape").is_err());
929+
});
930+
}
931+
932+
#[test]
933+
fn load_active_gateway_ignores_invalid_user_name_and_falls_back_to_system() {
934+
let user = tempfile::tempdir().unwrap();
935+
let system = tempfile::tempdir().unwrap();
936+
with_tmp_xdg_and_system(user.path(), system.path(), || {
937+
std::fs::write(user.path().join("active_gateway"), "../escape").unwrap();
938+
std::fs::write(system.path().join("active_gateway"), "system-default").unwrap();
939+
940+
assert_eq!(load_user_active_gateway(), None);
941+
assert_eq!(load_active_gateway(), Some("system-default".to_string()));
942+
});
943+
}
902944
}

crates/openshell-bootstrap/src/mtls.rs

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
use crate::paths::user_gateway_dir;
45
use crate::pki::PkiBundle;
56
use miette::{IntoDiagnostic, Result};
6-
use openshell_core::paths::{create_dir_restricted, set_file_owner_only, xdg_config_dir};
7+
use openshell_core::paths::{create_dir_restricted, set_file_owner_only};
78
use std::path::PathBuf;
89

910
/// Store the PKI bundle's client materials (ca.crt, tls.crt, tls.key) to the
@@ -77,11 +78,7 @@ pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> {
7778
}
7879

7980
fn cli_mtls_dir(name: &str) -> Result<PathBuf> {
80-
Ok(xdg_config_dir()?
81-
.join("openshell")
82-
.join("gateways")
83-
.join(name)
84-
.join("mtls"))
81+
Ok(user_gateway_dir(name)?.join("mtls"))
8582
}
8683

8784
fn cli_mtls_temp_dir(name: &str) -> Result<PathBuf> {
@@ -104,3 +101,45 @@ fn validate_cli_mtls_bundle_dir(dir: &std::path::Path) -> Result<()> {
104101
}
105102
Ok(())
106103
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::*;
108+
109+
#[allow(unsafe_code)]
110+
fn with_tmp_xdg<F: FnOnce()>(tmp: &std::path::Path, f: F) {
111+
let _guard = crate::XDG_TEST_LOCK
112+
.lock()
113+
.unwrap_or_else(std::sync::PoisonError::into_inner);
114+
let orig = std::env::var("XDG_CONFIG_HOME").ok();
115+
unsafe {
116+
std::env::set_var("XDG_CONFIG_HOME", tmp);
117+
}
118+
f();
119+
unsafe {
120+
match orig {
121+
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
122+
None => std::env::remove_var("XDG_CONFIG_HOME"),
123+
}
124+
}
125+
}
126+
127+
#[test]
128+
fn store_pki_bundle_rejects_multi_component_gateway_names() {
129+
let tmp = tempfile::tempdir().unwrap();
130+
with_tmp_xdg(tmp.path(), || {
131+
let bundle = PkiBundle {
132+
ca_cert_pem: "ca".to_string(),
133+
ca_key_pem: "cakey".to_string(),
134+
server_cert_pem: "server".to_string(),
135+
server_key_pem: "serverkey".to_string(),
136+
client_cert_pem: "client".to_string(),
137+
client_key_pem: "clientkey".to_string(),
138+
jwt_signing_key_pem: "signing".to_string(),
139+
jwt_public_key_pem: "public".to_string(),
140+
jwt_key_id: "kid".to_string(),
141+
};
142+
assert!(store_pki_bundle("../escape", &bundle).is_err());
143+
});
144+
}
145+
}

crates/openshell-bootstrap/src/oidc_token.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//! `$XDG_CONFIG_HOME/openshell/gateways/<name>/oidc_token.json`.
88
//! File permissions are `0600` (owner-only).
99
10-
use crate::paths::user_gateways_dir;
10+
use crate::paths::user_gateway_dir;
1111
use miette::{IntoDiagnostic, Result, WrapErr};
1212
use openshell_core::paths::{ensure_parent_dir_restricted, set_file_owner_only};
1313
use serde::{Deserialize, Serialize};
@@ -36,9 +36,7 @@ pub struct OidcTokenBundle {
3636

3737
/// Path to the stored OIDC token bundle for a gateway.
3838
pub fn oidc_token_path(gateway_name: &str) -> Result<PathBuf> {
39-
Ok(user_gateways_dir()?
40-
.join(gateway_name)
41-
.join("oidc_token.json"))
39+
Ok(user_gateway_dir(gateway_name)?.join("oidc_token.json"))
4240
}
4341

4442
/// Store an OIDC token bundle for a gateway.
@@ -92,3 +90,43 @@ pub fn is_token_expired(bundle: &OidcTokenBundle) -> bool {
9290
.as_secs();
9391
now + 30 >= expires_at
9492
}
93+
94+
#[cfg(test)]
95+
mod tests {
96+
use super::*;
97+
98+
#[allow(unsafe_code)]
99+
fn with_tmp_xdg<F: FnOnce()>(tmp: &std::path::Path, f: F) {
100+
let _guard = crate::XDG_TEST_LOCK
101+
.lock()
102+
.unwrap_or_else(std::sync::PoisonError::into_inner);
103+
let orig = std::env::var("XDG_CONFIG_HOME").ok();
104+
unsafe {
105+
std::env::set_var("XDG_CONFIG_HOME", tmp);
106+
}
107+
f();
108+
unsafe {
109+
match orig {
110+
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
111+
None => std::env::remove_var("XDG_CONFIG_HOME"),
112+
}
113+
}
114+
}
115+
116+
#[test]
117+
fn oidc_token_paths_reject_multi_component_gateway_names() {
118+
let tmp = tempfile::tempdir().unwrap();
119+
with_tmp_xdg(tmp.path(), || {
120+
let bundle = OidcTokenBundle {
121+
access_token: "token".to_string(),
122+
refresh_token: None,
123+
expires_at: None,
124+
issuer: "https://issuer.example.com".to_string(),
125+
client_id: "openshell-cli".to_string(),
126+
};
127+
assert!(store_oidc_token("../escape", &bundle).is_err());
128+
assert!(load_oidc_token("../escape").is_none());
129+
assert!(remove_oidc_token("../escape").is_err());
130+
});
131+
}
132+
}

0 commit comments

Comments
 (0)