Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
516 changes: 516 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.15.0",
"globals": "^17.3.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
Expand All @@ -69,7 +70,6 @@
"vitest": "^4.0.18"
},
"dependencies": {
"@recallai/desktop-sdk": "2.0.26",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
Expand All @@ -86,6 +86,7 @@
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@recallai/desktop-sdk": "2.0.26",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-notification": "^2.3.3",
Expand Down
63 changes: 62 additions & 1 deletion src-tauri/src/commands/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,18 @@ pub fn save_temp_attachment(name: String, content_base64: String) -> Result<Stri
.decode(&content_base64)
.map_err(|e| format!("base64 decode: {}", e))?;

// Reduce the caller's name to a bare filename before it touches a path. The uuid
// prefix below happens to neutralise `../` today (the first component becomes
// `<hex>_..`, which does not exist, so the write fails rather than escapes) — that
// is luck, not a fence, and it does not hold on Windows drive-relative forms.
let file_name = std::path::Path::new(&name)
.file_name()
.and_then(|n| n.to_str())
.filter(|n| !n.is_empty() && *n != "." && *n != "..")
.ok_or_else(|| format!("invalid attachment name: {}", name))?;

// Unique prefix to avoid collisions
let unique_name = format!("{}_{}", &uuid::Uuid::new_v4().to_string()[..8], name);
let unique_name = format!("{}_{}", &uuid::Uuid::new_v4().to_string()[..8], file_name);
let path = tmp_dir.join(&unique_name);
std::fs::write(&path, &bytes).map_err(|e| format!("write: {}", e))?;

Expand All @@ -368,6 +378,57 @@ pub fn save_temp_attachment(name: String, content_base64: String) -> Result<Stri
mod tests {
use super::*;

// The name is caller-supplied. The uuid prefix happens to make `../` land on a
// nonexistent directory rather than escape, but that is luck, not a fence — the
// name is reduced to a bare filename before it ever reaches a path.
#[test]
fn save_temp_attachment_strips_traversal_from_the_name() {
let path = save_temp_attachment("../../.claude/settings.json".into(), String::new())
.expect("write should succeed with a sanitised name");
let path = std::path::PathBuf::from(path);
assert_eq!(
path.parent().unwrap().file_name().unwrap(),
"opencovibe-attachments",
"attachment must stay in its temp directory: {}",
path.display()
);
assert!(path
.file_name()
.unwrap()
.to_str()
.unwrap()
.ends_with("_settings.json"));
let _ = std::fs::remove_file(&path);
}

#[test]
fn save_temp_attachment_strips_absolute_paths() {
let path = save_temp_attachment("/etc/passwd".into(), String::new()).unwrap();
let path = std::path::PathBuf::from(path);
assert_eq!(
path.parent().unwrap().file_name().unwrap(),
"opencovibe-attachments"
);
assert!(path
.file_name()
.unwrap()
.to_str()
.unwrap()
.ends_with("_passwd"));
let _ = std::fs::remove_file(&path);
}

#[test]
fn save_temp_attachment_rejects_a_nameless_payload() {
for name in ["", ".", "..", "/", "../.."] {
assert!(
save_temp_attachment(name.into(), String::new()).is_err(),
"expected {:?} to be rejected",
name
);
}
}

#[test]
fn validate_clipboard_path_existing_pdf() {
let dir = tempfile::tempdir().unwrap();
Expand Down
120 changes: 54 additions & 66 deletions src-tauri/src/web_server/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,50 @@ pub enum WsAuthSubject {
QueryToken,
}

/// Reject a WebSocket handshake whose `Origin` we do not serve.
///
/// This is the check that stops cross-site WebSocket hijacking, and it has to live
/// here: browsers do not apply CORS to WebSocket at all, and the `CorsLayer` on the
/// router only omits response headers instead of refusing the request. Without it, any
/// page the user visits can open `ws://127.0.0.1:<port>/ws` and reach every method in
/// `dispatch_command` — `start_run`, `write_text_file`, `add_mcp_server`. Under DNS
/// rebinding the browser even treats the handshake as same-site and attaches the
/// `SameSite=Lax` session cookie, so credential checks alone do not cover this.
///
/// A missing `Origin` is allowed: browsers always send one on a handshake, so only
/// non-browser clients (the CLI, a script using the query token) land here.
fn ws_origin_allowed(state: &AppState, headers: &axum::http::HeaderMap) -> bool {
let Some(origin) = headers.get("origin") else {
return true;
};
let Ok(origin) = origin.to_str() else {
log::debug!("[auth] WS auth: non-UTF8 Origin rejected");
return false;
};
let port = state.effective_port.load(Ordering::Relaxed);
let allowed = crate::web_server::router::origin_allowed(origin, &state.allowed_origins, port);
if !allowed {
log::warn!(
"[auth] WS upgrade rejected: origin '{}' is not allowed (port={})",
origin,
port
);
}
allowed
}

/// Authenticate WS connection and return the auth subject.
/// Returns None if authentication fails.
pub async fn authenticate_ws(
state: &AppState,
headers: &axum::http::HeaderMap,
query_token: Option<&str>,
) -> Option<WsAuthSubject> {
// 0. Origin gate — before any credential is consulted, so a hostile page cannot
// ride a valid cookie or a leaked query token in from another origin.
if !ws_origin_allowed(state, headers) {
return None;
}
// 1. Try cookie auth
if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
for part in cookie_header.split(';') {
Expand Down Expand Up @@ -200,80 +237,31 @@ pub async fn session_cookie_middleware(
redirect_to_login_with_clear_cookie()
}

/// Validate WS connection — check cookie first, then query token fallback.
/// Returns true if authenticated.
/// Validate a WS connection, returning only whether it is authenticated.
///
/// Delegates to `authenticate_ws` so the Origin gate and the credential checks live in
/// exactly one place — an independent copy here is how a future caller would quietly
/// reintroduce cross-site WebSocket hijacking.
pub async fn validate_ws_auth(state: &AppState, req: &Request<Body>) -> bool {
// 1. Try cookie auth
if let Some(session_id) = extract_cookie(req, "session") {
let sessions = state.http_sessions.lock().await;
let current_token_version = state.token_version.load(Ordering::Relaxed);

if let Some(entry) = sessions.get(&session_id) {
let now = Utc::now();
if now < entry.expires_at && entry.token_version == current_token_version {
log::debug!("[auth] WS auth: valid session cookie");
return true;
}
log::debug!("[auth] WS auth: expired/invalid session cookie");
}
}

// 2. Fallback to query param token
if let Some(query) = req.uri().query() {
for pair in query.split('&') {
if let Some(token_val) = pair.strip_prefix("token=") {
if token_val == state.token.read().await.as_str() {
log::debug!("[auth] WS auth: valid query token");
return true;
}
log::debug!("[auth] WS auth: invalid query token (masked)");
}
}
}

log::debug!("[auth] WS auth: no valid credentials");
false
let query_token = req.uri().query().and_then(|query| {
query
.split('&')
.find_map(|pair| pair.strip_prefix("token="))
.map(String::from)
});
authenticate_ws(state, req.headers(), query_token.as_deref())
.await
.is_some()
}

/// Validate WS connection using pre-extracted headers and query token.
/// Used by the WS handler where headers/query are extracted by axum before upgrade.
/// Validate a WS connection from pre-extracted headers and query token.
/// Used where axum has already extracted them before the upgrade.
pub async fn validate_ws_auth_extracted(
state: &AppState,
headers: &axum::http::HeaderMap,
query_token: Option<&str>,
) -> bool {
// 1. Try cookie auth from headers
if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
let prefix = "session=";
for part in cookie_header.split(';') {
let trimmed = part.trim();
if let Some(session_id) = trimmed.strip_prefix(prefix) {
let sessions = state.http_sessions.lock().await;
let current_token_version = state.token_version.load(Ordering::Relaxed);

if let Some(entry) = sessions.get(session_id) {
let now = Utc::now();
if now < entry.expires_at && entry.token_version == current_token_version {
log::debug!("[auth] WS auth: valid session cookie");
return true;
}
log::debug!("[auth] WS auth: expired/invalid session cookie");
}
}
}
}

// 2. Fallback to query param token
if let Some(token_val) = query_token {
if token_val == state.token.read().await.as_str() {
log::debug!("[auth] WS auth: valid query token");
return true;
}
log::debug!("[auth] WS auth: invalid query token (masked)");
}

log::debug!("[auth] WS auth: no valid credentials");
false
authenticate_ws(state, headers, query_token).await.is_some()
}

/// Extract a named cookie from the request
Expand Down
27 changes: 17 additions & 10 deletions src-tauri/src/web_server/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,16 +877,22 @@ pub async fn dispatch_command(
serde_json::to_value(result).map_err(|e| e.to_string())
}

// ── Clipboard (partial browser support) ──
"read_clipboard_file" => {
let path = extract_str(&params, "path")?;
let as_text = params
.get("as_text")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let result = crate::commands::clipboard::read_clipboard_file(path, as_text)?;
serde_json::to_value(result).map_err(|e| e.to_string())
}
// ── Clipboard ──
//
// `read_clipboard_file` is NOT exposed here. It is fenced by extension and size
// but NOT by directory, so a caller choosing its own path reads any .json /
// .toml / .env / .conf on disk — including ~/.claude/settings.json and
// ~/.codex/config.toml, which both hold the brn_ bearer. `brains_token_get` is
// deliberately kept off this table; leaving the file read on it handed the same
// token back to a remote caller and made that guard decorative.
//
// The legitimate flow is local: `get_clipboard_files` (already desktop-only)
// hands the UI paths the OS put on the clipboard, and the UI reads those back.
// See the desktop-only arm below.
//
// `save_temp_attachment` stays dispatchable: it only WRITES a sanitised bare
// filename under the app's temp dir — no read primitive, no caller-chosen
// directory — and the browser UI's 20–100MB PDF attachment path depends on it.
"save_temp_attachment" => {
let name = extract_str(&params, "name")?;
let content_base64 = extract_str(&params, "content_base64")?;
Expand Down Expand Up @@ -1297,6 +1303,7 @@ pub async fn dispatch_command(
"capture_screenshot"
| "update_screenshot_hotkey"
| "get_clipboard_files"
| "read_clipboard_file"
| "run_claude_login"
| "run_codex_login"
| "run_codex_logout"
Expand Down
Loading
Loading