max / goingson
9 files changed,
+112 insertions,
-56 deletions
| @@ -63,7 +63,19 @@ impl PluginEngine { | |||
| 63 | 63 | // Disable dangerous operations | |
| 64 | 64 | engine.disable_symbol("eval"); | |
| 65 | 65 | ||
| 66 | - | // Register the goingson:: API module | |
| 66 | + | // Register the goingson:: API module. | |
| 67 | + | // | |
| 68 | + | // This is the ONLY host surface exposed to plugins: pure data helpers, no | |
| 69 | + | // filesystem, no network, no DB. A plugin's manifest may declare a | |
| 70 | + | // `network` capability, but nothing here backs it — it grants nothing | |
| 71 | + | // today (fail-closed by omission, ultra-fuzz Run #28 surprise). | |
| 72 | + | // | |
| 73 | + | // WARNING for future host functions: if you ever register a networking or | |
| 74 | + | // filesystem capability here, gate it on the plugin's manifest capability | |
| 75 | + | // AND force a fresh consent prompt. The reload escalation guard only fires | |
| 76 | + | // when a manifest *changes*; it will NOT re-prompt for an already-installed | |
| 77 | + | // `network = true` plugin when the host newly gains the capability, so | |
| 78 | + | // adding the host function alone would silently activate it. | |
| 67 | 79 | let goingson_module = create_goingson_module(); | |
| 68 | 80 | engine.register_static_module("goingson", Arc::new(goingson_module)); | |
| 69 | 81 |
| @@ -1,11 +1,10 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "$schema": "https://schemas.tauri.app/capabilities/2", | |
| 3 | 3 | "identifier": "desktop", | |
| 4 | - | "description": "Desktop-only capabilities (shell, notifications, OTA updater)", | |
| 4 | + | "description": "Desktop-only capabilities (notifications, OTA updater). External URLs are opened via the validated open_external_url command, not a JS shell binding.", | |
| 5 | 5 | "platforms": ["linux", "macOS", "windows"], | |
| 6 | 6 | "windows": ["main", "compose-*"], | |
| 7 | 7 | "permissions": [ | |
| 8 | - | "shell:allow-open", | |
| 9 | 8 | "notification:default", | |
| 10 | 9 | "updater:default", | |
| 11 | 10 | "process:allow-restart" |
| @@ -377,6 +377,7 @@ const api = { | |||
| 377 | 377 | setTitle: (title) => invoke('set_window_title', { title }), | |
| 378 | 378 | openCompose: (context) => invoke('open_compose_window', { context: context || null }), // Separate compose window, optional reply context | |
| 379 | 379 | openEmailInBrowser: (id) => invoke('open_email_in_browser', { id }), // Render HTML email to temp file → open | |
| 380 | + | openExternal: (url) => invoke('open_external_url', { url }), // Validated http(s) opener; JS shell.open is not granted | |
| 380 | 381 | }, | |
| 381 | 382 | }; | |
| 382 | 383 |
| @@ -10,6 +10,7 @@ | |||
| 10 | 10 | 'use strict'; | |
| 11 | 11 | const esc = GoingsOn.utils.escapeHtml; | |
| 12 | 12 | const escAttr = GoingsOn.utils.escapeAttr; | |
| 13 | + | const escAttrVal = GoingsOn.utils.escapeAttrValue; | |
| 13 | 14 | ||
| 14 | 15 | // ============ Email Selection & Pagination ============ | |
| 15 | 16 | ||
| @@ -489,7 +490,7 @@ | |||
| 489 | 490 | <button class="dropdown-item" onclick="GoingsOn.emails.createEventFromEmail('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');"> | |
| 490 | 491 | Convert to Event | |
| 491 | 492 | </button> | |
| 492 | - | <button class="dropdown-item" onclick="GoingsOn.emails.editLabels('${escAttr(latestEmail.id)}', ${escAttr(JSON.stringify(latestEmail.labels || []))}); this.parentElement.classList.remove('show');"> | |
| 493 | + | <button class="dropdown-item" onclick="GoingsOn.emails.editLabels('${escAttr(latestEmail.id)}', ${escAttrVal(JSON.stringify(latestEmail.labels || []))}); this.parentElement.classList.remove('show');"> | |
| 493 | 494 | Edit Labels | |
| 494 | 495 | </button> | |
| 495 | 496 | <button class="dropdown-item" onclick="GoingsOn.emails.moveToFolder('${escAttr(latestEmail.id)}'); this.parentElement.classList.remove('show');"> | |
| @@ -1078,7 +1079,7 @@ | |||
| 1078 | 1079 | <form id="label-form" onsubmit="event.preventDefault(); GoingsOn.emails._saveLabels('${escAttr(emailId)}');"> | |
| 1079 | 1080 | <div class="form-group"> | |
| 1080 | 1081 | <label class="form-label">Labels (comma-separated)</label> | |
| 1081 | - | <input type="text" class="form-input" id="label-input" value="${escAttr((currentLabels || []).join(', '))}" | |
| 1082 | + | <input type="text" class="form-input" id="label-input" value="${escAttrVal((currentLabels || []).join(', '))}" | |
| 1082 | 1083 | placeholder="work, important, follow-up" autofocus> | |
| 1083 | 1084 | ${existing.length > 0 ? `<div class="label-existing-line">Existing: ${existing.map(l => esc(l)).join(', ')}</div>` : ''} | |
| 1084 | 1085 | </div> |
| @@ -219,10 +219,12 @@ | |||
| 219 | 219 | GoingsOn.ui.showToast('Starting authentication...', 'info'); | |
| 220 | 220 | const authData = await GoingsOn.api.sync.startAuth(); | |
| 221 | 221 | ||
| 222 | - | // Open browser | |
| 223 | - | if (window.__TAURI__?.shell?.open) { | |
| 224 | - | await window.__TAURI__.shell.open(authData.authUrl); | |
| 225 | - | } else { | |
| 222 | + | // Open the auth URL via the validated Rust opener (the JS shell.open | |
| 223 | + | // permission is intentionally not granted). Fall back to the webview | |
| 224 | + | // on mobile / if the command is unavailable. | |
| 225 | + | try { | |
| 226 | + | await GoingsOn.api.window.openExternal(authData.authUrl); | |
| 227 | + | } catch (e) { | |
| 226 | 228 | window.open(authData.authUrl, '_blank'); | |
| 227 | 229 | } | |
| 228 | 230 |
| @@ -36,6 +36,25 @@ function escapeAttr(str) { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | /** | |
| 39 | + | * Escape a string for placement inside a double-quoted HTML attribute value. | |
| 40 | + | * | |
| 41 | + | * Unlike `escapeAttr` (which backslash-escapes for a JS-string context) and | |
| 42 | + | * `escapeHtml` (which, via textContent serialization, does NOT encode `"`), this | |
| 43 | + | * encodes `&` and `"` to HTML entities so the value cannot terminate the | |
| 44 | + | * attribute. Use it for any attacker-influenced content going into `value="..."`, | |
| 45 | + | * `title="..."`, a `data-*` attribute, or a JSON literal embedded in an inline | |
| 46 | + | * handler — the HTML parser decodes the entities before the value (or handler JS) | |
| 47 | + | * is used, so the original text/JSON is recovered intact. (ultra-fuzz Run #28 C1.) | |
| 48 | + | * @param {string} str | |
| 49 | + | * @returns {string} | |
| 50 | + | */ | |
| 51 | + | function escapeAttrValue(str) { | |
| 52 | + | return String(str ?? '') | |
| 53 | + | .replace(/&/g, '&') | |
| 54 | + | .replace(/"/g, '"'); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** | |
| 39 | 58 | * Human-friendly prefixes for machine-readable API error codes. | |
| 40 | 59 | * Backend sends structured ApiError { code, message, details }. | |
| 41 | 60 | */ | |
| @@ -627,6 +646,7 @@ GoingsOn.utils = { | |||
| 627 | 646 | // HTML escaping | |
| 628 | 647 | escapeHtml, | |
| 629 | 648 | escapeAttr, | |
| 649 | + | escapeAttrValue, | |
| 630 | 650 | getErrorMessage, | |
| 631 | 651 | showError, | |
| 632 | 652 |
| @@ -124,3 +124,70 @@ pub async fn set_window_title( | |||
| 124 | 124 | ||
| 125 | 125 | Ok(()) | |
| 126 | 126 | } | |
| 127 | + | ||
| 128 | + | /// Open an external URL in the user's default browser, after validating it is an | |
| 129 | + | /// `http(s)` URL. | |
| 130 | + | /// | |
| 131 | + | /// This is the only sanctioned path from the frontend to the system opener. The | |
| 132 | + | /// JS `shell:allow-open` permission is intentionally NOT granted, so a frontend | |
| 133 | + | /// scripting bug (e.g. an XSS in rendered email content) cannot reach the opener | |
| 134 | + | /// with an attacker-chosen target — every call funnels through this scheme check | |
| 135 | + | /// (ultra-fuzz Run #28 Security MINOR-1). On mobile the caller falls back to a | |
| 136 | + | /// normal `window.open`. | |
| 137 | + | #[tauri::command] | |
| 138 | + | #[instrument(skip_all)] | |
| 139 | + | pub async fn open_external_url( | |
| 140 | + | #[allow(unused_variables)] app: tauri::AppHandle, | |
| 141 | + | url: String, | |
| 142 | + | ) -> Result<(), ApiError> { | |
| 143 | + | let trimmed = url.trim(); | |
| 144 | + | if !is_external_http_url(trimmed) { | |
| 145 | + | return Err(ApiError::bad_request("Only http(s) URLs can be opened externally")); | |
| 146 | + | } | |
| 147 | + | #[cfg(not(any(target_os = "ios", target_os = "android")))] | |
| 148 | + | { | |
| 149 | + | use tauri_plugin_shell::ShellExt; | |
| 150 | + | app.shell() | |
| 151 | + | .open(trimmed.to_string(), None) | |
| 152 | + | .map_api_err("Failed to open URL", ApiError::internal)?; | |
| 153 | + | } | |
| 154 | + | Ok(()) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /// Whether `url` is an `http`/`https` URL safe to hand to the system opener. | |
| 158 | + | /// Rejects every other scheme (`file:`, `javascript:`, `data:`, custom-handler | |
| 159 | + | /// schemes) and scheme-relative or path inputs. | |
| 160 | + | fn is_external_http_url(url: &str) -> bool { | |
| 161 | + | let lower = url.to_ascii_lowercase(); | |
| 162 | + | lower.starts_with("https://") || lower.starts_with("http://") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[cfg(test)] | |
| 166 | + | mod tests { | |
| 167 | + | use super::is_external_http_url; | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn accepts_http_and_https() { | |
| 171 | + | assert!(is_external_http_url("https://accounts.google.com/o/oauth2/auth?x=1")); | |
| 172 | + | assert!(is_external_http_url("http://localhost:1421/callback")); | |
| 173 | + | assert!(is_external_http_url("HTTPS://EXAMPLE.COM")); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn rejects_dangerous_and_non_http_schemes() { | |
| 178 | + | for bad in [ | |
| 179 | + | "file:///etc/passwd", | |
| 180 | + | "javascript:alert(1)", | |
| 181 | + | "data:text/html,<script>alert(1)</script>", | |
| 182 | + | "mailto:a@b.com", | |
| 183 | + | "tel:+15551234", | |
| 184 | + | "smb://host/share", | |
| 185 | + | "/etc/passwd", | |
| 186 | + | "//evil.com", | |
| 187 | + | "ftp://host/x", | |
| 188 | + | "", | |
| 189 | + | ] { | |
| 190 | + | assert!(!is_external_http_url(bad), "should reject {bad:?}"); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | } |
| @@ -194,6 +194,7 @@ macro_rules! all_commands { | |||
| 194 | 194 | // Window | |
| 195 | 195 | $crate::commands::open_compose_window, | |
| 196 | 196 | $crate::commands::set_window_title, | |
| 197 | + | $crate::commands::open_external_url, | |
| 197 | 198 | // Search | |
| 198 | 199 | $crate::commands::search, | |
| 199 | 200 | // Daily Notes |
| @@ -370,53 +370,6 @@ impl CredentialStore { | |||
| 370 | 370 | } | |
| 371 | 371 | } | |
| 372 | 372 | } | |
| 373 | - | ||
| 374 | - | /// Migrate credentials from database to keychain. | |
| 375 | - | /// | |
| 376 | - | /// Call this on startup to move any plaintext credentials | |
| 377 | - | /// from SQLite to the secure keychain. | |
| 378 | - | /// | |
| 379 | - | /// # Arguments | |
| 380 | - | /// * `account_id` - The email account UUID | |
| 381 | - | /// * `oauth_access` - Access token from database (if any) | |
| 382 | - | /// * `oauth_refresh` - Refresh token from database (if any) | |
| 383 | - | /// * `password` - Password from database (if any) | |
| 384 | - | /// | |
| 385 | - | /// # Returns | |
| 386 | - | /// true if any credentials were migrated | |
| 387 | - | pub fn migrate_from_database( | |
| 388 | - | account_id: Uuid, | |
| 389 | - | oauth_access: Option<&str>, | |
| 390 | - | oauth_refresh: Option<&str>, | |
| 391 | - | password: Option<&str>, | |
| 392 | - | ) -> bool { | |
| 393 | - | let mut migrated = false; | |
| 394 | - | ||
| 395 | - | // Migrate OAuth tokens | |
| 396 | - | if let Some(access) = oauth_access { | |
| 397 | - | if !access.is_empty() && Self::get_oauth(account_id).is_none() { | |
| 398 | - | let creds = OAuthCredentials { | |
| 399 | - | access_token: access.to_string(), | |
| 400 | - | refresh_token: oauth_refresh.map(String::from), | |
| 401 | - | }; | |
| 402 | - | if Self::store_oauth(account_id, &creds).is_ok() { | |
| 403 | - | debug!("Migrated OAuth credentials for account {}", account_id); | |
| 404 | - | migrated = true; | |
| 405 | - | } | |
| 406 | - | } | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | // Migrate password | |
| 410 | - | if let Some(pwd) = password { | |
| 411 | - | if !pwd.is_empty() && Self::get_password(account_id).is_none() | |
| 412 | - | && Self::store_password(account_id, pwd).is_ok() { | |
| 413 | - | debug!("Migrated password for account {}", account_id); | |
| 414 | - | migrated = true; | |
| 415 | - | } | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | migrated | |
| 419 | - | } | |
| 420 | 373 | } | |
| 421 | 374 | ||
| 422 | 375 | #[cfg(test)] |