Skip to main content

max / goingson

fix(security): keychain email passwords, SMTP STARTTLS floor, IPC OAuth callback, CSP Axis 2 of the Run #26 ultra-fuzz remediation. UF-5: route password-account create/update through CredentialStore::store_password and write "" to the DB password column (mirrors the OAuth path). get_account_password now prefers the keychain, falling back to the column only for legacy accounts. Clear the keychain entry on delete; route the latent ImapProvider::new path through it too. UF-6: build_mailer always negotiates STARTTLS. Remove builder_dangerous and the use_tls downgrade so credentials and OAuth tokens are never sent in cleartext. UF-low: remove the unauthenticated /result HTTP endpoint and the wildcard CORS header. Deliver the OAuth callback over IPC via poll_oauth_result, keeping the callback server alive in AppState and tearing it down on completion. Email and sync flows poll via IPC. Hardening: set a real CSP (default-src self, connect-src locked to ipc, object-src none, restricted base-uri/frame-ancestors/form-action). withGlobalTauri stays on because the frontend reads window.__TAURI__ directly.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 01:14 UTC
Signed with PGP, not checked
Commit: 622bc51e0121818c0aad06a157dc097e72b881f3
Parent: cc8e3ed
15 files changed, +277 insertions, -161 deletions
@@ -23,7 +23,7 @@
23 23 }
24 24 ],
25 25 "security": {
26 - "csp": null
26 + "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
27 27 }
28 28 },
29 29 "bundle": {
@@ -207,6 +207,7 @@
207 207 // OAuth
208 208 $crate::commands::list_oauth_providers,
209 209 $crate::commands::start_oauth,
210 + $crate::commands::poll_oauth_result,
210 211 $crate::commands::complete_oauth,
211 212 $crate::commands::refresh_oauth_tokens,
212 213 $crate::commands::disconnect_oauth,
@@ -57,6 +57,10 @@
57 57 pub token_refresh_locks: Arc<Mutex<std::collections::HashMap<uuid::Uuid, Arc<TokioMutex<()>>>>>,
58 58 /// Pending OAuth flows keyed by state token (CSRF + PKCE verifier stored server-side).
59 59 pub pending_oauth_flows: Arc<Mutex<std::collections::HashMap<String, PendingOAuthFlow>>>,
60 + /// Live OAuth callback servers keyed by loopback port. The callback result is
61 + /// polled through the trusted IPC layer (`poll_oauth_result`) rather than an
62 + /// unauthenticated local HTTP endpoint.
63 + pub pending_oauth_servers: Arc<Mutex<std::collections::HashMap<u16, crate::oauth::OAuthCallbackServer>>>,
60 64 pub data_dir: PathBuf,
61 65 }
62 66
@@ -164,6 +168,7 @@
164 168 email_sync_locks: Arc::new(Mutex::new(std::collections::HashSet::new())),
165 169 token_refresh_locks: Arc::new(Mutex::new(std::collections::HashMap::new())),
166 170 pending_oauth_flows: Arc::new(Mutex::new(std::collections::HashMap::new())),
171 + pending_oauth_servers: Arc::new(Mutex::new(std::collections::HashMap::new())),
167 172 data_dir: app_data_dir,
168 173 })
169 174 }
@@ -77,6 +77,7 @@
77 77 email_sync_locks: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
78 78 token_refresh_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
79 79 pending_oauth_flows: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
80 + pending_oauth_servers: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
80 81 data_dir: std::path::PathBuf::from("/tmp/goingson-test"),
81 82 };
82 83
@@ -217,6 +217,7 @@
217 217 oauth: {
218 218 listProviders: () => invoke('list_oauth_providers'),
219 219 start: (providerId) => invoke('start_oauth', { providerId }), // Opens browser for consent
220 + pollResult: (port) => invoke('poll_oauth_result', { port }), // Polls the loopback callback over IPC
220 221 complete: (input) => invoke('complete_oauth', { input }), // Exchanges auth code for tokens
221 222 refreshTokens: (accountId) => invoke('refresh_oauth_tokens', { accountId }),
222 223 disconnect: (accountId) => invoke('disconnect_oauth', { accountId }),
@@ -694,26 +694,17 @@
694 694 }
695 695
696 696 try {
697 - // Check if there's a callback response available
698 - // The callback server writes to a temp location we can poll
699 - // Poll the local OAuth callback server. Expected to fail
700 - // repeatedly until the user completes the browser auth flow.
701 - const response = await fetch(`http://127.0.0.1:${port}/result`, {
702 - method: 'GET',
703 - mode: 'cors',
704 - }).catch(() => null);
705 -
706 - if (response && response.ok) {
707 - const data = await response.json();
708 - if (data.code) {
709 - await completeOAuth(data.code, data.state);
710 - return;
711 - } else if (data.error) {
712 - GoingsOn.ui.showToast('OAuth error: ' + data.error, 'error');
713 - pendingOAuthState = null;
714 - refreshAccountsView();
715 - return;
716 - }
697 + // Poll the local callback server over IPC. Stays "pending"
698 + // until the user completes the browser auth flow.
699 + const data = await GoingsOn.api.oauth.pollResult(port);
700 + if (data.status === 'success' && data.code) {
701 + await completeOAuth(data.code, data.state);
702 + return;
703 + } else if (data.status === 'error') {
704 + GoingsOn.ui.showToast('OAuth error: ' + (data.error || 'unknown'), 'error');
705 + pendingOAuthState = null;
706 + refreshAccountsView();
707 + return;
717 708 }
718 709 } catch (e) {
719 710 // Ignore polling errors
@@ -246,37 +246,34 @@
246 246 }
247 247
248 248 try {
249 - const resp = await fetch(`http://127.0.0.1:${port}/result`);
250 - if (resp.status === 200) {
251 - const data = await resp.json();
249 + // Poll the local callback server over IPC (no HTTP endpoint).
250 + const data = await GoingsOn.api.oauth.pollResult(port);
252 251
253 - // Still waiting for the browser redirect
254 - if (data.status === 'pending') return;
252 + // Still waiting for the browser redirect
253 + if (data.status === 'pending') return;
255 254
256 - clearInterval(pollInterval);
255 + clearInterval(pollInterval);
257 256
258 - if (data.code) {
259 - // Complete auth
260 - try {
261 - await GoingsOn.api.sync.completeAuth({
262 - code: data.code,
263 - state: data.state,
264 - });
265 - GoingsOn.ui.showToast('Connected to Makenot.work!');
266 - refreshSyncIndicator();
267 - // Force re-render: open settings overlay and show sync section.
268 - await GoingsOn.settings.open();
269 - await GoingsOn.settings.showSection('sync');
270 - } catch (err) {
271 - GoingsOn.ui.showToast('Auth failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
272 - }
273 - } else if (data.error) {
274 - GoingsOn.ui.showToast('Auth error: ' + data.error, 'error');
257 + if (data.status === 'success' && data.code) {
258 + // Complete auth
259 + try {
260 + await GoingsOn.api.sync.completeAuth({
261 + code: data.code,
262 + state: data.state,
263 + });
264 + GoingsOn.ui.showToast('Connected to Makenot.work!');
265 + refreshSyncIndicator();
266 + // Force re-render: open settings overlay and show sync section.
267 + await GoingsOn.settings.open();
268 + await GoingsOn.settings.showSection('sync');
269 + } catch (err) {
270 + GoingsOn.ui.showToast('Auth failed: ' + GoingsOn.utils.getErrorMessage(err), 'error');
275 271 }
272 + } else if (data.status === 'error') {
273 + GoingsOn.ui.showToast('Auth error: ' + (data.error || 'unknown'), 'error');
276 274 }
277 - // 204 = still waiting, continue polling
278 275 } catch (_) {
279 - // Server not ready yet or gone, keep polling
276 + // Command briefly unavailable, keep polling
280 277 }
281 278 }, 1000);
282 279 }
@@ -625,7 +625,6 @@
625 625 account.smtp_port as u16,
626 626 &account.email_address,
627 627 &access_token,
628 - true,
629 628 );
630 629 smtp_client
631 630 .send_message(&params)
@@ -28,19 +28,21 @@
28 28 account.is_oauth() && !uses_jmap(account)
29 29 }
30 30
31 + /// Resolves an account password, preferring the secure keychain over the
32 + /// legacy plaintext `password` column.
33 + ///
34 + /// New accounts store the secret only in the keychain (the DB column is `""`);
35 + /// the column is consulted solely for accounts created by older versions that
36 + /// have not yet been re-saved.
37 + fn resolve_password(keychain: Option<String>, db_column: &str) -> Option<String> {
38 + keychain.or_else(|| (!db_column.is_empty()).then(|| db_column.to_string()))
39 + }
40 +
31 41 /// Gets the password for a password-based email account.
32 42 pub(super) fn get_account_password(account: &EmailAccount) -> Result<String, ApiError> {
33 - if !account.password.is_empty() {
34 - return Ok(account.password.clone());
35 - }
36 -
37 - // Migration: check keychain for passwords stored by older versions
38 43 let raw_id: Uuid = account.id.into();
39 - if let Some(password) = CredentialStore::get_password(raw_id) {
40 - return Ok(password);
41 - }
42 -
43 - Err(ApiError::auth("No password available"))
44 + resolve_password(CredentialStore::get_password(raw_id), &account.password)
45 + .or_api_err(|| ApiError::auth("No password available"))
44 46 }
45 47
46 48 /// Gets a valid access token for an OAuth account, refreshing if needed.
@@ -261,6 +263,8 @@
261 263 }
262 264 }
263 265
266 + // Never write the password to the DB column; store it in the OS keychain
267 + // and leave the column empty (mirrors the OAuth token path).
264 268 let account = state.email_accounts
265 269 .create(
266 270 DESKTOP_USER_ID,
@@ -271,12 +275,18 @@
271 275 &input.smtp_server,
272 276 input.smtp_port,
273 277 &input.username,
274 - &input.password,
278 + "",
275 279 input.use_tls,
276 280 input.archive_folder_name.as_deref(),
277 281 )
278 282 .await?;
279 283
284 + if !input.password.is_empty() {
285 + let raw_id: Uuid = account.id.into();
286 + CredentialStore::store_password(raw_id, &input.password)
287 + .map_api_err("Failed to store password", ApiError::internal)?;
288 + }
289 +
280 290 Ok(account)
281 291 }
282 292
@@ -297,6 +307,19 @@
297 307 }
298 308 }
299 309
310 + // Route a changed password through the keychain. Pass `Some("")` to the
311 + // repo so any stale plaintext in the column is cleared; `None` leaves the
312 + // column untouched when the user did not change the password.
313 + let db_password = match input.password.as_deref() {
314 + Some(pwd) if !pwd.is_empty() => {
315 + let raw_id: Uuid = id.into();
316 + CredentialStore::store_password(raw_id, pwd)
317 + .map_api_err("Failed to store password", ApiError::internal)?;
318 + Some("")
319 + }
320 + _ => None,
321 + };
322 +
300 323 state.email_accounts
301 324 .update(
302 325 id,
@@ -308,7 +331,7 @@
308 331 &input.smtp_server,
309 332 input.smtp_port,
310 333 &input.username,
311 - input.password.as_deref(),
334 + db_password,
312 335 input.use_tls,
313 336 input.archive_folder_name.as_deref(),
314 337 )
@@ -322,6 +345,7 @@
322 345 pub async fn delete_email_account(state: State<'_, Arc<AppState>>, id: EmailAccountId) -> Result<bool, ApiError> {
323 346 let raw_id: Uuid = id.into();
324 347 let _ = CredentialStore::delete_oauth(raw_id);
348 + let _ = CredentialStore::delete_password(raw_id);
325 349 Ok(state.email_accounts.delete(id, DESKTOP_USER_ID).await?)
326 350 }
327 351
@@ -450,7 +474,6 @@
450 474 account.smtp_port as u16,
451 475 &account.email_address,
452 476 &access_token,
453 - true,
454 477 );
455 478
456 479 let (smtp_success, smtp_message) = match smtp_client.test_connection().await {
@@ -514,3 +537,32 @@
514 537 available_folders: folders,
515 538 })
516 539 }
540 +
541 + #[cfg(test)]
542 + mod tests {
543 + use super::resolve_password;
544 +
545 + #[test]
546 + fn keychain_password_is_preferred_over_db_column() {
547 + // A keychain hit always wins, even when a legacy plaintext column is present.
548 + assert_eq!(
549 + resolve_password(Some("secret".to_string()), "stale_plaintext"),
550 + Some("secret".to_string())
551 + );
552 + }
553 +
554 + #[test]
555 + fn db_column_is_used_only_when_keychain_is_empty() {
556 + // Legacy accounts created before the keychain migration fall back to the column.
557 + assert_eq!(
558 + resolve_password(None, "legacy_plaintext"),
559 + Some("legacy_plaintext".to_string())
560 + );
561 + }
562 +
563 + #[test]
564 + fn no_password_anywhere_resolves_to_none() {
565 + // New accounts with the column blanked and nothing in the keychain have no password.
566 + assert_eq!(resolve_password(None, ""), None);
567 + }
568 + }
@@ -72,6 +72,49 @@
72 72 pub provider_name: String,
73 73 }
74 74
75 + /// Result of polling a pending OAuth callback.
76 + ///
77 + /// Mirrors the callback server's stored state. `status` is one of
78 + /// `pending`, `success`, or `error`.
79 + #[derive(Debug, Serialize)]
80 + #[serde(rename_all = "camelCase")]
81 + pub struct OAuthPollResponse {
82 + pub status: String,
83 + pub code: Option<String>,
84 + pub state: Option<String>,
85 + pub error: Option<String>,
86 + pub description: Option<String>,
87 + }
88 +
89 + impl From<crate::oauth::StoredCallback> for OAuthPollResponse {
90 + fn from(stored: crate::oauth::StoredCallback) -> Self {
91 + use crate::oauth::StoredCallback;
92 + match stored {
93 + StoredCallback::Pending => OAuthPollResponse {
94 + status: "pending".to_string(),
95 + code: None,
96 + state: None,
97 + error: None,
98 + description: None,
99 + },
100 + StoredCallback::Success { code, state } => OAuthPollResponse {
101 + status: "success".to_string(),
102 + code: Some(code),
103 + state: Some(state),
104 + error: None,
105 + description: None,
106 + },
107 + StoredCallback::Error { error, description } => OAuthPollResponse {
108 + status: "error".to_string(),
109 + code: None,
110 + state: None,
111 + error: Some(error),
112 + description,
113 + },
114 + }
115 + }
116 + }
117 +
75 118 // ============ Commands ============
76 119
77 120 /// Lists available OAuth providers.
@@ -136,6 +179,13 @@
136 179 });
137 180 }
138 181
182 + // Keep the callback server alive so the frontend can poll its result over
183 + // IPC (see `poll_oauth_result`).
184 + {
185 + let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
186 + servers.insert(port, callback_server);
187 + }
188 +
139 189 Ok(OAuthStartResponse {
140 190 auth_url: start_result.auth_url,
141 191 state: start_result.state,
@@ -144,6 +194,28 @@
144 194 })
145 195 }
146 196
197 + /// Polls the local OAuth callback server for a result.
198 + ///
199 + /// The frontend calls this on an interval after opening the browser. Delivering
200 + /// the authorization code over IPC (instead of an unauthenticated HTTP endpoint)
201 + /// keeps it out of reach of other local processes on the loopback port.
202 + ///
203 + /// Returns `{status: "pending"}` until the redirect arrives, then a `success`
204 + /// (with code/state) or `error` payload. An unknown port also reports `pending`.
205 + #[tauri::command]
206 + #[instrument(skip_all)]
207 + pub async fn poll_oauth_result(
208 + state: State<'_, Arc<AppState>>,
209 + port: u16,
210 + ) -> Result<OAuthPollResponse, ApiError> {
211 + let servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
212 + let stored = match servers.get(&port) {
213 + Some(server) => server.poll(),
214 + None => crate::oauth::StoredCallback::Pending,
215 + };
216 + Ok(stored.into())
217 + }
218 +
147 219 /// Completes OAuth with an authorization code.
148 220 ///
149 221 /// Called after the browser redirects back with the code.
@@ -167,6 +239,12 @@
167 239 flows.remove(&input.state)
168 240 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
169 241
242 + // The callback has been consumed; tear down its loopback server.
243 + {
244 + let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
245 + servers.remove(&flow.port);
246 + }
247 +
170 248 let token_manager = TokenManager::from_env();
171 249 let provider = token_manager
172 250 .provider(&flow.provider_id)
@@ -182,6 +182,13 @@
182 182 });
183 183 }
184 184
185 + // Keep the callback server alive so the frontend can poll its result over
186 + // IPC (`poll_oauth_result`) instead of an unauthenticated HTTP endpoint.
187 + {
188 + let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
189 + servers.insert(port, callback_server);
190 + }
191 +
185 192 Ok(SyncAuthStartResponse {
186 193 auth_url,
187 194 state: csrf_state,
@@ -204,6 +211,12 @@
204 211 flows.remove(&input.state)
205 212 }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?;
206 213
214 + // The callback has been consumed; tear down its loopback server.
215 + {
216 + let mut servers = state.pending_oauth_servers.lock().unwrap_or_else(|e| e.into_inner());
217 + servers.remove(&flow.port);
218 + }
219 +
207 220 let (user_id, app_id) = client
208 221 .authenticate_with_code(&input.code, &flow.code_verifier, flow.port, "__internal__")
209 222 .await
@@ -131,9 +131,13 @@
131 131
132 132 impl ImapProvider {
133 133 pub fn new(account: &EmailAccount) -> Self {
134 + // Prefer the keychain; fall back to the legacy plaintext column only
135 + // for accounts created before credentials moved to secure storage.
136 + let password = crate::oauth::CredentialStore::get_password(account.id.into())
137 + .unwrap_or_else(|| account.password.clone());
134 138 Self {
135 - imap_client: ImapClient::with_password(account, &account.password),
136 - smtp_client: SmtpClient::new(account),
139 + imap_client: ImapClient::with_password(account, &password),
140 + smtp_client: SmtpClient::with_password(account, &password),
137 141 }
138 142 }
139 143 }
@@ -131,7 +131,6 @@
131 131 port: u16,
132 132 auth: SmtpAuth,
133 133 from_address: String,
134 - use_tls: bool,
135 134 }
136 135
137 136 impl SmtpClient {
@@ -148,7 +147,6 @@
148 147 password: account.password.clone(),
149 148 },
150 149 from_address: account.email_address.clone(),
151 - use_tls: account.use_tls,
152 150 }
153 151 }
154 152
@@ -164,7 +162,6 @@
164 162 password: password.to_string(),
165 163 },
166 164 from_address: account.email_address.clone(),
167 - use_tls: account.use_tls,
168 165 }
169 166 }
170 167
@@ -174,7 +171,6 @@
174 171 port: u16,
175 172 email: &str,
176 173 access_token: &str,
177 - use_tls: bool,
178 174 ) -> Self {
179 175 Self {
180 176 server: server.to_string(),
@@ -184,47 +180,33 @@
184 180 access_token: access_token.to_string(),
185 181 },
186 182 from_address: email.to_string(),
187 - use_tls,
188 183 }
189 184 }
190 185
186 + /// Builds an SMTP transport with a mandatory encryption floor.
187 + ///
188 + /// The transport always negotiates STARTTLS, so credentials and OAuth
189 + /// access tokens are never transmitted over an unencrypted connection. A
190 + /// server that does not offer STARTTLS fails the handshake rather than
191 + /// silently downgrading to cleartext (the previous `builder_dangerous`
192 + /// path, which leaked secrets whenever the account had TLS unticked).
191 193 fn build_mailer(&self) -> Result<AsyncSmtpTransport<Tokio1Executor>, String> {
192 - match &self.auth {
193 - SmtpAuth::Password { username, password } => {
194 - let creds = Credentials::new(username.clone(), password.clone());
195 - if self.use_tls {
196 - Ok(AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.server)
197 - .map_err(|e| format!("SMTP relay error: {}", e))?
198 - .credentials(creds)
199 - .port(self.port)
200 - .build())
201 - } else {
202 - Ok(AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.server)
203 - .credentials(creds)
204 - .port(self.port)
205 - .build())
206 - }
207 - }
208 - SmtpAuth::XOAuth2 { email, access_token } => {
209 - // For XOAUTH2, we use the email as username and access_token as password
210 - // with the Xoauth2 mechanism
211 - let creds = Credentials::new(email.clone(), access_token.clone());
212 - if self.use_tls {
213 - Ok(AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.server)
214 - .map_err(|e| format!("SMTP relay error: {}", e))?
215 - .credentials(creds)
216 - .authentication(vec![Mechanism::Xoauth2])
217 - .port(self.port)
218 - .build())
219 - } else {
220 - Ok(AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.server)
221 - .credentials(creds)
222 - .authentication(vec![Mechanism::Xoauth2])
223 - .port(self.port)
224 - .build())
225 - }
226 - }
227 - }
194 + let builder = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.server)
195 + .map_err(|e| format!("SMTP relay error: {}", e))?
196 + .port(self.port);
197 +
198 + let mailer = match &self.auth {
199 + SmtpAuth::Password { username, password } => builder
200 + .credentials(Credentials::new(username.clone(), password.clone()))
201 + .build(),
202 + SmtpAuth::XOAuth2 { email, access_token } => builder
203 + // For XOAUTH2 the email is the username and the access token is
204 + // the secret, negotiated with the Xoauth2 mechanism.
205 + .credentials(Credentials::new(email.clone(), access_token.clone()))
206 + .authentication(vec![Mechanism::Xoauth2])
207 + .build(),
208 + };
209 + Ok(mailer)
228 210 }
229 211
230 212 pub async fn send_message(&self, params: &SendParams<'_>) -> Result<String, String> {