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
13 changes: 4 additions & 9 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod flags;
mod frame_ws;
mod general_settings;
mod hotkeys;
mod logging;
mod notifications;
mod permissions;
mod platform;
Expand Down Expand Up @@ -110,24 +111,17 @@ pub enum RecordingState {
Active(InProgressRecording),
}

#[derive(specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
#[deprecated = "can be removed when native camera preview is ready"]
camera_ws_port: u16,
#[serde(skip)]
camera_preview: CameraPreviewManager,
#[serde(skip)]
handle: AppHandle,
#[serde(skip)]
recording_state: RecordingState,
#[serde(skip)]
recording_logging_handle: LoggingHandle,
#[serde(skip)]
mic_feed: ActorRef<feeds::microphone::MicrophoneFeed>,
#[serde(skip)]
camera_feed: ActorRef<feeds::camera::CameraFeed>,
server_url: String,
logs_dir: PathBuf,
}

#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
Expand Down Expand Up @@ -1938,7 +1932,7 @@ pub type DynLoggingLayer = Box<dyn tracing_subscriber::Layer<FilteredRegistry> +
type LoggingHandle = tracing_subscriber::reload::Handle<Option<DynLoggingLayer>, FilteredRegistry>;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run(recording_logging_handle: LoggingHandle) {
pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
ffmpeg::init()
.map_err(|e| {
error!("Failed to initialize ffmpeg: {e}");
Expand Down Expand Up @@ -2256,6 +2250,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
.map(|v| v.server_url.clone())
})
.unwrap_or_else(|| "https://cap.so".to_string()),
logs_dir: logs_dir.clone(),
})));

app.manage(Arc::new(RwLock::new(
Expand Down
83 changes: 83 additions & 0 deletions apps/desktop/src-tauri/src/logging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use crate::{ArcLock, web_api::ManagerExt};
use std::{fs, path::PathBuf};
use tauri::{AppHandle, Manager};

async fn get_latest_log_file(app: &AppHandle) -> Option<PathBuf> {
let logs_dir = app
.state::<ArcLock<crate::App>>()
.read()
.await
.logs_dir
.clone();

let entries = fs::read_dir(&logs_dir).ok()?;
let mut log_files: Vec<_> = entries
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.is_file() && path.file_name()?.to_str()?.contains("cap-desktop.log") {
let metadata = fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
Some((path, modified))
} else {
None
}
})
.collect();

log_files.sort_by(|a, b| b.1.cmp(&a.1));
log_files.first().map(|(path, _)| path.clone())
}

pub async fn upload_log_file(app: &AppHandle) -> Result<(), String> {
let log_file = get_latest_log_file(app).await.ok_or("No log file found")?;

let metadata =
fs::metadata(&log_file).map_err(|e| format!("Failed to read log file metadata: {}", e))?;
let file_size = metadata.len();

const MAX_SIZE: u64 = 1 * 1024 * 1024;

let log_content = if file_size > MAX_SIZE {
let content =
fs::read_to_string(&log_file).map_err(|e| format!("Failed to read log file: {}", e))?;

let header = format!(
"⚠️ Log file truncated (original size: {} bytes, showing last ~1MB)\n\n",
file_size
);
let max_content_size = (MAX_SIZE as usize) - header.len();

if content.len() > max_content_size {
let start_pos = content.len() - max_content_size;
let truncated = &content[start_pos..];
if let Some(newline_pos) = truncated.find('\n') {
format!("{}{}", header, &truncated[newline_pos + 1..])
} else {
format!("{}{}", header, truncated)
}
} else {
content
}
} else {
fs::read_to_string(&log_file).map_err(|e| format!("Failed to read log file: {}", e))?
};

let form = reqwest::multipart::Form::new()
.text("log", log_content)
.text("os", std::env::consts::OS)
.text("version", env!("CARGO_PKG_VERSION"));

Comment on lines +66 to +70
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redact secrets/PII and set a request timeout.

Logs may contain tokens, API keys, emails, etc. Redact before upload. Also set a per-request timeout to avoid hanging.

-    let form = reqwest::multipart::Form::new()
-        .text("log", log_content)
+    // Simple redaction (expand as needed)
+    let log_content = redact_secrets(&log_content);
+    let form = reqwest::multipart::Form::new()
+        .text("log", log_content)
         .text("os", std::env::consts::OS)
         .text("version", env!("CARGO_PKG_VERSION"));
@@
-            client.post(url).multipart(form)
+            client
+                .post(url)
+                .multipart(form)
+                .timeout(std::time::Duration::from_secs(20))

Helper (place near top of module):

fn redact_secrets(s: &str) -> std::borrow::Cow<'_, str> {
    use once_cell::sync::Lazy;
    use regex::Regex;
    static AUTH: Lazy<Regex> = Lazy::new(|| Regex::new("(?i)(authorization:\\s*bearer\\s+)[A-Za-z0-9._-]+").unwrap());
    static KEY:  Lazy<Regex> = Lazy::new(|| Regex::new("(?i)(api[_-]?key\\s*[:=]\\s*)[A-Za-z0-9._-]+").unwrap());
    let s = AUTH.replace_all(s, "$1[REDACTED]");
    KEY.replace_all(&s, "$1[REDACTED]")
}

Note: adds regex + once_cell deps if not already present. Based on learnings.

Also applies to: 71-76

let response = app
.api_request("/api/desktop/logs", |client, url| {
client.post(url).multipart(form)
})
.await
.map_err(|e| format!("Failed to upload logs: {}", e))?;

if !response.status().is_success() {
return Err(format!("Upload failed with status: {}", response.status()));
}

Ok(())
}
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,5 +134,5 @@ fn main() {
.enable_all()
.build()
.expect("Failed to build multi threaded tokio runtime")
.block_on(cap_desktop_lib::run(handle));
.block_on(cap_desktop_lib::run(handle, logs_dir));
}
19 changes: 0 additions & 19 deletions apps/desktop/src-tauri/src/thumbnails/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ pub async fn collect_displays_with_thumbnails() -> Result<Vec<CaptureDisplayWith
pub async fn collect_windows_with_thumbnails() -> Result<Vec<CaptureWindowWithThumbnail>, String> {
let windows = list_windows();

debug!(window_count = windows.len(), "Collecting window thumbnails");
let mut results = Vec::new();
for (capture_window, window) in windows {
let thumbnail = capture_window_thumbnail(&window).await;
Expand All @@ -117,22 +116,6 @@ pub async fn collect_windows_with_thumbnails() -> Result<Vec<CaptureWindowWithTh
}
});

if thumbnail.is_none() {
warn!(
window_id = ?capture_window.id,
window_name = %capture_window.name,
owner_name = %capture_window.owner_name,
"Window thumbnail capture returned None",
);
} else {
debug!(
window_id = ?capture_window.id,
window_name = %capture_window.name,
owner_name = %capture_window.owner_name,
"Captured window thumbnail",
);
}

results.push(CaptureWindowWithThumbnail {
id: capture_window.id,
name: capture_window.name,
Expand All @@ -145,7 +128,5 @@ pub async fn collect_windows_with_thumbnails() -> Result<Vec<CaptureWindowWithTh
});
}

info!(windows = results.len(), "Collected window thumbnail data");

Ok(results)
}
19 changes: 19 additions & 0 deletions apps/desktop/src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
};
use tauri_plugin_dialog::DialogExt;
use tauri_specta::Event;

pub enum TrayItem {
Expand All @@ -23,6 +24,7 @@ pub enum TrayItem {
PreviousRecordings,
PreviousScreenshots,
OpenSettings,
UploadLogs,
Quit,
}

Expand All @@ -34,6 +36,7 @@ impl From<TrayItem> for MenuId {
TrayItem::PreviousRecordings => "previous_recordings",
TrayItem::PreviousScreenshots => "previous_screenshots",
TrayItem::OpenSettings => "open_settings",
TrayItem::UploadLogs => "upload_logs",
TrayItem::Quit => "quit",
}
.into()
Expand All @@ -50,6 +53,7 @@ impl TryFrom<MenuId> for TrayItem {
"previous_recordings" => Ok(TrayItem::PreviousRecordings),
"previous_screenshots" => Ok(TrayItem::PreviousScreenshots),
"open_settings" => Ok(TrayItem::OpenSettings),
"upload_logs" => Ok(TrayItem::UploadLogs),
"quit" => Ok(TrayItem::Quit),
value => Err(format!("Invalid tray item id {value}")),
}
Expand Down Expand Up @@ -78,6 +82,7 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
)?,
&MenuItem::with_id(app, TrayItem::OpenSettings, "Settings", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, TrayItem::UploadLogs, "Upload Logs", true, None::<&str>)?,
&MenuItem::with_id(
app,
"version",
Expand Down Expand Up @@ -130,6 +135,20 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
async move { ShowCapWindow::Settings { page: None }.show(&app).await },
);
}
Ok(TrayItem::UploadLogs) => {
let app = app.clone();
tokio::spawn(async move {
match crate::logging::upload_log_file(&app).await {
Ok(_) => {
tracing::info!("Successfully uploaded logs");
}
Err(e) => {
tracing::error!("Failed to upload logs: {e:#}");
app.dialog().message("Failed to upload logs").show(|_| {});
}
}
});
}
Ok(TrayItem::Quit) => {
app.exit(0);
}
Expand Down
55 changes: 38 additions & 17 deletions apps/desktop/src-tauri/src/web_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,35 @@ impl From<String> for AuthedApiError {
}
}

fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut req = req.header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION"));

if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") {
req = req.header("x-vercel-protection-bypass", s);
}

req
}
Comment on lines +42 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Scope the Vercel bypass header to trusted hosts only.

Currently, x-vercel-protection-bypass is added to every outbound request if the env var is set. This risks leaking the secret to arbitrary hosts if server_url is misconfigured or user-controlled. Gate the header by hostname (e.g., only *.vercel.app) or an allowlist, and pass the URL to the helper so it can decide.

Proposed minimal change:

-fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
+fn apply_env_headers(req: reqwest::RequestBuilder, base_url: &str) -> reqwest::RequestBuilder {
     let mut req = req.header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION"));

-    if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") {
-        req = req.header("x-vercel-protection-bypass", s);
-    }
+    if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") {
+        if let Ok(u) = reqwest::Url::parse(base_url) {
+            if matches!(u.host_str(), Some(h) if h.ends_with(".vercel.app")) {
+                req = req.header("x-vercel-protection-bypass", s);
+            }
+        }
+    }

     req
 }
@@
-    apply_env_headers(req).send().await
+    apply_env_headers(req, &url).send().await
@@
-        apply_env_headers(build(client, url)).send().await
+        apply_env_headers(build(client.clone(), url.clone()), &url).send().await

Please confirm the exact trusted hostnames/allowlist you want (prod, staging). Based on learnings.

Also applies to: 70-71, 112-121


I'll verify the security concern and the proposed fix by examining the actual codebase.


---

🏁 Script executed:

```shell
#!/bin/bash
# Inspect the apply_env_headers function and surrounding context
cat apps/desktop/src-tauri/src/web_api.rs | head -130 | tail -90

Length of output: 2923


🏁 Script executed:

#!/bin/bash
# Find all occurrences of apply_env_headers call sites
rg -n "apply_env_headers" apps/desktop/src-tauri/src/web_api.rs -A 2 -B 2

Length of output: 384


🏁 Script executed:

#!/bin/bash
# Understand function signatures and URL availability
rg -n "fn apply_env_headers|\.send\(\)" apps/desktop/src-tauri/src/web_api.rs -B 5 -A 5

Length of output: 1227


Add hostname validation to apply_env_headers to prevent leaking Vercel bypass secret to untrusted hosts.

The security concern is valid: the Vercel bypass header is currently added to every outbound request without verifying the destination host. If server_url is misconfigured or user-controlled, the secret leaks to arbitrary hosts.

The proposed fix is feasible—both call sites (lines 70 and 120) have access to the URL and can pass it as a parameter. You should:

  1. Update apply_env_headers(req: reqwest::RequestBuilder, base_url: &str) to accept the URL
  2. Parse and validate the hostname before adding the bypass header
  3. Update both call sites to pass &url

Clarify which hostnames should be trusted (the proposal suggests *.vercel.app; confirm if staging/prod have distinct domains that should both be allowed).


async fn do_authed_request(
auth: &AuthStore,
build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder,
url: String,
) -> Result<reqwest::Response, reqwest::Error> {
let client = reqwest::Client::new();

let mut req = build(client, url)
.header(
"Authorization",
format!(
"Bearer {}",
match &auth.secret {
AuthSecret::ApiKey { api_key } => api_key,
AuthSecret::Session { token, .. } => token,
}
),
)
.header("X-Cap-Desktop-Version", env!("CARGO_PKG_VERSION"));

if let Ok(s) = std::env::var("VITE_VERCEL_AUTOMATION_BYPASS_SECRET") {
req = req.header("x-vercel-protection-bypass", s);
}
let req = build(client, url).header(
"Authorization",
format!(
"Bearer {}",
match &auth.secret {
AuthSecret::ApiKey { api_key } => api_key,
AuthSecret::Session { token, .. } => token,
}
),
);

req.send().await
apply_env_headers(req).send().await
}

pub trait ManagerExt<R: Runtime>: Manager<R> {
Expand All @@ -73,6 +77,12 @@ pub trait ManagerExt<R: Runtime>: Manager<R> {
build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, AuthedApiError>;

async fn api_request(
&self,
path: impl Into<String>,
build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error>;

async fn make_app_url(&self, pathname: impl AsRef<str>) -> String;
}

Expand All @@ -99,6 +109,17 @@ impl<T: Manager<R> + Emitter<R>, R: Runtime> ManagerExt<R> for T {
Ok(response)
}

async fn api_request(
&self,
path: impl Into<String>,
build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error> {
let url = self.make_app_url(path.into()).await;
let client = reqwest::Client::new();

apply_env_headers(build(client, url)).send().await
}

async fn make_app_url(&self, pathname: impl AsRef<str>) -> String {
let app_state = self.state::<ArcLock<crate::App>>();
let server_url = &app_state.read().await.server_url;
Expand Down
Loading
Loading