diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index a533ca7..a5aec7b 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -566,18 +566,8 @@ fn normalize_headers(headers: Option, media: bool) -> Option { .lines() .filter(|line| { line.split_once(':') - .map(|(name, _)| { - !matches!( - name.trim().to_ascii_lowercase().as_str(), - "authorization" - | "cookie" - | "cookie2" - | "proxy-authorization" - | "set-cookie" - | "set-cookie2" - ) - }) - .unwrap_or(true) + .map(|(name, _)| !crate::queue::header_name_has_credential_material(name)) + .unwrap_or(false) }) .collect::>() .join("\n"); @@ -919,7 +909,7 @@ mod tests { silent: false, filename: None, headers: Some(format!( - "Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox", + "Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nX-Api-Key: stale\nX-Auth-Token: stale\nX-Access-Token: stale\nX-Request-Signature: stale\nX-Session: stale\n: malformed\nUser-Agent: Firefox\nX-Trace: safe", "x".repeat(64 * 1024) )), cookies: Some(format!("large={}", "x".repeat(64 * 1024))), @@ -933,7 +923,10 @@ mod tests { assert!(download.media); assert!(download.cookies.is_none()); - assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox")); + assert_eq!( + download.headers.as_deref(), + Some("User-Agent: Firefox\nX-Trace: safe") + ); } #[test] @@ -960,6 +953,37 @@ mod tests { ); } + #[test] + fn multi_url_capture_drops_shared_credentials_but_keeps_safe_headers() { + let download = normalize_download(ExtensionRequest { + urls: vec![ + "https://one.example/file.zip".to_string(), + "https://two.example/file.zip".to_string(), + ], + referer: None, + silent: false, + filename: None, + headers: Some( + "X-Api-Key: shared-secret\nX-Request-Signature: signature-secret\n: malformed\nUser-Agent: Firefox\nX-Trace: safe" + .to_string(), + ), + cookies: Some("session=must-not-cross-hosts".to_string()), + cookie_scopes: None, + media: false, + torrent: false, + batch: true, + batch_name: Some("batch".to_string()), + }) + .expect("valid multi-url handoff"); + + assert!(download.batch); + assert!(download.cookies.is_none()); + assert_eq!( + download.headers.as_deref(), + Some("User-Agent: Firefox\nX-Trace: safe") + ); + } + #[test] fn torrent_handoff_accepts_magnets_and_preserves_the_intent() { let download = normalize_download(ExtensionRequest { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a98826..b97270f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11408,27 +11408,25 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String { use std::sync::OnceLock; static SECRET: OnceLock = OnceLock::new(); static QUOTED_SECRET: OnceLock = OnceLock::new(); - static HEADER: OnceLock = OnceLock::new(); + static COOKIE_HEADER: OnceLock = OnceLock::new(); static QUERY: OnceLock = OnceLock::new(); static USERINFO: OnceLock = OnceLock::new(); static FRAGMENT: OnceLock = OnceLock::new(); let secret = SECRET.get_or_init(|| { regex::Regex::new( - r"(?i)(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)\s*[:=]\s*([^\r\n,;]+)", + r"(?i)(authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)\s*[:=]\s*([^\r\n,;]+)", ) .expect("valid secret redaction regex") }); let quoted_secret = QUOTED_SECRET.get_or_init(|| { regex::Regex::new( - r#"(?i)(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']"#, + r#"(?i)(["'])(authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']"#, ) .expect("valid quoted secret redaction regex") }); - let header = HEADER.get_or_init(|| { - regex::Regex::new( - r"(?i)(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]+", - ) - .expect("valid sensitive header redaction regex") + let cookie_header = COOKIE_HEADER.get_or_init(|| { + regex::Regex::new(r"(?i)((?:set-)?cookie2?)\s*[:=]\s*[^\r\n]+") + .expect("valid cookie header redaction regex") }); let query = QUERY.get_or_init(|| { regex::Regex::new(r#"([A-Za-z][A-Za-z0-9+.-]*://[^\s?\"'<>},\]]+)\?[^\s\"'<>},\]]+"#) @@ -11445,7 +11443,7 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String { let redacted = query.replace_all(line, "$1?[redacted]"); let redacted = fragment.replace_all(&redacted, "$1#[redacted]"); let redacted = userinfo.replace_all(&redacted, "$1[redacted]@"); - let redacted = header.replace_all(&redacted, "$1: [redacted]"); + let redacted = cookie_header.replace_all(&redacted, "$1: [redacted]"); let redacted = quoted_secret.replace_all(&redacted, "$1$2$3$4[redacted]"); secret .replace_all(&redacted, "$1=[redacted]") @@ -14220,6 +14218,29 @@ mod tests { assert!(redacted.contains("[redacted]")); } + #[test] + fn redacts_legacy_cookie_and_compound_custom_headers() { + let line = "Set-Cookie2: legacy-cookie X-Session: id=session-secret; key=compound-secret"; + let redacted = redact_log_line(line); + assert!(!redacted.contains("legacy-cookie")); + assert!(!redacted.contains("session-secret")); + assert!(!redacted.contains("compound-secret")); + assert!(redacted.contains("[redacted]")); + } + + #[test] + fn redacts_all_pairs_in_cookie_headers() { + let redacted = redact_log_line("Cookie: a=1; user_id=secret; state=xyz"); + assert!(!redacted.contains("a=1")); + assert!(!redacted.contains("user_id=secret")); + assert!(!redacted.contains("state=xyz")); + assert!(redacted.contains("[redacted]")); + + let redacted = redact_log_line("Cookie2=a=1; user_id=secret; state=xyz"); + assert!(!redacted.contains("user_id=secret")); + assert!(!redacted.contains("state=xyz")); + } + #[test] fn preserves_compact_json_delimiters_while_redacting_url_queries() { let redacted = redact_log_line( diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 99c61ac..fabdc81 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -6086,15 +6086,16 @@ fn payload_has_credential_material(payload: &SpawnPayload) -> bool { .any(|name| header_name_has_credential_material(&name)) } -fn header_name_has_credential_material(name: &str) -> bool { +pub(crate) fn header_name_has_credential_material(name: &str) -> bool { let name = name.trim().to_ascii_lowercase(); - matches!( + name.is_empty() || matches!( name.as_str(), "authorization" | "cookie" | "cookie2" | "proxy-authorization" | "set-cookie" + | "set-cookie2" | "x-api-key" | "x-auth-token" | "x-access-token" diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index d0b3ca4..bd8bad2 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -14,7 +14,7 @@ import { open } from '@tauri-apps/plugin-dialog'; import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -124,12 +124,12 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [ context?.referer ? `Referer: ${context.referer.replace(/[\r\n]/g, '')}` : '', context?.media ? (context.headers || '') - .split(/\r?\n/) - .filter(line => { - const separator = line.indexOf(':'); - return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie'; - }) - .join('\n') + .split(/\r?\n/) + .filter(line => { + const separator = line.indexOf(':'); + return separator > 0 && !headerNameHasCredentialMaterial(line.slice(0, separator)); + }) + .join('\n') : context?.headers ].filter(Boolean).join('\n'); diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 9c833ad..3453ccb 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -3643,7 +3643,7 @@ describe('useDownloadStore', () => { silent: false, filename: null, headers: null, - cookies: null, + cookies: 'shared=session', cookie_scopes: null, media: false, torrent: false, @@ -3659,6 +3659,7 @@ describe('useDownloadStore', () => { 'https://example.com/one.zip\nhttps://example.com/two.zip' ); expect(useDownloadStore.getState().pendingAddBatch).toBe(false); + expect(useDownloadStore.getState().pendingAddCookies).toBe(''); }); it('keeps each extension handoff context attached to its own URL while the Add Modal is open', async () => { @@ -3720,7 +3721,7 @@ describe('useDownloadStore', () => { referer: 'https://adult.example/watch/123', silent: false, filename: null, - headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox Test`, + headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nX-Api-Key: stale\nX-Auth-Token: stale\nX-Request-Signature: stale\nX-Session: stale\nUser-Agent: Firefox Test`, cookies: `oversized=${'x'.repeat(64 * 1024)}`, cookie_scopes: null, media: true, diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 672787c..a5b4624 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; -import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -300,16 +300,8 @@ const stripSensitiveMediaHeaders = (value: string | null | undefined): string => .split(/\r?\n/) .filter(line => { const separator = line.indexOf(':'); - if (separator < 0) return true; - const name = line.slice(0, separator).trim().toLowerCase(); - return ![ - 'authorization', - 'cookie', - 'cookie2', - 'proxy-authorization', - 'set-cookie', - 'set-cookie2' - ].includes(name); + if (separator <= 0) return false; + return !headerNameHasCredentialMaterial(line.slice(0, separator)); }) .join('\n') .trim(); @@ -1818,8 +1810,8 @@ export const useDownloadStore = create((set, get) => { // Explicit media authentication belongs to yt-dlp's configured browser // cookie source. Keep this frontend guard for events from older desktop or // extension builds; ordinary captured downloads retain their cookies. - const cookies = request.media === true ? null : request.cookies; - const headers = request.media === true + const cookies = request.media === true || urls.length > 1 ? null : request.cookies; + const headers = request.media === true || urls.length > 1 ? stripSensitiveMediaHeaders(request.headers) || null : request.headers; diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 7833f73..bd458aa 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -21,6 +21,7 @@ import { torrentWebSeedDraftsFromSeeds, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, + headerNameHasCredentialMaterial, redactDownloadForPersistence, resolveDownloadConnections } from './downloads'; @@ -178,6 +179,17 @@ describe('download persistence progress snapshots', () => { }); }); +describe('credential-bearing extension header names', () => { + it('classifies named and marker-based credential headers while preserving browser context names', () => { + expect(headerNameHasCredentialMaterial('X-Api-Key')).toBe(true); + expect(headerNameHasCredentialMaterial('X-Request-Signature')).toBe(true); + expect(headerNameHasCredentialMaterial('X-Session')).toBe(true); + expect(headerNameHasCredentialMaterial('Set-Cookie2')).toBe(true); + expect(headerNameHasCredentialMaterial('User-Agent')).toBe(false); + expect(headerNameHasCredentialMaterial('X-Trace')).toBe(false); + }); +}); + describe('allocation phase visibility', () => { it('does not override paused or completed statuses', () => { expect(isAllocationPhaseVisible(true, 'ready')).toBe(true); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 0ee2598..ee57891 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -640,6 +640,37 @@ const NON_CREDENTIAL_REQUEST_HEADERS = new Set([ 'via', 'warning', ]); + +const CREDENTIAL_HEADER_NAMES = new Set([ + 'authorization', + 'cookie', + 'cookie2', + 'proxy-authorization', + 'set-cookie', + 'set-cookie2', + 'x-api-key', + 'x-auth-token', + 'x-access-token', +]); +const CREDENTIAL_HEADER_MARKERS = [ + 'auth', + 'credential', + 'key', + 'password', + 'passwd', + 'secret', + 'session', + 'signature', + 'token', +] as const; + +/** Header-name classifier shared by extension handoff defenses. */ +export const headerNameHasCredentialMaterial = (rawName: string): boolean => { + const name = rawName.trim().toLowerCase(); + return name.length === 0 + || CREDENTIAL_HEADER_NAMES.has(name) + || CREDENTIAL_HEADER_MARKERS.some(marker => name.includes(marker)); +}; // Only stable request context is safe to carry into a later lifecycle. Range, // conditional, hop-by-hop, and routing headers describe the old HTTP request // and can conflict with Aria2's own resume negotiation. diff --git a/src/utils/logEntries.test.ts b/src/utils/logEntries.test.ts index 6e21126..0d128f8 100644 --- a/src/utils/logEntries.test.ts +++ b/src/utils/logEntries.test.ts @@ -46,6 +46,25 @@ describe('log entry streaming', () => { expect(liveLogEntry(3, 'Authorization: Bearer secret').message).not.toContain('secret'); }); + it('redacts custom session and signature headers from live output', () => { + const redacted = redactLogText('X-Request-Signature: signature-secret X-Session: session-secret'); + + expect(redacted).not.toContain('signature-secret'); + expect(redacted).not.toContain('session-secret'); + }); + + it('redacts legacy cookie headers and compound custom values', () => { + const redacted = redactLogText('Set-Cookie2: legacy-cookie X-Session: id=session-secret; key=compound-secret'); + + expect(redacted).not.toContain('legacy-cookie'); + expect(redacted).not.toContain('session-secret'); + expect(redacted).not.toContain('compound-secret'); + + const equalsRedacted = redactLogText('Cookie2=a=1; user_id=secret; state=xyz'); + expect(equalsRedacted).not.toContain('user_id=secret'); + expect(equalsRedacted).not.toContain('state=xyz'); + }); + it('redacts persisted content and quoted credential fields', () => { const persisted = persistedLogEntry('{"api_key":"json-secret","path":"/Users/nima/file"}', '/Users/nima'); diff --git a/src/utils/logEntries.ts b/src/utils/logEntries.ts index 3828c76..fcb5d34 100644 --- a/src/utils/logEntries.ts +++ b/src/utils/logEntries.ts @@ -49,11 +49,15 @@ export const redactLogText = (message: string, homePath = ''): string => { '$1[redacted]@' ); redacted = redacted.replace( - /(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi, + /([A-Za-z0-9_-]*(?:authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)[A-Za-z0-9_-]*)\s*[:=]\s*[^\r\n]*?(;?)(?=\s+(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/|[A-Za-z0-9_-]*(?:authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)\s*[:=])|$)/gi, + '$1: [redacted]$2' + ); + redacted = redacted.replace( + /(["'])(authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi, '$1$2$3$4[redacted]' ); return redacted.replace( - /(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(\s*)([:=])(\s*)([^\r\n,;]+)/gi, + /(authorization|proxy-authorization|cookie2|cookie|set-cookie2|set-cookie|password|passwd|auth|token|secret|credential|key|pairing[-_ ]?token|api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|signature|session)(\s*)([:=])(\s*)([^\r\n,;]+)/gi, '$1$2$3$4[redacted]' ); };