diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 3f43925de36..a78a499bdd1 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -16,7 +16,8 @@ //! calls hit the cache and silently refresh when expired. use std::fs; -use std::path::PathBuf; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -188,15 +189,16 @@ impl PkceOAuthTokenSource { } /// Persist a token to disk and the in-memory cell. + /// + /// The cache holds both the access and refresh tokens, so the on-disk + /// file is written owner-only (`0o600` on Unix) via an atomic + /// inode-swapping rename — see [`write_private_cache`]. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { let body = serde_json::to_vec_pretty(&token) .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - // Atomic rename so a concurrent reader never sees a partial write. - let tmp = self.cache_path.with_extension("json.tmp"); - fs::write(&tmp, &body) - .map_err(|e| AgentError::Llm(format!("oauth cache write {tmp:?}: {e}")))?; - fs::rename(&tmp, &self.cache_path) - .map_err(|e| AgentError::Llm(format!("oauth cache rename: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; *state = Some(token); Ok(()) } @@ -463,11 +465,162 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { Ok(dir.join(format!("{hash}.json"))) } -fn read_cache(path: &PathBuf) -> Option { - let body = fs::read(path).ok()?; +/// Load a cached token, enforcing the owner-only invariant on load. +/// +/// Owner-only permissions are a cache *lifecycle* invariant, not just a +/// write-path property: a world-readable cache left by an older buzz-agent +/// (or any tampering) must be tightened the moment we touch it, before the +/// tokens are used — otherwise a file that never expires stays exposed until +/// some future refresh happens to rewrite it. Every load path (initial and +/// cross-process re-reads) funnels through here, so the repair covers them +/// all. Returns `None` when the cache is absent, unreadable, unparseable, or +/// cannot be secured; the caller then falls through to refresh/browser. +fn read_cache(path: &Path) -> Option { + let body = read_private_cache(path).ok()?; serde_json::from_slice(&body).ok() } +/// Open the cache, reject symlinks, tighten loose permissions to `0o600`, and +/// return its bytes. +/// +/// On Unix `O_NOFOLLOW` rejects a symlinked cache path at the kernel level +/// (no stat/open TOCTOU), and `fchmod` on the already-open handle repairs a +/// loose mode against the pinned inode rather than re-resolving the path. +/// A cache that exists but cannot be secured is an error, so the caller fails +/// closed instead of using an exposed file. +#[cfg(unix)] +fn read_private_cache(path: &Path) -> io::Result> { + use std::io::Read; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + + let meta = file.metadata()?; + if !meta.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oauth cache is not a regular file", + )); + } + // Tighten in place on the open fd if any group/other bit is set. fchmod + // targets the inode we already hold, so no attacker can swap the path + // between the check and the repair. + if meta.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + + let mut body = Vec::new(); + file.read_to_end(&mut body)?; + Ok(body) +} + +/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the +/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +#[cfg(not(unix))] +fn read_private_cache(path: &Path) -> io::Result> { + fs::read(path) +} + +/// Removes a temp file on drop unless it was already renamed away. Keeps a +/// failed/partial write from leaving a stray token file behind. +struct TmpFileGuard<'a>(&'a Path); + +impl Drop for TmpFileGuard<'_> { + fn drop(&mut self) { + let _ = fs::remove_file(self.0); + } +} + +/// A per-write-unique temp suffix so concurrent savers — sibling threads or +/// separate processes sharing `$HOME` — never collide on one temp path. +/// Falls back to a timestamp if the RNG is unavailable rather than panicking +/// mid-auth. +fn unique_suffix() -> String { + let mut bytes = [0u8; 8]; + if getrandom::fill(&mut bytes).is_ok() { + return hex::encode(bytes); + } + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +/// Write `body` to `path` as an owner-only file via an atomic rename. +/// +/// The cache holds both the refresh and access tokens, so it must never be +/// readable by other users. We create a uniquely-named temp file in the same +/// directory with owner-only protection at creation time — mode `0o600` on +/// Unix (see [`create_private_temp_file`]) — so it is never briefly +/// world/other readable, write and fsync it, then rename over the +/// destination. The rename swaps the inode/entry wholesale, so a pre-existing +/// cache file with loose permissions is *replaced* by the new private one; +/// its old mode never survives. `fs::rename` maps to +/// `MOVEFILE_REPLACE_EXISTING` on Windows, so the atomic replace holds on +/// both platforms; the Windows owner-only DACL is pending the unsafe-FFI +/// decision noted at the seam. +fn write_private_cache(path: &Path, body: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "oauth cache path has no parent directory", + ) + })?; + fs::create_dir_all(parent)?; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("oauth-cache"); + let tmp = parent.join(format!(".{file_name}.{}.tmp", unique_suffix())); + let guard = TmpFileGuard(&tmp); + + let mut f = create_private_temp_file(&tmp)?; + f.write_all(body)?; + f.sync_all()?; + drop(f); + + fs::rename(&tmp, path)?; + // The rename consumed the temp path; nothing left to clean up. + std::mem::forget(guard); + Ok(()) +} + +/// Create `tmp` for writing with owner-only permissions from the moment it +/// exists. Fails if the file already exists (`create_new`), which the +/// per-write-unique suffix makes effectively impossible. +#[cfg(unix)] +fn create_private_temp_file(tmp: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(tmp) +} + +/// Non-Unix fallback: create the temp file if it does not already exist. +/// +/// On Windows the owner-only equivalent is an explicit DACL set at creation +/// (`CreateFileW` with SDDL `D:P(A;;FA;;;OW)`, matching goose's +/// `private_file.rs`), but that FFI needs `unsafe`, which this crate forbids. +/// Reconciling the two — an isolated helper crate, a vetted safe dependency, +/// or descoping Windows — is an open decision escalated to the maintainer, so +/// this interim relies on the default per-user ACLs and drops the owner-only +/// implementation in behind this seam once the decision lands. `create_new` +/// fails if the file already exists. +#[cfg(not(unix))] +fn create_private_temp_file(tmp: &Path) -> io::Result { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(tmp) +} + /// Parse a token-endpoint JSON response. Fails loudly when `access_token` /// is missing or empty — without this, a malformed server response would /// be cached and `bearer()` would silently return `""` until the entry @@ -518,6 +671,47 @@ fn random_state() -> Result { Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) } +/// Decide the OAuth callback result and the HTML page to serve. +/// +/// Returns `(result, page)`: `result` carries the auth code (or a detail +/// string on failure) to the waiting flow via the oneshot channel; `page` is +/// the *static* HTML shown in the browser. The page never embeds any request +/// parameter — the `error` query value is attacker-influenceable, so +/// reflecting it would be an XSS sink on the localhost callback. Failure +/// detail travels only through `result`, which surfaces in the process error +/// and logs, never in the served markup. +fn callback_outcome( + params: &std::collections::HashMap, + expected_state: &str, +) -> (Result, String) { + let result = match (params.get("code"), params.get("state")) { + (Some(code), Some(st)) if st == expected_state => Ok(code.clone()), + (Some(_), Some(_)) => Err("state mismatch".to_string()), + _ => Err(params + .get("error") + .map(|e| sanitize_callback_detail(e)) + .unwrap_or_else(|| "missing code".into())), + }; + let page = match result { + Ok(_) => "

Buzz: signed in

You can close this window.

", + Err(_) => "

Buzz auth failed

You can close this window and try again.

", + } + .to_string(); + (result, page) +} + +/// Neutralize an attacker-controllable OAuth `error` value before it enters +/// an error string that later reaches the logs. Control characters (CR/LF in +/// particular) enable log-line injection, and an unbounded value could flood +/// the logs — replace control chars with spaces and cap the length. +fn sanitize_callback_detail(raw: &str) -> String { + const MAX: usize = 200; + raw.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(MAX) + .collect() +} + /// Spin up a localhost callback server, open the authorize URL in a /// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then /// exchange the code for a token. @@ -544,23 +738,11 @@ async fn browser_pkce_flow( let tx = Arc::clone(&tx); let expected = expected_state.clone(); async move { - let result = match (params.get("code"), params.get("state")) { - (Some(code), Some(st)) if st == &expected => Ok(code.clone()), - (Some(_), Some(_)) => Err("state mismatch".to_string()), - _ => Err(params - .get("error") - .cloned() - .unwrap_or_else(|| "missing code".into())), - }; + let (result, page) = callback_outcome(¶ms, &expected); if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(result.clone()); - } - match result { - Ok(_) => Html( - "

Buzz: signed in

You can close this window.

".to_string(), - ), - Err(e) => Html(format!("

Buzz auth failed

{e}
")), + let _ = sender.send(result); } + Html(page) } }), ); @@ -844,4 +1026,320 @@ mod tests { ), } } + + // ---- callback HTML must never reflect input -------------------------- + + #[test] + fn test_callback_failure_page_omits_reflected_error_param() { + // A hostile `error` query value carrying markup must not appear in + // the served HTML — otherwise the localhost callback is an XSS sink. + let payload = ""; + let mut params = std::collections::HashMap::new(); + params.insert("error".to_string(), payload.to_string()); + + let (result, page) = callback_outcome(¶ms, "expected-state"); + + // The failure detail still reaches the waiting flow via `result`... + assert_eq!(result.as_ref().err().map(String::as_str), Some(payload)); + // ...but the browser page is static and inert. + assert!( + !page.contains(payload), + "callback page reflected the raw error param: {page}" + ); + assert!( + !page.contains("