Skip to content

Commit 9bda380

Browse files
authored
fix(core): harden file permissions for user config directory (#328)
1 parent 66ba07f commit 9bda380

File tree

13 files changed

+227
-83
lines changed

13 files changed

+227
-83
lines changed

Cargo.lock

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

crates/openshell-bootstrap/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ repository.workspace = true
1010
rust-version.workspace = true
1111

1212
[dependencies]
13+
openshell-core = { path = "../openshell-core" }
1314
base64 = "0.22"
1415
bollard = { version = "0.20", features = ["ssh"] }
1516
bytes = { workspace = true }

crates/openshell-bootstrap/src/edge_token.rs

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
1010
use crate::paths::gateways_dir;
1111
use miette::{IntoDiagnostic, Result, WrapErr};
12+
use openshell_core::paths::{ensure_parent_dir_restricted, set_file_owner_only};
1213
use std::path::PathBuf;
1314

1415
/// Path to the stored edge auth token for a gateway.
@@ -24,38 +25,47 @@ fn legacy_token_path(gateway_name: &str) -> Result<PathBuf> {
2425
/// Store an edge authentication token for a gateway.
2526
pub fn store_edge_token(gateway_name: &str, token: &str) -> Result<()> {
2627
let path = edge_token_path(gateway_name)?;
27-
if let Some(parent) = path.parent() {
28-
std::fs::create_dir_all(parent)
29-
.into_diagnostic()
30-
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
31-
}
28+
ensure_parent_dir_restricted(&path)?;
3229
std::fs::write(&path, token)
3330
.into_diagnostic()
3431
.wrap_err_with(|| format!("failed to write edge token to {}", path.display()))?;
3532
// Restrict permissions to owner-only (0600).
36-
#[cfg(unix)]
37-
{
38-
use std::os::unix::fs::PermissionsExt;
39-
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
40-
.into_diagnostic()
41-
.wrap_err("failed to set token file permissions")?;
42-
}
33+
set_file_owner_only(&path)?;
4334
Ok(())
4435
}
4536

4637
/// Load a stored edge authentication token for a gateway.
4738
///
4839
/// Returns `None` if no token file exists or the file is empty.
4940
/// Falls back to the legacy `cf_token` path for backwards compatibility.
41+
/// When loading from the legacy path, migrates the token to the new path
42+
/// with proper permissions.
5043
pub fn load_edge_token(gateway_name: &str) -> Option<String> {
51-
// Try the new path first, then fall back to legacy.
52-
let path = edge_token_path(gateway_name)
44+
// Try the new path first.
45+
if let Some(path) = edge_token_path(gateway_name).ok().filter(|p| p.exists()) {
46+
let contents = std::fs::read_to_string(&path).ok()?;
47+
let token = contents.trim().to_string();
48+
if !token.is_empty() {
49+
return Some(token);
50+
}
51+
}
52+
53+
// Fall back to the legacy cf_token path.
54+
let legacy_path = legacy_token_path(gateway_name)
5355
.ok()
54-
.filter(|p| p.exists())
55-
.or_else(|| legacy_token_path(gateway_name).ok().filter(|p| p.exists()))?;
56-
let contents = std::fs::read_to_string(&path).ok()?;
56+
.filter(|p| p.exists())?;
57+
let contents = std::fs::read_to_string(&legacy_path).ok()?;
5758
let token = contents.trim().to_string();
58-
if token.is_empty() { None } else { Some(token) }
59+
if token.is_empty() {
60+
return None;
61+
}
62+
63+
// Migrate: write to new path with proper permissions, then remove legacy.
64+
if store_edge_token(gateway_name, &token).is_ok() {
65+
let _ = std::fs::remove_file(&legacy_path);
66+
}
67+
68+
Some(token)
5969
}
6070

6171
/// Remove a stored edge authentication token.

crates/openshell-bootstrap/src/metadata.rs

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use crate::RemoteOptions;
55
use crate::paths::{active_gateway_path, gateways_dir, last_sandbox_path};
66
use miette::{IntoDiagnostic, Result, WrapErr};
7+
use openshell_core::paths::ensure_parent_dir_restricted;
78
use serde::{Deserialize, Serialize};
89
use std::path::PathBuf;
910

@@ -205,11 +206,7 @@ pub fn resolve_ssh_hostname(host: &str) -> String {
205206

206207
pub fn store_gateway_metadata(name: &str, metadata: &GatewayMetadata) -> Result<()> {
207208
let path = stored_metadata_path(name)?;
208-
if let Some(parent) = path.parent() {
209-
std::fs::create_dir_all(parent)
210-
.into_diagnostic()
211-
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
212-
}
209+
ensure_parent_dir_restricted(&path)?;
213210
let contents = serde_json::to_string_pretty(metadata)
214211
.into_diagnostic()
215212
.wrap_err("failed to serialize gateway metadata")?;
@@ -237,11 +234,7 @@ pub fn get_gateway_metadata(name: &str) -> Option<GatewayMetadata> {
237234
/// Save the active gateway name to persistent storage.
238235
pub fn save_active_gateway(name: &str) -> Result<()> {
239236
let path = active_gateway_path()?;
240-
if let Some(parent) = path.parent() {
241-
std::fs::create_dir_all(parent)
242-
.into_diagnostic()
243-
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
244-
}
237+
ensure_parent_dir_restricted(&path)?;
245238
std::fs::write(&path, name)
246239
.into_diagnostic()
247240
.wrap_err_with(|| format!("failed to write active gateway to {}", path.display()))?;
@@ -261,11 +254,7 @@ pub fn load_active_gateway() -> Option<String> {
261254
/// Save the last-used sandbox name for a gateway to persistent storage.
262255
pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> {
263256
let path = last_sandbox_path(gateway)?;
264-
if let Some(parent) = path.parent() {
265-
std::fs::create_dir_all(parent)
266-
.into_diagnostic()
267-
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
268-
}
257+
ensure_parent_dir_restricted(&path)?;
269258
std::fs::write(&path, sandbox)
270259
.into_diagnostic()
271260
.wrap_err_with(|| format!("failed to write last sandbox to {}", path.display()))?;

crates/openshell-bootstrap/src/mtls.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
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::xdg_config_dir;
54
use crate::pki::PkiBundle;
65
use miette::{IntoDiagnostic, Result};
6+
use openshell_core::paths::{create_dir_restricted, set_file_owner_only, xdg_config_dir};
77
use std::path::PathBuf;
88

99
/// Store the PKI bundle's client materials (ca.crt, tls.crt, tls.key) to the
1010
/// local filesystem so the CLI can use them for mTLS connections.
1111
///
1212
/// Files are written atomically: temp dir -> validate -> rename over target.
13+
/// Directories are created with `0o700` and `tls.key` is set to `0o600`.
1314
pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> {
1415
let dir = cli_mtls_dir(name)?;
1516
let temp_dir = cli_mtls_temp_dir(name)?;
@@ -21,9 +22,9 @@ pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> {
2122
.map_err(|e| e.wrap_err(format!("failed to remove {}", temp_dir.display())))?;
2223
}
2324

24-
std::fs::create_dir_all(&temp_dir)
25-
.into_diagnostic()
26-
.map_err(|e| e.wrap_err(format!("failed to create {}", temp_dir.display())))?;
25+
// Create the temp dir with restricted permissions so the private key
26+
// is never world-readable, even momentarily.
27+
create_dir_restricted(&temp_dir)?;
2728

2829
std::fs::write(temp_dir.join("ca.crt"), &bundle.ca_cert_pem)
2930
.into_diagnostic()
@@ -35,6 +36,9 @@ pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> {
3536
.into_diagnostic()
3637
.map_err(|e| e.wrap_err("failed to write tls.key"))?;
3738

39+
// Restrict the private key to owner-only.
40+
set_file_owner_only(&temp_dir.join("tls.key"))?;
41+
3842
validate_cli_mtls_bundle_dir(&temp_dir)?;
3943

4044
let had_backup = if dir.exists() {
@@ -61,6 +65,9 @@ pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> {
6165
return Err(err);
6266
}
6367

68+
// Ensure the final directory also has restricted permissions after rename.
69+
create_dir_restricted(&dir)?;
70+
6471
if had_backup {
6572
std::fs::remove_dir_all(&backup_dir)
6673
.into_diagnostic()

crates/openshell-bootstrap/src/paths.rs

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +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 miette::{IntoDiagnostic, Result, WrapErr};
4+
use miette::Result;
5+
use openshell_core::paths::xdg_config_dir;
56
use std::path::PathBuf;
67

7-
pub fn xdg_config_dir() -> Result<PathBuf> {
8-
if let Ok(path) = std::env::var("XDG_CONFIG_HOME") {
9-
return Ok(PathBuf::from(path));
10-
}
11-
let home = std::env::var("HOME")
12-
.into_diagnostic()
13-
.wrap_err("HOME is not set")?;
14-
Ok(PathBuf::from(home).join(".config"))
15-
}
16-
178
/// Path to the file that stores the active gateway name.
189
///
1910
/// Location: `$XDG_CONFIG_HOME/openshell/active_gateway`

crates/openshell-cli/src/ssh.rs

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -773,15 +773,9 @@ fn render_ssh_config(gateway: &str, name: &str) -> String {
773773
}
774774

775775
fn openshell_ssh_config_path() -> Result<PathBuf> {
776-
let base = if let Ok(path) = std::env::var("XDG_CONFIG_HOME") {
777-
PathBuf::from(path)
778-
} else {
779-
let home = std::env::var("HOME")
780-
.into_diagnostic()
781-
.wrap_err("HOME is not set")?;
782-
PathBuf::from(home).join(".config")
783-
};
784-
Ok(base.join("openshell").join("ssh_config"))
776+
Ok(openshell_core::paths::xdg_config_dir()?
777+
.join("openshell")
778+
.join("ssh_config"))
785779
}
786780

787781
fn user_ssh_config_path() -> Result<PathBuf> {
@@ -905,9 +899,7 @@ pub fn install_ssh_config(gateway: &str, name: &str) -> Result<PathBuf> {
905899
ensure_openshell_include(&main_config, &managed_config)?;
906900

907901
if let Some(parent) = managed_config.parent() {
908-
fs::create_dir_all(parent)
909-
.into_diagnostic()
910-
.wrap_err("failed to create OpenShell config directory")?;
902+
openshell_core::paths::create_dir_restricted(parent)?;
911903
}
912904

913905
let alias = host_alias(name);

crates/openshell-cli/src/tls.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,7 @@ fn sanitize_name(value: &str) -> String {
150150
}
151151

152152
fn xdg_config_dir() -> Result<PathBuf> {
153-
if let Ok(path) = std::env::var("XDG_CONFIG_HOME") {
154-
return Ok(PathBuf::from(path));
155-
}
156-
let home = std::env::var("HOME")
157-
.into_diagnostic()
158-
.wrap_err("HOME is not set")?;
159-
Ok(PathBuf::from(home).join(".config"))
153+
openshell_core::paths::xdg_config_dir()
160154
}
161155

162156
pub fn require_tls_materials(server: &str, tls: &TlsOptions) -> Result<TlsMaterials> {

crates/openshell-core/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@ url = { workspace = true }
2424
tonic-build = { workspace = true }
2525
protobuf-src = { workspace = true }
2626

27+
[dev-dependencies]
28+
tempfile = "3"
29+
2730
[lints]
2831
workspace = true

crates/openshell-core/src/forward.rs

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//! Used by both the CLI (`openshell-cli`) and the TUI (`openshell-tui`) to
77
//! start, stop, list, and track background SSH port forwards.
88
9+
use crate::paths::{create_dir_restricted, xdg_config_dir};
910
use miette::{IntoDiagnostic, Result, WrapErr};
1011
use std::net::TcpListener;
1112
use std::path::PathBuf;
@@ -17,15 +18,7 @@ use std::process::Command;
1718

1819
/// Base directory for forward PID files.
1920
pub fn forward_pid_dir() -> Result<PathBuf> {
20-
let base = if let Ok(path) = std::env::var("XDG_CONFIG_HOME") {
21-
PathBuf::from(path)
22-
} else {
23-
let home = std::env::var("HOME")
24-
.into_diagnostic()
25-
.wrap_err("HOME is not set")?;
26-
PathBuf::from(home).join(".config")
27-
};
28-
Ok(base.join("openshell").join("forwards"))
21+
Ok(xdg_config_dir()?.join("openshell").join("forwards"))
2922
}
3023

3124
/// PID file path for a specific sandbox + port forward.
@@ -44,9 +37,7 @@ pub fn write_forward_pid(
4437
bind_addr: &str,
4538
) -> Result<()> {
4639
let dir = forward_pid_dir()?;
47-
std::fs::create_dir_all(&dir)
48-
.into_diagnostic()
49-
.wrap_err("failed to create forwards directory")?;
40+
create_dir_restricted(&dir)?;
5041
let path = forward_pid_path(name, port)?;
5142
std::fs::write(&path, format!("{pid}\t{sandbox_id}\t{bind_addr}"))
5243
.into_diagnostic()

0 commit comments

Comments
 (0)