fix(extension): harden desktop handoff

This commit is contained in:
NimBold
2026-06-24 21:06:20 +03:30
parent 6f426c1a18
commit 2fe1683f4b
8 changed files with 122 additions and 35 deletions
+16 -7
View File
@@ -26,6 +26,8 @@ pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION
const MAX_URL_COUNT: usize = 200;
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
const SERVER_HEADER: &str = "x-firelink-server";
const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version";
const PROTOCOL_VERSION: &str = "2";
type HmacSha256 = Hmac<Sha256>;
pub type SharedExtensionToken = Arc<RwLock<String>>;
@@ -122,10 +124,13 @@ pub async fn start_server(
async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.insert(SERVER_HEADER, HeaderValue::from_static("1"));
response
response
.headers_mut()
.insert(SERVER_HEADER, HeaderValue::from_static("1"));
response
.headers_mut()
.insert(PROTOCOL_VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
response
}
async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> {
@@ -374,7 +379,7 @@ fn is_allowed_origin(origin: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{add_server_identity, SERVER_HEADER};
use super::{add_server_identity, PROTOCOL_VERSION_HEADER, SERVER_HEADER};
use axum::{http::StatusCode, middleware, routing::get, Router};
#[tokio::test]
@@ -391,8 +396,12 @@ mod tests {
});
let response = reqwest::get(format!("http://{address}/ping")).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
"2"
);
server.abort();
}
+13 -9
View File
@@ -3069,14 +3069,18 @@ fn hydrate_extension_pairing_token(
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
let mut connection = database.lock()?;
// Frontend always skips keychain on regular hydration to avoid system prompts.
match crate::db::hydrate_pairing_token(&mut connection, true) {
Ok((token, token_changed)) => Ok(PairingTokenHydration {
token,
token_changed,
persistent: false, // Explicitly false since we skipped the keychain
error: None,
}),
match crate::db::hydrate_pairing_token(&mut connection, false) {
Ok((token, token_changed)) => {
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
Ok(PairingTokenHydration {
token,
token_changed,
persistent: true,
error: None,
})
}
Err(error) => {
let token = app_state
.extension_pairing_token
@@ -3893,7 +3897,7 @@ pub fn run() {
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
let initial_pairing_token = {
let mut connection = database.lock()?;
match crate::db::hydrate_pairing_token(&mut connection, true) {
match crate::db::hydrate_pairing_token(&mut connection, false) {
Ok((token, _)) => token,
Err(error) => {
log::warn!(
+3 -1
View File
@@ -545,7 +545,9 @@ function App() {
});
const unlistenExtension = listen('extension-add-download', (event) => {
useDownloadStore.getState().handleExtensionDownload(event.payload);
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
console.error('Failed to handle browser extension download:', error);
});
});
const unlistenDeepLink = listen('deep-link-add-download', (event) => {
useDownloadStore.getState().openAddModalWithUrls(event.payload);
+5 -5
View File
@@ -46,17 +46,17 @@ export const KeychainPermissionModal: React.FC = () => {
<div className="p-2 bg-blue-500/10 rounded-full flex items-center justify-center">
<KeyRound size={20} className="text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Keychain Access Needed</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">Credential Storage Access Needed</h2>
</div>
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4">
<p>
Firelink uses a browser extension to seamlessly capture downloads.
To securely store the pairing token that connects the app and the extension,
we need access to the macOS Keychain.
Firelink uses the browser extension to seamlessly capture downloads.
To securely store the pairing token that connects the app and the extension,
we need access to this system's credential store.
</p>
<p>
<strong>Note:</strong> Firelink only requests access to its own dedicated entry in the Keychain. It cannot and will not access any other passwords or Keychain items on your system.
<strong>Note:</strong> Firelink only requests access to its own dedicated credential entry. It cannot and will not access other saved passwords or credential items on your system.
</p>
{error && (
+5 -5
View File
@@ -1061,9 +1061,9 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
<Check size={16} strokeWidth={2.5} />
</div>
<div className="flex-1">
<h4 className="text-sm font-semibold text-green-500 m-0">Keychain Access Granted</h4>
<h4 className="text-sm font-semibold text-green-500 m-0">Credential Storage Available</h4>
<p className="text-xs text-text-secondary m-0 mt-0.5">
Your pairing token is securely saved and will persist across restarts.
Your pairing token is securely saved in this system's credential store and will persist across restarts.
</p>
</div>
</div>
@@ -1071,16 +1071,16 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 flex items-start gap-3">
<ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-text-primary mb-1">Keychain Access Needed</h4>
<h4 className="text-sm font-semibold text-text-primary mb-1">Credential Storage Needed</h4>
<p className="text-xs text-text-secondary mb-3">
Firelink needs macOS Keychain access to securely save your pairing token across app restarts.
Firelink needs access to this system's credential store to securely save your pairing token across app restarts.
Currently, your extension will only stay connected for this session.
</p>
<button
onClick={() => settings.setShowKeychainModal(true)}
className="px-4 py-1.5 rounded-md text-xs font-medium transition-colors bg-accent text-white hover:bg-accent/90 shadow-sm"
>
Grant Keychain Access
Grant Credential Access
</button>
</div>
</div>
+48 -4
View File
@@ -55,6 +55,12 @@ describe('useDownloadStore', () => {
downloads: [],
backendRegisteredIds: new Set(),
pendingOrder: [],
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
pendingAddFilename: '',
pendingAddHeaders: '',
pendingAddCookies: '',
});
});
@@ -367,8 +373,8 @@ describe('useDownloadStore', () => {
)?.[1] as any).item.queue_id).toBe('queue-a');
});
it('preserves extension request headers and cookies for the Add modal', () => {
useDownloadStore.getState().handleExtensionDownload({
it('preserves extension request headers and cookies for the Add modal', async () => {
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/file.bin'],
referer: 'https://example.com/page',
silent: false,
@@ -382,7 +388,45 @@ describe('useDownloadStore', () => {
expect(state.pendingAddUrls).toBe('https://example.com/file.bin');
expect(state.pendingAddReferer).toBe('https://example.com/page');
expect(state.pendingAddFilename).toBe('file.bin');
expect(state.pendingAddHeaders).toBe('X-Test: value');
expect(state.pendingAddCookies).toBe('session=secret');
expect(state.pendingAddHeaders).toBe('X-Test: value');
expect(state.pendingAddCookies).toBe('session=secret');
});
it('starts silent extension captures through backend queue', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'get_pending_order') return ['silent-id'];
return undefined;
});
vi.spyOn(crypto, 'randomUUID').mockReturnValueOnce('silent-id' as `${string}-${string}-${string}-${string}-${string}`);
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/downloads/report.pdf'],
referer: 'https://example.com/page',
silent: true,
filename: 'report.pdf',
headers: 'User-Agent: Test',
cookies: 'session=secret'
});
const state = useDownloadStore.getState();
expect(state.isAddModalOpen).toBe(false);
expect(state.downloads[0]).toEqual(expect.objectContaining({
id: 'silent-id',
url: 'https://example.com/downloads/report.pdf',
fileName: 'report.pdf',
category: 'Documents',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
}));
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
id: 'silent-id',
headers: 'User-Agent: Test',
cookies: 'session=secret'
})
})
);
});
});
+31 -3
View File
@@ -7,7 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -125,6 +125,16 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
return null;
};
const suggestedFileNameFromUrl = (rawUrl: string): string => {
try {
const url = new URL(rawUrl);
const lastSegment = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() || '');
return lastSegment || 'download';
} catch {
return 'download';
}
};
const syncSystemIntegrations = () => {
const settings = useSettingsStore.getState();
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
@@ -192,7 +202,7 @@ interface DownloadState {
headers?: string | null,
cookies?: string | null
) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
deleteModalState: DeleteModalState;
openDeleteModal: (downloadIds?: string | string[]) => void;
closeDeleteModal: () => void;
@@ -302,10 +312,28 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingAddCookies: cookies?.trim() || state.pendingAddCookies || ''
};
}),
handleExtensionDownload: (request) => {
handleExtensionDownload: async (request) => {
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
if (urls.length === 0) return;
if (request.silent) {
for (const url of urls) {
const filename = canonicalizeDownloadFileName(
urls.length === 1 && request.filename ? request.filename : suggestedFileNameFromUrl(url)
);
await get().addDownload({
id: crypto.randomUUID(),
url,
fileName: filename,
category: categoryForFileName(filename),
dateAdded: new Date().toISOString(),
headers: request.headers?.trim() || undefined,
cookies: urls.length === 1 ? request.cookies?.trim() || undefined : undefined,
}, { type: 'start-now' });
}
return;
}
get().openAddModalWithUrls(
urls.join('\n'),
request.referer,