security: harden credential management in onyx-core

- Enforce HTTPS for WebDAV URLs (reject http:// to prevent plaintext credentials)
- Replace String with Zeroizing<String> for credential fields and load_credentials return
- Remove manual Drop impl (Zeroizing handles zeroize-on-drop automatically)
- Scope keyring password entries by domain+username to prevent collisions
- Add migration fallback for legacy unscoped keyring entries
- Sanitize error messages to not leak keyring service patterns or env var names
- Add log warnings when falling back to env var credentials
- Add log dependency to onyx-core

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tristan Michael 2026-04-03 08:56:24 -07:00
parent d03cc92a53
commit 0c4073c998
2 changed files with 89 additions and 37 deletions

View file

@ -23,6 +23,7 @@ quick-xml = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true } keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
zeroize = "1" zeroize = "1"
log = "0.4"
[dev-dependencies] [dev-dependencies]
tempfile = "3.0" tempfile = "3.0"

View file

@ -1,5 +1,5 @@
use reqwest::Client; use reqwest::Client;
use zeroize::Zeroize; use zeroize::Zeroizing;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
/// Information about a file on the remote WebDAV server. /// Information about a file on the remote WebDAV server.
@ -11,29 +11,34 @@ pub struct RemoteFileInfo {
pub last_modified: Option<String>, pub last_modified: Option<String>,
} }
/// WebDAV client wrapping reqwest with basic auth. /// WebDAV client wrapping reqwest with basic auth. Credentials are zeroized on drop.
pub struct WebDavClient { pub struct WebDavClient {
_client: Client, _client: Client,
_base_url: String, _base_url: String,
_username: String, _username: Zeroizing<String>,
_password: String, _password: Zeroizing<String>,
}
impl Drop for WebDavClient {
fn drop(&mut self) {
self._password.zeroize();
self._username.zeroize();
}
} }
impl WebDavClient { impl WebDavClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Self { /// Create a new WebDAV client. Rejects non-HTTPS URLs to prevent sending credentials in plaintext.
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self> {
if !base_url.starts_with("https://") {
return Err(Error::WebDav("Refusing non-HTTPS URL: credentials would be sent in plaintext".into()));
}
Ok(Self::new_unchecked(base_url, username, password))
}
fn new_unchecked(base_url: &str, username: &str, password: &str) -> Self {
let base_url = base_url.trim_end_matches('/').to_string(); let base_url = base_url.trim_end_matches('/').to_string();
Self { Self {
_client: Client::new(), _client: Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
_base_url: base_url, _base_url: base_url,
_username: username.to_string(), _username: Zeroizing::new(username.to_string()),
_password: password.to_string(), _password: Zeroizing::new(password.to_string()),
} }
} }
@ -56,7 +61,7 @@ impl WebDavClient {
pub async fn test_connection(&self) -> Result<()> { pub async fn test_connection(&self) -> Result<()> {
let resp = self._client let resp = self._client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url) .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &self._base_url)
.basic_auth(&self._username, Some(&self._password)) .basic_auth(self._username.as_str(), Some(self._password.as_str()))
.header("Depth", "0") .header("Depth", "0")
.header("Content-Type", "application/xml") .header("Content-Type", "application/xml")
.body(PROPFIND_BODY) .body(PROPFIND_BODY)
@ -78,7 +83,7 @@ impl WebDavClient {
let url = self.full_url(path); let url = self.full_url(path);
let resp = self._client let resp = self._client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
.basic_auth(&self._username, Some(&self._password)) .basic_auth(self._username.as_str(), Some(self._password.as_str()))
.header("Depth", "1") .header("Depth", "1")
.header("Content-Type", "application/xml") .header("Content-Type", "application/xml")
.body(PROPFIND_BODY) .body(PROPFIND_BODY)
@ -99,7 +104,7 @@ impl WebDavClient {
let url = self.full_url(path); let url = self.full_url(path);
let resp = self._client let resp = self._client
.get(&url) .get(&url)
.basic_auth(&self._username, Some(&self._password)) .basic_auth(self._username.as_str(), Some(self._password.as_str()))
.send() .send()
.await?; .await?;
@ -119,7 +124,7 @@ impl WebDavClient {
let url = self.full_url(path); let url = self.full_url(path);
let resp = self._client let resp = self._client
.put(&url) .put(&url)
.basic_auth(&self._username, Some(&self._password)) .basic_auth(self._username.as_str(), Some(self._password.as_str()))
.body(content) .body(content)
.send() .send()
.await?; .await?;
@ -136,7 +141,7 @@ impl WebDavClient {
let url = self.full_url(path); let url = self.full_url(path);
let resp = self._client let resp = self._client
.delete(&url) .delete(&url)
.basic_auth(&self._username, Some(&self._password)) .basic_auth(self._username.as_str(), Some(self._password.as_str()))
.send() .send()
.await?; .await?;
@ -155,7 +160,7 @@ impl WebDavClient {
let url = self.full_url(path); let url = self.full_url(path);
let resp = self._client let resp = self._client
.request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url) .request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), &url)
.basic_auth(&self._username, Some(&self._password)) .basic_auth(self._username.as_str(), Some(self._password.as_str()))
.send() .send()
.await?; .await?;
@ -388,20 +393,27 @@ fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> Stri
// --- Credential Storage --- // --- Credential Storage ---
#[cfg(feature = "keyring-storage")] #[cfg(feature = "keyring-storage")]
/// Store WebDAV credentials in the platform keychain. /// Store WebDAV credentials in the platform keychain. Password is scoped by domain+username
/// to prevent collisions when multiple accounts exist on the same server.
pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> { pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> {
let service = format!("com.onyx.webdav.{}", domain); let service = format!("com.onyx.webdav.{}", domain);
let scoped_service = format!("com.onyx.webdav.{}.{}", domain, username);
let user_entry = keyring::Entry::new(&service, "username") let user_entry = keyring::Entry::new(&service, "username")
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
user_entry.set_password(username) user_entry.set_password(username)
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?; .map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
let pass_entry = keyring::Entry::new(&service, "password") let pass_entry = keyring::Entry::new(&scoped_service, "password")
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
pass_entry.set_password(password) pass_entry.set_password(password)
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?; .map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
// Clean up legacy unscoped password entry if present
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
let _ = legacy.delete_credential();
}
Ok(()) Ok(())
} }
@ -413,16 +425,28 @@ pub fn store_credentials(_domain: &str, _username: &str, _password: &str) -> Res
#[cfg(feature = "keyring-storage")] #[cfg(feature = "keyring-storage")]
/// Load WebDAV credentials from the platform keychain, falling back to env vars. /// Load WebDAV credentials from the platform keychain, falling back to env vars.
pub fn load_credentials(domain: &str) -> Result<(String, String)> { pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<String>)> {
let service = format!("com.onyx.webdav.{}", domain); let service = format!("com.onyx.webdav.{}", domain);
let user_entry = keyring::Entry::new(&service, "username") let user_entry = keyring::Entry::new(&service, "username")
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
let pass_entry = keyring::Entry::new(&service, "password")
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
if let (Ok(user), Ok(pass)) = (user_entry.get_password(), pass_entry.get_password()) { if let Ok(user) = user_entry.get_password() {
return Ok((user, pass)); // Try scoped password key first (domain+username), fall back to legacy unscoped key
let scoped_service = format!("com.onyx.webdav.{}.{}", domain, user);
let pass = keyring::Entry::new(&scoped_service, "password")
.ok()
.and_then(|e| e.get_password().ok())
.or_else(|| {
// Migration fallback: try legacy unscoped password entry
keyring::Entry::new(&service, "password")
.ok()
.and_then(|e| e.get_password().ok())
});
if let Some(pass) = pass {
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
}
} }
// Fallback to env vars for headless/CI environments // Fallback to env vars for headless/CI environments
@ -430,27 +454,29 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> {
std::env::var("ONYX_WEBDAV_USER"), std::env::var("ONYX_WEBDAV_USER"),
std::env::var("ONYX_WEBDAV_PASS"), std::env::var("ONYX_WEBDAV_PASS"),
) { ) {
return Ok((user, pass)); log::warn!("Using environment variables for WebDAV credentials — prefer keyring for better security");
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
} }
Err(Error::Credential(format!( Err(Error::Credential(format!(
"No credentials found for '{}'. Run 'onyx sync --setup' or set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS.", "No credentials found for '{}'. Run setup or configure environment variables.",
domain domain
))) )))
} }
#[cfg(not(feature = "keyring-storage"))] #[cfg(not(feature = "keyring-storage"))]
/// Load WebDAV credentials from env vars only (keyring not available). /// Load WebDAV credentials from env vars only (keyring not available).
pub fn load_credentials(domain: &str) -> Result<(String, String)> { pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<String>)> {
if let (Ok(user), Ok(pass)) = ( if let (Ok(user), Ok(pass)) = (
std::env::var("ONYX_WEBDAV_USER"), std::env::var("ONYX_WEBDAV_USER"),
std::env::var("ONYX_WEBDAV_PASS"), std::env::var("ONYX_WEBDAV_PASS"),
) { ) {
return Ok((user, pass)); log::warn!("Using environment variables for WebDAV credentials — these are visible to other processes on this system");
return Ok((Zeroizing::new(user), Zeroizing::new(pass)));
} }
Err(Error::Credential(format!( Err(Error::Credential(format!(
"No credentials found for '{}'. Set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS.", "No credentials found for '{}'. Configure environment variables.",
domain domain
))) )))
} }
@ -460,12 +486,25 @@ pub fn load_credentials(domain: &str) -> Result<(String, String)> {
pub fn delete_credentials(domain: &str) -> Result<()> { pub fn delete_credentials(domain: &str) -> Result<()> {
let service = format!("com.onyx.webdav.{}", domain); let service = format!("com.onyx.webdav.{}", domain);
if let Ok(entry) = keyring::Entry::new(&service, "username") { // Load username first so we can delete the scoped password entry
let username = keyring::Entry::new(&service, "username")
.ok()
.and_then(|e| e.get_password().ok());
if let Some(user) = &username {
let scoped_service = format!("com.onyx.webdav.{}.{}", domain, user);
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
let _ = entry.delete_credential(); let _ = entry.delete_credential();
} }
}
// Clean up legacy unscoped password and username entries
if let Ok(entry) = keyring::Entry::new(&service, "password") { if let Ok(entry) = keyring::Entry::new(&service, "password") {
let _ = entry.delete_credential(); let _ = entry.delete_credential();
} }
if let Ok(entry) = keyring::Entry::new(&service, "username") {
let _ = entry.delete_credential();
}
Ok(()) Ok(())
} }
@ -640,9 +679,21 @@ mod tests {
// --- WebDavClient URL building --- // --- WebDavClient URL building ---
#[test]
fn test_new_rejects_http() {
let result = WebDavClient::new("http://example.com/dav", "user", "pass");
assert!(result.is_err());
}
#[test]
fn test_new_accepts_https() {
let result = WebDavClient::new("https://example.com/dav", "user", "pass");
assert!(result.is_ok());
}
#[test] #[test]
fn test_full_url_building() { fn test_full_url_building() {
let client = WebDavClient::new("http://example.com/dav/", "user", "pass"); let client = WebDavClient::new_unchecked("http://example.com/dav/", "user", "pass");
assert_eq!(client.full_url(""), "http://example.com/dav"); assert_eq!(client.full_url(""), "http://example.com/dav");
assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md"); assert_eq!(client.full_url("file.md"), "http://example.com/dav/file.md");
assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md"); assert_eq!(client.full_url("My Tasks/Buy groceries.md"), "http://example.com/dav/My%20Tasks/Buy%20groceries.md");
@ -650,7 +701,7 @@ mod tests {
#[test] #[test]
fn test_full_url_strips_leading_slash() { fn test_full_url_strips_leading_slash() {
let client = WebDavClient::new("http://example.com/dav", "user", "pass"); let client = WebDavClient::new_unchecked("http://example.com/dav", "user", "pass");
assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md"); assert_eq!(client.full_url("/file.md"), "http://example.com/dav/file.md");
} }