fix(downloads): preserve authenticated capture metadata

Keep Gmail attachment filenames and origin-scoped browser cookies intact through metadata redirects, while rejecting Google sign-in responses.\n\nFixes #21
This commit is contained in:
NimBold
2026-07-16 22:58:12 +03:30
parent 469faed7b9
commit a56b859151
9 changed files with 406 additions and 32 deletions
+126 -6
View File
@@ -63,9 +63,18 @@ struct ExtensionRequest {
#[serde(default)]
cookies: Option<String>,
#[serde(default)]
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
#[serde(default)]
media: bool,
}
#[derive(Clone, Deserialize, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct ExtensionCookieScope {
pub url: String,
pub cookies: String,
}
#[derive(Clone, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct ExtensionDownload {
@@ -75,6 +84,7 @@ pub struct ExtensionDownload {
filename: Option<String>,
headers: Option<String>,
cookies: Option<String>,
cookie_scopes: Option<Vec<ExtensionCookieScope>>,
media: bool,
}
@@ -314,7 +324,7 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool {
false
}
fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
fn normalize_download(mut payload: ExtensionRequest) -> Option<ExtensionDownload> {
let mut seen = HashSet::new();
let urls = payload
.urls
@@ -345,13 +355,26 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
// request headers, but drop Cookie headers and the dedicated cookie field
// so a legacy or untrusted caller cannot reuse one session across hosts.
let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1);
let cookies = if !payload.media && urls.len() == 1 {
payload
.cookies
.filter(|value| !value.trim().is_empty())
let cookie_scopes = if !payload.media && urls.len() == 1 {
let mut scopes = payload.cookie_scopes.take().unwrap_or_default();
if let Some(cookies) = payload.cookies.take() {
if !cookies.trim().is_empty() {
scopes.push(ExtensionCookieScope {
url: urls[0].clone(),
cookies,
});
}
}
normalize_cookie_scopes(scopes)
} else {
None
};
let cookies = cookie_scopes.as_ref().and_then(|scopes| {
scopes
.iter()
.find(|scope| same_origin_url(&scope.url, &urls[0]))
.map(|scope| scope.cookies.clone())
});
Some(ExtensionDownload {
urls,
@@ -365,10 +388,62 @@ fn normalize_download(payload: ExtensionRequest) -> Option<ExtensionDownload> {
// builds pay for a doomed metadata request before retrying. Ordinary
// captured downloads still need their exact request cookies.
cookies,
cookie_scopes,
media: payload.media,
})
}
fn normalize_cookie_scopes(scopes: Vec<ExtensionCookieScope>) -> Option<Vec<ExtensionCookieScope>> {
let mut normalized = Vec::new();
let mut seen_origins = HashSet::new();
for scope in scopes {
let Ok(url) = Url::parse(scope.url.trim()) else {
continue;
};
if !matches!(url.scheme(), "http" | "https") {
continue;
}
let cookies = scope.cookies.trim();
if cookies.is_empty() {
continue;
}
let Some(host) = url.host_str() else {
continue;
};
let origin = format!(
"{}://{}:{}",
url.scheme(),
host,
url.port_or_known_default().unwrap_or(443)
);
if !seen_origins.insert(origin) {
continue;
}
normalized.push(ExtensionCookieScope {
url: url.to_string(),
cookies: cookies.to_string(),
});
if normalized.len() >= 16 {
break;
}
}
(!normalized.is_empty()).then_some(normalized)
}
fn same_origin_url(left: &str, right: &str) -> bool {
let Some(left) = Url::parse(left).ok() else {
return false;
};
let Some(right) = Url::parse(right).ok() else {
return false;
};
left.scheme() == right.scheme()
&& left.host() == right.host()
&& left.port_or_known_default() == right.port_or_known_default()
}
fn normalize_headers(headers: Option<String>, media: bool) -> Option<String> {
let headers = headers?;
if !media {
@@ -532,7 +607,7 @@ mod tests {
use super::{
add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce,
is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest,
PROTOCOL_VERSION_HEADER, SERVER_HEADER,
ExtensionCookieScope, PROTOCOL_VERSION_HEADER, SERVER_HEADER,
};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
@@ -613,6 +688,7 @@ mod tests {
filename: None,
headers: None,
cookies: None,
cookie_scopes: None,
media: true,
});
@@ -640,6 +716,7 @@ mod tests {
"x".repeat(64 * 1024)
)),
cookies: Some(format!("large={}", "x".repeat(64 * 1024))),
cookie_scopes: None,
media: true,
})
.expect("valid media handoff");
@@ -658,6 +735,7 @@ mod tests {
filename: None,
headers: None,
cookies: Some("session=browser-cookie-header".to_string()),
cookie_scopes: None,
media: false,
})
.expect("valid download handoff");
@@ -669,6 +747,47 @@ mod tests {
);
}
#[test]
fn regular_capture_normalizes_host_scoped_cookie_headers() {
let download = normalize_download(ExtensionRequest {
urls: vec!["https://mail.google.com/mail/u/0/?view=att".to_string()],
referer: Some("https://mail.google.com/mail/u/0/".to_string()),
silent: true,
filename: Some("report.zip".to_string()),
headers: None,
cookies: Some("SID=mail-session".to_string()),
cookie_scopes: Some(vec![
ExtensionCookieScope {
url: "https://mail.google.com/".to_string(),
cookies: "SID=mail-session".to_string(),
},
ExtensionCookieScope {
url: "https://accounts.google.com/".to_string(),
cookies: "SID=account-session".to_string(),
},
ExtensionCookieScope {
url: "https://mail.google.com/another-path".to_string(),
cookies: "duplicate=ignored".to_string(),
},
]),
media: false,
})
.expect("valid download handoff");
assert_eq!(download.cookies.as_deref(), Some("SID=mail-session"));
assert_eq!(
download
.cookie_scopes
.as_ref()
.map(|scopes| scopes.len()),
Some(2)
);
assert_eq!(
download.cookie_scopes.as_ref().unwrap()[1].cookies,
"SID=account-session"
);
}
#[test]
fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() {
let download = normalize_download(ExtensionRequest {
@@ -681,6 +800,7 @@ mod tests {
filename: None,
headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()),
cookies: Some("session=secret".to_string()),
cookie_scopes: None,
media: false,
})
.expect("valid multi-url handoff");
+173 -14
View File
@@ -116,7 +116,20 @@ fn filename_from_url_path(raw_url: &str) -> Option<String> {
// If the escape sequence cannot form valid UTF-8, retain the raw segment
// so a malformed URL cannot erase an otherwise usable filename.
let decoded = percent_decode_metadata_value(last).unwrap_or_else(|| last.to_string());
sanitize_metadata_filename(&decoded)
let filename = sanitize_metadata_filename(&decoded)?;
let is_gmail_profile_path = parsed.host_str() == Some("mail.google.com")
&& parsed.path().contains("/mail/u/");
let is_gmail_profile_number = is_gmail_profile_path
&& filename.chars().all(|character| character.is_ascii_digit());
(!is_weak_url_filename(&filename) && !is_gmail_profile_number).then_some(filename)
}
fn is_weak_url_filename(filename: &str) -> bool {
let normalized = filename.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"identifier" | "download" | "view" | "uc"
)
}
fn metadata_filename_from_response(
@@ -129,11 +142,13 @@ fn metadata_filename_from_response(
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.and_then(filename_from_content_disposition)
.filter(|filename| !is_weak_url_filename(filename))
{
return filename;
}
filename_from_url_disposition_query(current_url)
.filter(|filename| !is_weak_url_filename(filename))
.or_else(|| filename_from_url_path(original_url))
.or_else(|| filename_from_url_path(current_url))
.unwrap_or_else(|| "download".to_string())
@@ -149,7 +164,11 @@ fn metadata_response_error(status: reqwest::StatusCode) -> Option<String> {
})
}
fn metadata_cookie_header_present(headers: Option<&str>, cookies: Option<&str>) -> bool {
fn metadata_cookie_header_present(
headers: Option<&str>,
cookies: Option<&str>,
cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>,
) -> bool {
cookies.is_some_and(|value| !value.trim().is_empty())
|| headers.is_some_and(|value| {
value.lines().any(|line| {
@@ -159,11 +178,14 @@ fn metadata_cookie_header_present(headers: Option<&str>, cookies: Option<&str>)
name.trim().eq_ignore_ascii_case("cookie") && !value.trim().is_empty()
})
})
|| cookie_scopes.is_some_and(|scopes| {
scopes.iter().any(|scope| !scope.cookies.trim().is_empty())
})
}
fn metadata_headers(
headers: Option<&str>,
cookies: Option<&str>,
cookie_header: Option<&str>,
include_cookies: bool,
) -> reqwest::header::HeaderMap {
let mut header_map = reqwest::header::HeaderMap::new();
@@ -191,8 +213,11 @@ fn metadata_headers(
}
if include_cookies {
if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) {
if let Ok(value) = reqwest::header::HeaderValue::from_str(cookies) {
if let Some(cookie_header) = cookie_header
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Ok(value) = reqwest::header::HeaderValue::from_str(cookie_header) {
header_map.insert(reqwest::header::COOKIE, value);
}
}
@@ -1592,6 +1617,41 @@ fn should_send_metadata_credentials(
.is_some_and(|(original, current)| same_origin(original, current))
}
fn cookie_scope_for_url<'a>(
current_url: &str,
cookie_scopes: Option<&'a [extension_server::ExtensionCookieScope]>,
) -> Option<&'a str> {
let current = reqwest::Url::parse(current_url).ok()?;
cookie_scopes?.iter().find_map(|scope| {
let scope_url = reqwest::Url::parse(&scope.url).ok()?;
let same_googleusercontent_site = scope_url.scheme() == current.scheme()
&& scope_url.port_or_known_default() == current.port_or_known_default()
&& scope_url.host_str() == Some("googleusercontent.com")
&& current
.host_str()
.is_some_and(|host| host.ends_with(".googleusercontent.com"));
(same_origin(&scope_url, &current) || same_googleusercontent_site)
.then_some(scope.cookies.as_str())
})
}
fn metadata_authentication_error(current_url: &str, content_type: Option<&str>) -> Option<String> {
let url = reqwest::Url::parse(current_url).ok()?;
let host = url.host_str()?.to_ascii_lowercase();
let path = url.path().to_ascii_lowercase();
let content_type = content_type.unwrap_or_default().to_ascii_lowercase();
let is_html = content_type.starts_with("text/html")
|| content_type.starts_with("application/xhtml+xml");
let is_google_auth_host = host == "accounts.google.com"
&& (path.contains("signin")
|| path.contains("servicelogin")
|| url.query().is_some_and(|query| query.contains("continue=")));
(is_google_auth_host && (is_html || path.contains("signin"))).then(|| {
"The server returned a Google sign-in page instead of the requested attachment. Re-authenticate in the browser and try again.".to_string()
})
}
#[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed.
#[tauri::command]
async fn fetch_metadata(
@@ -1601,6 +1661,7 @@ async fn fetch_metadata(
password: Option<String>,
headers: Option<String>,
cookies: Option<String>,
cookie_scopes: Option<Vec<extension_server::ExtensionCookieScope>>,
proxy: Option<String>,
defer_cookies: Option<bool>,
) -> Result<MetadataResponse, String> {
@@ -1609,7 +1670,11 @@ async fn fetch_metadata(
let mut current_url = url.clone();
let original_origin = reqwest::Url::parse(&url).ok();
let mut redirects = 0;
let cookies_available = metadata_cookie_header_present(headers.as_deref(), cookies.as_deref());
let cookies_available = metadata_cookie_header_present(
headers.as_deref(),
cookies.as_deref(),
cookie_scopes.as_deref(),
);
let defer_cookies = defer_cookies.unwrap_or(false);
let mut send_cookies = !defer_cookies || !cookies_available;
let mut cookie_retry_attempted = false;
@@ -1657,16 +1722,29 @@ async fn fetch_metadata(
}
let current_origin = reqwest::Url::parse(&current_url).ok();
let should_send_auth = should_send_metadata_credentials(
let same_origin_credentials = should_send_metadata_credentials(
original_origin.as_ref(),
current_origin.as_ref(),
redirects,
);
let header_map = if should_send_auth {
metadata_headers(headers.as_deref(), cookies.as_deref(), send_cookies)
let scoped_cookie = if send_cookies {
cookie_scope_for_url(&current_url, cookie_scopes.as_deref())
} else {
reqwest::header::HeaderMap::new()
None
};
let cookie_header = if same_origin_credentials {
scoped_cookie.or(cookies.as_deref())
} else {
scoped_cookie
};
let header_map = if same_origin_credentials {
metadata_headers(headers.as_deref(), cookie_header, send_cookies)
} else {
// A cross-origin redirect may receive only the cookie scope that
// explicitly matches its host. Never forward captured headers or
// a source-host Cookie header to an unrelated redirect target.
metadata_headers(None, cookie_header, send_cookies)
};
builder = builder.default_headers(header_map);
@@ -1677,7 +1755,7 @@ async fn fetch_metadata(
let mut get_req = client
.get(&request_url)
.header(reqwest::header::RANGE, "bytes=0-0");
if should_send_auth {
if same_origin_credentials {
if let Some(ref user) = username {
if !user.is_empty() {
get_req = get_req.basic_auth(user, password.as_deref());
@@ -1688,7 +1766,7 @@ async fn fetch_metadata(
};
let mut head_req = client.head(&request_url);
if should_send_auth {
if same_origin_credentials {
if let Some(ref user) = username {
if !user.is_empty() {
head_req = head_req.basic_auth(user, password.as_deref());
@@ -1760,6 +1838,16 @@ async fn fetch_metadata(
}
}
if let Some(error) = metadata_authentication_error(
&current_url,
current_res
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
) {
return Err(error);
}
if let Some(error) = metadata_response_error(current_res.status()) {
return Err(error);
}
@@ -6090,7 +6178,8 @@ mod tests {
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key,
media_output_template, media_progress_args, media_progress_speed,
metadata_cookie_header_present, metadata_headers, metadata_response_error,
cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present,
metadata_headers, metadata_response_error,
normalize_speed_limit_for_aria2,
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
@@ -6600,7 +6689,8 @@ mod tests {
let headers = "Cookie: oversized=secret\nReferer: https://example.com/page";
assert!(metadata_cookie_header_present(
Some(headers),
Some("session=secret")
Some("session=secret"),
None
));
let initial_headers = metadata_headers(Some(headers), Some("session=secret"), false);
@@ -6621,6 +6711,43 @@ mod tests {
);
}
#[test]
fn metadata_cookie_scopes_select_only_the_matching_origin() {
let scopes = vec![
crate::extension_server::ExtensionCookieScope {
url: "https://mail.google.com/".to_string(),
cookies: "mail=session".to_string(),
},
crate::extension_server::ExtensionCookieScope {
url: "https://accounts.google.com/".to_string(),
cookies: "account=session".to_string(),
},
crate::extension_server::ExtensionCookieScope {
url: "https://googleusercontent.com/".to_string(),
cookies: "googleusercontent=session".to_string(),
},
];
assert_eq!(
cookie_scope_for_url("https://mail.google.com/mail/u/0/", Some(&scopes)),
Some("mail=session")
);
assert_eq!(
cookie_scope_for_url("https://accounts.google.com/v3/signin/identifier", Some(&scopes)),
Some("account=session")
);
assert_eq!(
cookie_scope_for_url("https://mail-attachment.googleusercontent.com/file", Some(&scopes)),
Some("googleusercontent=session")
);
assert_eq!(
cookie_scope_for_url("https://attacker.example/redirect", Some(&scopes)),
None
);
assert!(!metadata_cookie_header_present(None, None, Some(&scopes[..0])));
assert!(metadata_cookie_header_present(None, None, Some(&scopes)));
}
#[test]
fn metadata_cookie_retry_is_limited_to_one_auth_challenge() {
let mut send_cookies = false;
@@ -6865,6 +6992,38 @@ mod tests {
filename_from_url_path("https://example.com/files/a%2Fb.mkv"),
Some("b.mkv".to_string())
);
assert_eq!(
filename_from_url_path("https://accounts.google.com/v3/signin/identifier"),
None
);
assert_eq!(
filename_from_url_path("https://mail.google.com/mail/u/0/"),
None
);
assert_eq!(
filename_from_url_path("https://example.com/files/123"),
Some("123".to_string())
);
}
#[test]
fn metadata_rejects_google_sign_in_interstitials() {
let message = metadata_authentication_error(
"https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fmail.google.com%2F",
Some("text/html; charset=UTF-8"),
)
.expect("Google sign-in HTML must not become a download");
assert!(message.contains("Google sign-in page"));
assert!(metadata_authentication_error(
"https://accounts.google.com/drive/download/report.zip",
Some("application/zip"),
)
.is_none());
assert!(metadata_authentication_error(
"https://downloads.example.com/signin/identifier",
Some("text/html"),
)
.is_none());
}
#[test]
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExtensionCookieScope = { url: string, cookies: string, };
+2 -1
View File
@@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, };
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
+49 -6
View File
@@ -51,9 +51,48 @@ const normalizeComparableUrl = (rawUrl: string) => {
}
};
const urlsHaveDifferentHosts = (sourceUrl: string, targetUrl: string) => {
const urlsHaveDifferentOrigins = (sourceUrl: string, targetUrl: string) => {
try {
return new URL(sourceUrl).hostname.toLowerCase() !== new URL(targetUrl).hostname.toLowerCase();
const source = new URL(sourceUrl);
const target = new URL(targetUrl);
return source.protocol !== target.protocol
|| source.hostname.toLowerCase() !== target.hostname.toLowerCase()
|| source.port !== target.port;
} catch {
return false;
}
};
const cookieScopeForUrl = (context: PendingAddRequestContext | undefined, targetUrl: string) => {
if (!context?.cookieScopes?.length) return '';
try {
const target = new URL(targetUrl);
return context.cookieScopes.find(scope => {
try {
const scopeUrl = new URL(scope.url);
const sameGoogleusercontentSite = scopeUrl.protocol === target.protocol
&& scopeUrl.port === target.port
&& scopeUrl.hostname.toLowerCase() === 'googleusercontent.com'
&& target.hostname.toLowerCase().endsWith('.googleusercontent.com');
return sameGoogleusercontentSite || (scopeUrl.protocol === target.protocol
&& scopeUrl.hostname.toLowerCase() === target.hostname.toLowerCase()
&& scopeUrl.port === target.port);
} catch {
return false;
}
})?.cookies.trim() || '';
} catch {
return '';
}
};
const isGoogleAuthenticatedCaptureUrl = (rawUrl: string) => {
try {
const hostname = new URL(rawUrl).hostname.toLowerCase();
return hostname === 'mail.google.com'
|| hostname === 'accounts.google.com'
|| hostname === 'googleusercontent.com'
|| hostname.endsWith('.googleusercontent.com');
} catch {
return false;
}
@@ -152,14 +191,16 @@ export const AddDownloadsModal = () => {
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
if (cookiesManuallyEditedRef.current) return cookies.trim();
const context = requestContextForUrl(sourceUrl);
if (context && context.cookies && urlsHaveDifferentHosts(sourceUrl, targetUrl)) {
return '';
}
const scopedCookies = cookieScopeForUrl(context, targetUrl);
if (scopedCookies) return scopedCookies;
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
if (context) return context.cookies.trim();
return hasExtensionRequestContext ? '' : cookies.trim();
};
const shouldDeferCookiesForRow = (sourceUrl: string) =>
!cookiesManuallyEditedRef.current && Boolean(requestContextForUrl(sourceUrl));
!cookiesManuallyEditedRef.current
&& Boolean(requestContextForUrl(sourceUrl))
&& !isGoogleAuthenticatedCaptureUrl(sourceUrl);
const suggestedFilenameForRow = (sourceUrl: string) => {
const context = requestContextForUrl(sourceUrl);
if (context?.filename) return context.filename;
@@ -389,6 +430,7 @@ export const AddDownloadsModal = () => {
const proxy = await getProxyArgs(settingsStore);
const login = getSiteLogin(row.sourceUrl, settingsStore);
const contextUrl = requestContextUrlForRow(row);
const requestContext = requestContextForUrl(contextUrl);
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
settingsStore.setShowKeychainModal(true);
return;
@@ -493,6 +535,7 @@ export const AddDownloadsModal = () => {
password: useAuth ? password || null : keychainPassword,
headers: headersForRow(contextUrl) || null,
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
cookieScopes: requestContext?.cookieScopes || null,
proxy,
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
});
+2 -1
View File
@@ -5,6 +5,7 @@ import type { DownloadCategory } from './bindings/DownloadCategory';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
import type { ExtensionDownload } from './bindings/ExtensionDownload';
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
import type { MediaMetadata } from './bindings/MediaMetadata';
import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata';
import type { MetadataResponse } from './bindings/MetadataResponse';
@@ -18,7 +19,7 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
type CommandMap = {
fetch_metadata: {
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null; deferCookies?: boolean };
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; cookieScopes: Array<ExtensionCookieScope> | null; proxy: string | null; deferCookies?: boolean };
result: MetadataResponse;
};
fetch_media_metadata: {
+36
View File
@@ -1318,6 +1318,10 @@ describe('useDownloadStore', () => {
filename: 'file.bin',
headers: 'X-Test: value',
cookies: 'session=secret',
cookie_scopes: [
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
],
media: false
});
@@ -1328,6 +1332,10 @@ describe('useDownloadStore', () => {
expect(state.pendingAddFilename).toBe('file.bin');
expect(state.pendingAddHeaders).toBe('X-Test: value');
expect(state.pendingAddCookies).toBe('session=secret');
expect(state.pendingAddRequestContexts['https://example.com/file.bin'].cookieScopes).toEqual([
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
]);
expect(state.pendingAddMediaUrls).toEqual([]);
});
@@ -1349,6 +1357,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: 'User-Agent: Firefox Test',
cookies: null,
cookie_scopes: null,
media: false
});
@@ -1369,6 +1378,7 @@ describe('useDownloadStore', () => {
filename: 'report.pdf',
headers: 'User-Agent: Test',
cookies: 'session=secret',
cookie_scopes: null,
media: false
});
@@ -1390,6 +1400,7 @@ describe('useDownloadStore', () => {
filename: 'first.zip',
headers: 'User-Agent: First Browser',
cookies: 'first=session',
cookie_scopes: null,
media: false
});
await useDownloadStore.getState().handleExtensionDownload({
@@ -1399,6 +1410,7 @@ describe('useDownloadStore', () => {
filename: 'second.zip',
headers: 'User-Agent: Second Browser',
cookies: 'second=session',
cookie_scopes: null,
media: false
});
@@ -1435,6 +1447,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
cookie_scopes: null,
media: true
});
@@ -1454,12 +1467,31 @@ describe('useDownloadStore', () => {
filename: 'private.zip',
headers: null,
cookies: 'session=secret',
cookie_scopes: null,
media: false
});
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
});
it('drops extension cookie scopes for explicit media captures', async () => {
await useDownloadStore.getState().handleExtensionDownload({
urls: ['https://media.example/watch/123'],
referer: 'https://media.example/watch/123',
silent: true,
filename: null,
headers: null,
cookies: null,
cookie_scopes: [
{ url: 'https://media.example/', cookies: 'session=secret' }
],
media: true
});
expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes)
.toBeUndefined();
});
it('clears stale request context when the same URL is captured without it later', async () => {
const url = 'https://example.com/file.zip';
await useDownloadStore.getState().handleExtensionDownload({
@@ -1469,6 +1501,7 @@ describe('useDownloadStore', () => {
filename: 'private.zip',
headers: 'Authorization: secret',
cookies: 'session=secret',
cookie_scopes: null,
media: false
});
await useDownloadStore.getState().handleExtensionDownload({
@@ -1478,6 +1511,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: null,
cookies: null,
cookie_scopes: null,
media: false
});
@@ -1505,6 +1539,7 @@ describe('useDownloadStore', () => {
filename: null,
headers: 'User-Agent: Firefox Test',
cookies: 'session=secret',
cookie_scopes: null,
media: true
});
@@ -1524,6 +1559,7 @@ describe('useDownloadStore', () => {
filename: 'file.bin',
headers: 'User-Agent: Firefox Test',
cookies: null,
cookie_scopes: null,
media: false
});
+14 -3
View File
@@ -5,6 +5,7 @@ import { invokeCommand as invoke } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
@@ -451,6 +452,7 @@ export type PendingAddRequestContext = {
filename: string;
headers: string;
cookies: string;
cookieScopes?: ExtensionCookieScope[];
media: boolean;
};
@@ -487,7 +489,8 @@ interface DownloadState {
filename?: string | null,
headers?: string | null,
cookies?: string | null,
media?: boolean
media?: boolean,
cookieScopes?: ExtensionCookieScope[] | null
) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
deleteModalState: DeleteModalState;
@@ -652,7 +655,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
// opened or closed without URLs.
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
})),
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false, cookieScopes) => set((state) => {
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
const existingUrls = isAppending ? state.pendingAddUrls : '';
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
@@ -660,6 +663,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const cleanFilename = filename?.trim() || '';
const cleanHeaders = headers?.trim() || '';
const cleanCookies = cookies?.trim() || '';
const cleanCookieScopes = cookieScopes
?.map(scope => ({
url: scope.url.trim(),
cookies: scope.cookies.trim()
}))
.filter(scope => scope.url && scope.cookies);
const requestVersion = state.pendingAddRequestVersion + 1;
const pendingAddRequestContexts = isAppending
? { ...state.pendingAddRequestContexts }
@@ -683,6 +692,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
filename: cleanFilename,
headers: cleanHeaders,
cookies: cleanCookies,
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
media
};
}
@@ -719,7 +729,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
urls.length === 1 ? request.filename : null,
headers,
cookies,
request.media === true
request.media === true,
request.media === true ? undefined : request.cookie_scopes
);
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),