mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
fix(extension): harden desktop handoff
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: c35c001d15...b08c76517a
@@ -26,6 +26,8 @@ pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive<u16> = EXTENSION
|
|||||||
const MAX_URL_COUNT: usize = 200;
|
const MAX_URL_COUNT: usize = 200;
|
||||||
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
const SIGNATURE_MAX_AGE_MS: u64 = 60_000;
|
||||||
const SERVER_HEADER: &str = "x-firelink-server";
|
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>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
pub type SharedExtensionToken = Arc<RwLock<String>>;
|
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 {
|
async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
|
||||||
let mut response = next.run(request).await;
|
let mut response = next.run(request).await;
|
||||||
response
|
response
|
||||||
.headers_mut()
|
.headers_mut()
|
||||||
.insert(SERVER_HEADER, HeaderValue::from_static("1"));
|
.insert(SERVER_HEADER, HeaderValue::from_static("1"));
|
||||||
response
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(PROTOCOL_VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> {
|
async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> {
|
||||||
@@ -374,7 +379,7 @@ fn is_allowed_origin(origin: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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};
|
use axum::{http::StatusCode, middleware, routing::get, Router};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -391,8 +396,12 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let response = reqwest::get(format!("http://{address}/ping")).await.unwrap();
|
let response = reqwest::get(format!("http://{address}/ping")).await.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
|
||||||
|
"2"
|
||||||
|
);
|
||||||
|
|
||||||
server.abort();
|
server.abort();
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-9
@@ -3069,14 +3069,18 @@ fn hydrate_extension_pairing_token(
|
|||||||
app_state: tauri::State<'_, AppState>,
|
app_state: tauri::State<'_, AppState>,
|
||||||
) -> Result<PairingTokenHydration, String> {
|
) -> Result<PairingTokenHydration, String> {
|
||||||
let mut connection = database.lock()?;
|
let mut connection = database.lock()?;
|
||||||
// Frontend always skips keychain on regular hydration to avoid system prompts.
|
match crate::db::hydrate_pairing_token(&mut connection, false) {
|
||||||
match crate::db::hydrate_pairing_token(&mut connection, true) {
|
Ok((token, token_changed)) => {
|
||||||
Ok((token, token_changed)) => Ok(PairingTokenHydration {
|
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||||
token,
|
*pairing_token = token.clone();
|
||||||
token_changed,
|
}
|
||||||
persistent: false, // Explicitly false since we skipped the keychain
|
Ok(PairingTokenHydration {
|
||||||
error: None,
|
token,
|
||||||
}),
|
token_changed,
|
||||||
|
persistent: true,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let token = app_state
|
let token = app_state
|
||||||
.extension_pairing_token
|
.extension_pairing_token
|
||||||
@@ -3893,7 +3897,7 @@ pub fn run() {
|
|||||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||||
let initial_pairing_token = {
|
let initial_pairing_token = {
|
||||||
let mut connection = database.lock()?;
|
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,
|
Ok((token, _)) => token,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
|
|||||||
+3
-1
@@ -545,7 +545,9 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const unlistenExtension = listen('extension-add-download', (event) => {
|
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) => {
|
const unlistenDeepLink = listen('deep-link-add-download', (event) => {
|
||||||
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
useDownloadStore.getState().openAddModalWithUrls(event.payload);
|
||||||
|
|||||||
@@ -46,17 +46,17 @@ export const KeychainPermissionModal: React.FC = () => {
|
|||||||
<div className="p-2 bg-blue-500/10 rounded-full flex items-center justify-center">
|
<div className="p-2 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||||
<KeyRound size={20} className="text-blue-500" />
|
<KeyRound size={20} className="text-blue-500" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4">
|
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Firelink uses a browser extension to seamlessly capture downloads.
|
Firelink uses the browser extension to seamlessly capture downloads.
|
||||||
To securely store the pairing token that connects the app and the extension,
|
To securely store the pairing token that connects the app and the extension,
|
||||||
we need access to the macOS Keychain.
|
we need access to this system's credential store.
|
||||||
</p>
|
</p>
|
||||||
<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>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -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} />
|
<Check size={16} strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<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">
|
<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.
|
Currently, your extension will only stay connected for this session.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => settings.setShowKeychainModal(true)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ describe('useDownloadStore', () => {
|
|||||||
downloads: [],
|
downloads: [],
|
||||||
backendRegisteredIds: new Set(),
|
backendRegisteredIds: new Set(),
|
||||||
pendingOrder: [],
|
pendingOrder: [],
|
||||||
|
isAddModalOpen: false,
|
||||||
|
pendingAddUrls: '',
|
||||||
|
pendingAddReferer: '',
|
||||||
|
pendingAddFilename: '',
|
||||||
|
pendingAddHeaders: '',
|
||||||
|
pendingAddCookies: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,8 +373,8 @@ describe('useDownloadStore', () => {
|
|||||||
)?.[1] as any).item.queue_id).toBe('queue-a');
|
)?.[1] as any).item.queue_id).toBe('queue-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves extension request headers and cookies for the Add modal', () => {
|
it('preserves extension request headers and cookies for the Add modal', async () => {
|
||||||
useDownloadStore.getState().handleExtensionDownload({
|
await useDownloadStore.getState().handleExtensionDownload({
|
||||||
urls: ['https://example.com/file.bin'],
|
urls: ['https://example.com/file.bin'],
|
||||||
referer: 'https://example.com/page',
|
referer: 'https://example.com/page',
|
||||||
silent: false,
|
silent: false,
|
||||||
@@ -382,7 +388,45 @@ describe('useDownloadStore', () => {
|
|||||||
expect(state.pendingAddUrls).toBe('https://example.com/file.bin');
|
expect(state.pendingAddUrls).toBe('https://example.com/file.bin');
|
||||||
expect(state.pendingAddReferer).toBe('https://example.com/page');
|
expect(state.pendingAddReferer).toBe('https://example.com/page');
|
||||||
expect(state.pendingAddFilename).toBe('file.bin');
|
expect(state.pendingAddFilename).toBe('file.bin');
|
||||||
expect(state.pendingAddHeaders).toBe('X-Test: value');
|
expect(state.pendingAddHeaders).toBe('X-Test: value');
|
||||||
expect(state.pendingAddCookies).toBe('session=secret');
|
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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
|
|||||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||||
import type { Queue } from '../bindings/Queue';
|
import type { Queue } from '../bindings/Queue';
|
||||||
import { useSettingsStore } from './useSettingsStore';
|
import { useSettingsStore } from './useSettingsStore';
|
||||||
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||||
import {
|
import {
|
||||||
resolveCategoryDestination
|
resolveCategoryDestination
|
||||||
} from '../utils/downloadLocations';
|
} from '../utils/downloadLocations';
|
||||||
@@ -125,6 +125,16 @@ export const getSiteLogin = (url: string, settings: ReturnType<typeof useSetting
|
|||||||
return null;
|
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 syncSystemIntegrations = () => {
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
|
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
|
||||||
@@ -192,7 +202,7 @@ interface DownloadState {
|
|||||||
headers?: string | null,
|
headers?: string | null,
|
||||||
cookies?: string | null
|
cookies?: string | null
|
||||||
) => void;
|
) => void;
|
||||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
|
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
|
||||||
deleteModalState: DeleteModalState;
|
deleteModalState: DeleteModalState;
|
||||||
openDeleteModal: (downloadIds?: string | string[]) => void;
|
openDeleteModal: (downloadIds?: string | string[]) => void;
|
||||||
closeDeleteModal: () => void;
|
closeDeleteModal: () => void;
|
||||||
@@ -302,10 +312,28 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
pendingAddCookies: cookies?.trim() || state.pendingAddCookies || ''
|
pendingAddCookies: cookies?.trim() || state.pendingAddCookies || ''
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
handleExtensionDownload: (request) => {
|
handleExtensionDownload: async (request) => {
|
||||||
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
|
||||||
if (urls.length === 0) return;
|
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(
|
get().openAddModalWithUrls(
|
||||||
urls.join('\n'),
|
urls.join('\n'),
|
||||||
request.referer,
|
request.referer,
|
||||||
|
|||||||
Reference in New Issue
Block a user