fix(handoff): close browser credential boundaries

- Filter custom credential headers and cookies at restricted handoff consumers.
- Preserve ordinary single-file capture credentials for Add-window review.
- Extend native and renderer redaction coverage with focused regressions.
This commit is contained in:
NimBold
2026-08-22 05:02:04 +03:30
parent 3bcad639e2
commit e88425833f
10 changed files with 154 additions and 49 deletions
+7 -7
View File
@@ -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');
+3 -2
View File
@@ -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,
+5 -13
View File
@@ -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<DownloadState>((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;
+12
View File
@@ -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);
+31
View File
@@ -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.
+19
View File
@@ -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');
+6 -2
View File
@@ -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]'
);
};