diff --git a/AGENTS.md b/AGENTS.md index 180b3855..bd26d4ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,11 @@ per-server secret must follow: - `services/api/client.ts` reads it back off the in-memory `ServerConfig` to build the header via `utils/basicAuth.ts`. +Custom headers (`useCustomHeaders`, #228) follow the same rule: the flag is +plain, but the whole `customHeaders` array is a secret (values are tokens), +JSON-stringified into `server_custom_headers_{id}` and forced to `[]` in +AsyncStorage. + Be deliberate whenever you touch code that persists a `ServerConfig` — including paths that rewrite the *whole* server list, such as add, edit, delete, and import. A secret must never reach AsyncStorage, and bulk rewrites are the easiest @@ -379,7 +384,9 @@ All PascalCase function components taking a `…Props` interface. `avatarColor(name)`), `ServerAppearanceSection` (icon + badge-color editor used by both `app/server/add.tsx` and `app/server/[id].tsx` — quick `AVATAR_PALETTE` swatches plus a "custom color" swatch that opens the full - `ColorPicker`). + `ColorPicker`), `CustomHeadersSection` (per-server custom HTTP header + key/value rows, max 5, used by the same two screens — see + `utils/customHeaders.ts`). - **Visuals** — `SpeedGraph`, `CircularProgress`, `AnimatedProgressBar`, `AnimatedButton`, `Confetti`. - **Chrome / diagnostics** — `FocusAwareStatusBar`, `SettingRow`, @@ -461,6 +468,9 @@ label, completion and ETA rules) · `limit-input.ts` (share-limit sentinels: `server.ts` (endpoint resolution incl. fallback URL, avatar colors, and `getServerIcon`/`getServerIconColor` for the per-server badge — #224) · `authMode.ts` (derives `password`/`apiKey`/`none`) · `basicAuth.ts` · +`customHeaders.ts` (per-server custom HTTP headers — sanitize/validate, and the +reserved-name set the app manages itself: Authorization, Cookie, Referer, +Origin, Content-Type, Host — #228) · `magnet.ts` / `torrent-file.ts` (incoming link and file parsing) · `rss.ts` (RSS tree flattening; paths join with `\`) · `searchResult.ts` (indexer-label heuristics) · `login-response.ts` (qBittorrent login body/cookie interpretation) · diff --git a/app/(tabs)/settings/advanced.tsx b/app/(tabs)/settings/advanced.tsx index 476c6835..bd6799ee 100644 --- a/app/(tabs)/settings/advanced.tsx +++ b/app/(tabs)/settings/advanced.tsx @@ -150,6 +150,8 @@ export default function AdvancedSettingsScreen() { password: '', basicAuthPassword: '', apiKey: '', + // Imports never carry secrets — same rule as utils/server-export.ts. + customHeaders: [], }); } } diff --git a/app/server/[id].tsx b/app/server/[id].tsx index 6da5cf99..d33c57b4 100644 --- a/app/server/[id].tsx +++ b/app/server/[id].tsx @@ -34,12 +34,18 @@ import { DebugRow } from '@/components/DebugRow'; import { SettingRow } from '@/components/SettingRow'; import { OptionPicker, OptionPickerItem } from '@/components/OptionPicker'; import { ServerAppearanceSection } from '@/components/ServerAppearanceSection'; +import { CustomHeadersSection } from '@/components/CustomHeadersSection'; import { spacing, borderRadius } from '@/constants/spacing'; import { shadows } from '@/constants/shadows'; import * as Clipboard from 'expo-clipboard'; import { APP_VERSION } from '@/utils/version'; import { getErrorMessage } from '@/utils/error'; import { ServerAuthMode, getServerAuthMode, applyServerAuthMode } from '@/utils/authMode'; +import { + CustomHeaderPair, + sanitizeCustomHeaders, + validateCustomHeaders, +} from '@/utils/customHeaders'; export default function EditServerScreen() { const router = useRouter(); @@ -63,6 +69,8 @@ export default function EditServerScreen() { const [useBasicAuth, setUseBasicAuth] = useState(false); const [basicAuthUsername, setBasicAuthUsername] = useState(''); const [basicAuthPassword, setBasicAuthPassword] = useState(''); + const [useCustomHeaders, setUseCustomHeaders] = useState(false); + const [customHeaders, setCustomHeaders] = useState([]); const [useFallback, setUseFallback] = useState(false); const [fallbackHost, setFallbackHost] = useState(''); const [fallbackPort, setFallbackPort] = useState(''); @@ -184,11 +192,16 @@ export default function EditServerScreen() { baseUrl, loginEndpoint: `${baseUrl}/api/v2/auth/login`, versionEndpoint: `${baseUrl}/api/v2/app/version`, + // Names only — this whole block is copied to the clipboard and routinely + // pasted into public issue reports, and header values are auth tokens. + customHeaderNames: useCustomHeaders + ? sanitizeCustomHeaders(customHeaders).map((header) => header.key) + : [], warnings, hasErrors: warnings.some((w) => w.type === 'error'), hasWarnings: warnings.some((w) => w.type === 'warning'), }; - }, [host, port, useHttps, authMode, username, password, apiKey]); + }, [host, port, useHttps, authMode, username, password, apiKey, useCustomHeaders, customHeaders]); // Copy debug info to clipboard const copyDebugInfo = async () => { @@ -199,6 +212,7 @@ Host: ${debugInfo.cleanHost || '(empty)'} Port: ${debugInfo.portNum || 'default (80/443)'} HTTPS: ${useHttps ? 'Yes' : 'No'} Auth Method: ${authMode} +Custom Headers: ${debugInfo.customHeaderNames.length > 0 ? debugInfo.customHeaderNames.join(', ') + ' (values hidden)' : 'None'} Login Endpoint: ${debugInfo.loginEndpoint} Version Endpoint: ${debugInfo.versionEndpoint} @@ -240,6 +254,8 @@ App Version: ${APP_VERSION}`; setUseBasicAuth(server.useBasicAuth || false); setBasicAuthUsername(server.basicAuthUsername || ''); setBasicAuthPassword(server.basicAuthPassword || ''); + setUseCustomHeaders(server.useCustomHeaders || false); + setCustomHeaders(server.customHeaders || []); setIcon(server.icon || ''); setIconColor(server.iconColor || ''); // Preserve existing basePath for backward compatibility @@ -290,6 +306,21 @@ App Version: ${APP_VERSION}`; return; } + if (useCustomHeaders) { + const headerValidation = validateCustomHeaders(customHeaders); + if (!headerValidation.valid) { + if (headerValidation.error === 'reserved') { + showToast( + t('errors.reservedHeaderName', { name: headerValidation.reservedName }), + 'error', + ); + } else { + showToast(t('errors.fillCustomHeaderFields'), 'error'); + } + return; + } + } + const portNum = port.trim() ? parseInt(port, 10) : undefined; if (portNum !== undefined && (isNaN(portNum) || portNum < 1 || portNum > 65535)) { showToast(t('errors.validPort'), 'error'); @@ -332,6 +363,8 @@ App Version: ${APP_VERSION}`; useBasicAuth: useProxyBasicAuth, basicAuthUsername: useProxyBasicAuth ? basicAuthUsername.trim() : '', basicAuthPassword: useProxyBasicAuth ? basicAuthPassword : '', + useCustomHeaders, + customHeaders: useCustomHeaders ? sanitizeCustomHeaders(customHeaders) : [], useFallback, fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', fallbackPort: useFallback ? fallbackPortNum : undefined, @@ -401,6 +434,21 @@ App Version: ${APP_VERSION}`; return; } + if (useCustomHeaders) { + const headerValidation = validateCustomHeaders(customHeaders); + if (!headerValidation.valid) { + if (headerValidation.error === 'reserved') { + showToast( + t('errors.reservedHeaderName', { name: headerValidation.reservedName }), + 'error', + ); + } else { + showToast(t('errors.fillCustomHeaderFields'), 'error'); + } + return; + } + } + const portNum = port.trim() ? parseInt(port, 10) : undefined; if (portNum !== undefined && (isNaN(portNum) || portNum < 1 || portNum > 65535)) { showToast(t('errors.validPort'), 'error'); @@ -442,6 +490,8 @@ App Version: ${APP_VERSION}`; useBasicAuth: useProxyBasicAuth, basicAuthUsername: useProxyBasicAuth ? basicAuthUsername.trim() : '', basicAuthPassword: useProxyBasicAuth ? basicAuthPassword : '', + useCustomHeaders, + customHeaders: useCustomHeaders ? sanitizeCustomHeaders(customHeaders) : [], useFallback, fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', fallbackPort: useFallback ? fallbackPortNum : undefined, @@ -711,6 +761,8 @@ App Version: ${APP_VERSION}`; {authMethodLabel} @@ -933,6 +985,13 @@ App Version: ${APP_VERSION}`; + + {/* Test Connection */} @@ -1016,6 +1075,17 @@ App Version: ${APP_VERSION}`; debugInfo.portNum ? String(debugInfo.portNum) : t('server.debugDefaultPort') } /> + 0 + ? t('server.debugCustomHeadersValue', { + names: debugInfo.customHeaderNames.join(', '), + }) + : t('server.debugCustomHeadersNone') + } + numberOfLines={2} + /> @@ -1097,6 +1167,8 @@ App Version: ${APP_VERSION}`; useBasicAuth={authMode !== 'apiKey' && useBasicAuth} basicAuthUsername={basicAuthUsername} basicAuthPassword={basicAuthPassword} + useCustomHeaders={useCustomHeaders} + customHeaders={customHeaders} /> )} @@ -1361,10 +1433,20 @@ const styles = StyleSheet.create({ authMethodValue: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'flex-end', gap: 4, + // SettingRow's label side is flex:1, so it only gets what's left over after + // this value is laid out at its intrinsic width. Unbounded, a long value + // ("Username & Password") starved the label until "Authentication Method" + // broke mid-word. Capping the value reserves enough room for the label to + // wrap on a word boundary; the value scales its font down to compensate. + maxWidth: '45%', + flexShrink: 1, }, authMethodValueText: { fontSize: 16, + flexShrink: 1, + textAlign: 'right', }, hintText: { fontSize: 12, diff --git a/app/server/add.tsx b/app/server/add.tsx index 94fbf430..5ea229ea 100644 --- a/app/server/add.tsx +++ b/app/server/add.tsx @@ -33,12 +33,18 @@ import { DebugRow } from '@/components/DebugRow'; import { SettingRow } from '@/components/SettingRow'; import { OptionPicker, OptionPickerItem } from '@/components/OptionPicker'; import { ServerAppearanceSection } from '@/components/ServerAppearanceSection'; +import { CustomHeadersSection } from '@/components/CustomHeadersSection'; import { spacing, borderRadius } from '@/constants/spacing'; import { shadows } from '@/constants/shadows'; import * as Clipboard from 'expo-clipboard'; import { APP_VERSION } from '@/utils/version'; import { getErrorMessage } from '@/utils/error'; import { ServerAuthMode, applyServerAuthMode } from '@/utils/authMode'; +import { + CustomHeaderPair, + sanitizeCustomHeaders, + validateCustomHeaders, +} from '@/utils/customHeaders'; export default function AddServerScreen() { const router = useRouter(); @@ -61,6 +67,8 @@ export default function AddServerScreen() { const [useBasicAuth, setUseBasicAuth] = useState(false); const [basicAuthUsername, setBasicAuthUsername] = useState(''); const [basicAuthPassword, setBasicAuthPassword] = useState(''); + const [useCustomHeaders, setUseCustomHeaders] = useState(false); + const [customHeaders, setCustomHeaders] = useState([]); const [useFallback, setUseFallback] = useState(false); const [fallbackHost, setFallbackHost] = useState(''); const [fallbackPort, setFallbackPort] = useState(''); @@ -176,11 +184,16 @@ export default function AddServerScreen() { baseUrl, loginEndpoint: `${baseUrl}/api/v2/auth/login`, versionEndpoint: `${baseUrl}/api/v2/app/version`, + // Names only — this whole block is copied to the clipboard and routinely + // pasted into public issue reports, and header values are auth tokens. + customHeaderNames: useCustomHeaders + ? sanitizeCustomHeaders(customHeaders).map((header) => header.key) + : [], warnings, hasErrors: warnings.some((w) => w.type === 'error'), hasWarnings: warnings.some((w) => w.type === 'warning'), }; - }, [host, port, useHttps, authMode, username, password, apiKey]); + }, [host, port, useHttps, authMode, username, password, apiKey, useCustomHeaders, customHeaders]); // Copy debug info to clipboard const copyDebugInfo = async () => { @@ -191,6 +204,7 @@ Host: ${debugInfo.cleanHost || '(empty)'} Port: ${debugInfo.portNum || 'default (80/443)'} HTTPS: ${useHttps ? 'Yes' : 'No'} Auth Method: ${authMode} +Custom Headers: ${debugInfo.customHeaderNames.length > 0 ? debugInfo.customHeaderNames.join(', ') + ' (values hidden)' : 'None'} Login Endpoint: ${debugInfo.loginEndpoint} Version Endpoint: ${debugInfo.versionEndpoint} @@ -236,6 +250,21 @@ App Version: ${APP_VERSION}`; return; } + if (useCustomHeaders) { + const headerValidation = validateCustomHeaders(customHeaders); + if (!headerValidation.valid) { + if (headerValidation.error === 'reserved') { + showToast( + t('errors.reservedHeaderName', { name: headerValidation.reservedName }), + 'error', + ); + } else { + showToast(t('errors.fillCustomHeaderFields'), 'error'); + } + return; + } + } + const portNum = port.trim() ? parseInt(port, 10) : undefined; if (portNum !== undefined && (isNaN(portNum) || portNum < 1 || portNum > 65535)) { showToast(t('errors.validPort'), 'error'); @@ -277,6 +306,8 @@ App Version: ${APP_VERSION}`; useBasicAuth: useProxyBasicAuth, basicAuthUsername: useProxyBasicAuth ? basicAuthUsername.trim() : '', basicAuthPassword: useProxyBasicAuth ? basicAuthPassword : '', + useCustomHeaders, + customHeaders: useCustomHeaders ? sanitizeCustomHeaders(customHeaders) : [], useFallback, fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', fallbackPort: useFallback ? fallbackPortNum : undefined, @@ -332,6 +363,21 @@ App Version: ${APP_VERSION}`; return; } + if (useCustomHeaders) { + const headerValidation = validateCustomHeaders(customHeaders); + if (!headerValidation.valid) { + if (headerValidation.error === 'reserved') { + showToast( + t('errors.reservedHeaderName', { name: headerValidation.reservedName }), + 'error', + ); + } else { + showToast(t('errors.fillCustomHeaderFields'), 'error'); + } + return; + } + } + const portNum = port.trim() ? parseInt(port, 10) : undefined; if (portNum !== undefined && (isNaN(portNum) || portNum < 1 || portNum > 65535)) { showToast(t('errors.validPort'), 'error'); @@ -369,6 +415,8 @@ App Version: ${APP_VERSION}`; useBasicAuth: useProxyBasicAuth, basicAuthUsername: useProxyBasicAuth ? basicAuthUsername.trim() : '', basicAuthPassword: useProxyBasicAuth ? basicAuthPassword : '', + useCustomHeaders, + customHeaders: useCustomHeaders ? sanitizeCustomHeaders(customHeaders) : [], useFallback, fallbackHost: useFallback ? stripProtocol(fallbackHost.trim()) : '', fallbackPort: useFallback ? fallbackPortNum : undefined, @@ -633,6 +681,8 @@ App Version: ${APP_VERSION}`; {authMethodLabel} @@ -855,6 +905,13 @@ App Version: ${APP_VERSION}`; + + {/* Test Connection */} @@ -938,6 +995,17 @@ App Version: ${APP_VERSION}`; debugInfo.portNum ? String(debugInfo.portNum) : t('server.debugDefaultPort') } /> + 0 + ? t('server.debugCustomHeadersValue', { + names: debugInfo.customHeaderNames.join(', '), + }) + : t('server.debugCustomHeadersNone') + } + numberOfLines={2} + /> @@ -1019,6 +1087,8 @@ App Version: ${APP_VERSION}`; useBasicAuth={authMode !== 'apiKey' && useBasicAuth} basicAuthUsername={basicAuthUsername} basicAuthPassword={basicAuthPassword} + useCustomHeaders={useCustomHeaders} + customHeaders={customHeaders} /> )} @@ -1240,10 +1310,20 @@ const styles = StyleSheet.create({ authMethodValue: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'flex-end', gap: 4, + // SettingRow's label side is flex:1, so it only gets what's left over after + // this value is laid out at its intrinsic width. Unbounded, a long value + // ("Username & Password") starved the label until "Authentication Method" + // broke mid-word. Capping the value reserves enough room for the label to + // wrap on a word boundary; the value scales its font down to compensate. + maxWidth: '45%', + flexShrink: 1, }, authMethodValueText: { fontSize: 16, + flexShrink: 1, + textAlign: 'right', }, hintText: { fontSize: 12, diff --git a/components/CustomHeadersSection.tsx b/components/CustomHeadersSection.tsx new file mode 100644 index 00000000..201a205c --- /dev/null +++ b/components/CustomHeadersSection.tsx @@ -0,0 +1,222 @@ +/** + * CustomHeadersSection.tsx — Custom HTTP header editor for the add/edit server + * forms (#228). Lets a server send extra headers on every request, for + * tunnels/proxies (Pangolin, Cloudflare Access, etc.) that gate access with + * their own header-based token auth, independent of qBittorrent's own auth. + * + * Key exports: CustomHeadersSection + */ +import React from 'react'; +import { View, Text, TextInput, StyleSheet, TouchableOpacity, Switch } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from '@/context/ThemeContext'; +import { SettingRow } from '@/components/SettingRow'; +import { CustomHeaderPair, isReservedHeaderName } from '@/utils/customHeaders'; + +const MAX_CUSTOM_HEADERS = 5; + +interface CustomHeadersSectionProps { + useCustomHeaders: boolean; + headers: CustomHeaderPair[]; + onUseCustomHeadersChange: (value: boolean) => void; + onHeadersChange: (headers: CustomHeaderPair[]) => void; +} + +export function CustomHeadersSection({ + useCustomHeaders, + headers, + onUseCustomHeadersChange, + onHeadersChange, +}: CustomHeadersSectionProps) { + const { t } = useTranslation(); + const { colors } = useTheme(); + + const handleToggle = (value: boolean) => { + onUseCustomHeadersChange(value); + if (value && headers.length === 0) { + onHeadersChange([{ key: '', value: '' }]); + } + }; + + const updateHeader = (index: number, field: keyof CustomHeaderPair, text: string) => { + onHeadersChange( + headers.map((header, i) => (i === index ? { ...header, [field]: text } : header)), + ); + }; + + const addHeader = () => { + if (headers.length >= MAX_CUSTOM_HEADERS) return; + onHeadersChange([...headers, { key: '', value: '' }]); + }; + + const removeHeader = (index: number) => { + onHeadersChange(headers.filter((_, i) => i !== index)); + }; + + const atMax = headers.length >= MAX_CUSTOM_HEADERS; + + return ( + + + {t('server.customHeaders')} + + + + + + {useCustomHeaders && ( + <> + {headers.map((header, index) => { + const reserved = isReservedHeaderName(header.key); + return ( + + + + + updateHeader(index, 'key', text)} + placeholder={t('placeholders.headerName')} + placeholderTextColor={colors.textSecondary} + autoCapitalize="none" + autoCorrect={false} + textContentType="none" + autoComplete="off" + /> + updateHeader(index, 'value', text)} + placeholder={t('placeholders.headerValue')} + placeholderTextColor={colors.textSecondary} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + textContentType="none" + autoComplete="off" + passwordRules="" + /> + + removeHeader(index)} + style={styles.removeButton} + accessibilityLabel={t('server.removeHeader')} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + {reserved && ( + + {t('errors.reservedHeaderName', { name: header.key.trim() })} + + )} + + ); + })} + + + + + {t('server.addHeader')} + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + section: { + marginTop: 24, + paddingHorizontal: 16, + }, + sectionHeader: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.5, + marginBottom: 8, + marginLeft: 4, + }, + card: { + borderRadius: 16, + overflow: 'hidden', + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + }, + headerInputs: { + flex: 1, + gap: 8, + }, + headerInput: { + fontSize: 15, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + borderWidth: 1, + }, + removeButton: { + marginLeft: 12, + }, + separator: { + height: 1, + marginLeft: 16, + }, + addRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 14, + }, + addRowText: { + fontSize: 15, + fontWeight: '500', + }, + hintText: { + fontSize: 12, + lineHeight: 16, + paddingHorizontal: 16, + paddingBottom: 8, + }, +}); diff --git a/components/SuperDebugPanel.tsx b/components/SuperDebugPanel.tsx index f705e540..14008126 100644 --- a/components/SuperDebugPanel.tsx +++ b/components/SuperDebugPanel.tsx @@ -28,6 +28,7 @@ import { getConnectivityLog, formatConnectivityLog } from '@/services/connectivi import { logsApi } from '@/services/api/logs'; import { apiClient } from '@/services/api/client'; import { getErrorMessage } from '@/utils/error'; +import { CustomHeaderPair, sanitizeCustomHeaders } from '@/utils/customHeaders'; import { isLoginBodyFail, isLoginSuccess } from '@/utils/login-response'; // --------------------------------------------------------------------------- @@ -112,6 +113,9 @@ export interface SuperDebugPanelProps { useBasicAuth?: boolean; basicAuthUsername?: string; basicAuthPassword?: string; + /** Optional per-server custom headers (#228) — sent on every diagnostic request, mirroring the real client. */ + useCustomHeaders?: boolean; + customHeaders?: CustomHeaderPair[]; } type DiagnosticStep = 'REACH' | 'LOGIN' | 'COOKIE' | 'API' | 'INFO' | 'WARN' | 'ERROR'; @@ -159,6 +163,8 @@ export function SuperDebugPanel({ useBasicAuth = false, basicAuthUsername = '', basicAuthPassword = '', + useCustomHeaders = false, + customHeaders = [], }: SuperDebugPanelProps) { const { colors } = useTheme(); const [log, setLog] = useState([]); @@ -220,6 +226,23 @@ export function SuperDebugPanel({ return 'Basic ' + b64; }, [useApiKey, apiKey, useBasicAuth, basicAuthUsername, basicAuthPassword]); + /** Serialized so an inline `customHeaders` array prop doesn't churn the + * callbacks below on every render. */ + const customHeadersKey = JSON.stringify( + useCustomHeaders ? sanitizeCustomHeaders(customHeaders) : [], + ); + + /** Per-server custom headers (#228), attached to every diagnostic request so + * the diagnostic traverses the same proxy/tunnel the real client does — + * otherwise a header-gated server fails here while working in the app. */ + const buildCustomHeaders = useCallback((): Record => { + const result: Record = {}; + for (const header of JSON.parse(customHeadersKey) as CustomHeaderPair[]) { + result[header.key] = header.value; + } + return result; + }, [customHeadersKey]); + /** Fingerprint of everything that affects the diagnostic's outcome, so a * validated session is only trusted for export while the form still * matches the config it was actually proven against. */ @@ -236,6 +259,7 @@ export function SuperDebugPanel({ useBasicAuth, basicAuthUsername, basicAuthPassword, + customHeaders: customHeadersKey, }); }, [ host, @@ -249,6 +273,7 @@ export function SuperDebugPanel({ useBasicAuth, basicAuthUsername, basicAuthPassword, + customHeadersKey, ]); const addEntry = useCallback( @@ -298,7 +323,7 @@ export function SuperDebugPanel({ try { const authHeader = buildAuthHeader(); - const reachHeaders: Record = {}; + const reachHeaders: Record = { ...buildCustomHeaders() }; if (authHeader) reachHeaders['Authorization'] = authHeader; let response: Response; try { @@ -486,8 +511,14 @@ export function SuperDebugPanel({ }, 15000); const authHeader = buildAuthHeader(); - const diagHeaders: Record = {}; + const customHeaderMap = buildCustomHeaders(); + const diagHeaders: Record = { ...customHeaderMap }; if (authHeader) diagHeaders['Authorization'] = authHeader; + const customHeaderNames = Object.keys(customHeaderMap); + if (customHeaderNames.length > 0) { + // Names only — values are tokens, and this log gets copied into issues. + addEntry('INFO', `Sending custom headers: ${customHeaderNames.join(', ')}`, 'info'); + } try { let reachResp: Response; @@ -581,6 +612,7 @@ export function SuperDebugPanel({ `username=${encodeURIComponent(username.trim())}&password=${encodeURIComponent(password.trim())}`, { headers: { + ...customHeaderMap, 'Content-Type': 'application/x-www-form-urlencoded', ...(authHeader ? { Authorization: authHeader } : {}), }, @@ -701,7 +733,7 @@ export function SuperDebugPanel({ const versionUrl = `${baseUrl}/api/v2/app/version`; const apiStart = Date.now(); try { - const headers: Record = {}; + const headers: Record = { ...customHeaderMap }; if (sessionCookie) { headers['Cookie'] = sessionCookie; } @@ -947,7 +979,10 @@ export function SuperDebugPanel({ : null; if (validatedSession) { - const logHeaders: Record = {}; + // Safe to rebuild rather than snapshot: buildConfigKey() covers the + // custom headers, so a validated session is already discarded above if + // they changed since it was proven. + const logHeaders: Record = { ...buildCustomHeaders() }; if (validatedSession.sessionCookie) logHeaders['Cookie'] = validatedSession.sessionCookie; if (validatedSession.authHeader) logHeaders['Authorization'] = validatedSession.authHeader; diff --git a/constants/changelog.ts b/constants/changelog.ts index abe70615..096ab5c3 100644 --- a/constants/changelog.ts +++ b/constants/changelog.ts @@ -27,6 +27,7 @@ export const CHANGELOG: ChangelogRelease[] = [ items: [ 'Torrents can now be sorted by priority', 'Added Increase Priority and Decrease Priority actions to the torrent menu', + 'Added support for custom HTTP headers per server, for tunnels and proxies with their own token-based authentication', ], }, ], diff --git a/locales/de/translation.json b/locales/de/translation.json index bd8c6576..ad8ee808 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -641,7 +641,9 @@ "enterKbs": "KiB/s eingeben", "fallbackHost": "Fallback-Host oder IP", "proxyUsername": "Proxy-Benutzername", - "proxyPassword": "Proxy-Passwort" + "proxyPassword": "Proxy-Passwort", + "headerName": "Header-Name", + "headerValue": "Header-Wert" }, "actions": { "resume": "Fortsetzen", @@ -754,13 +756,21 @@ "useBasicAuth": "Basisauthentifizierung (Proxy)", "useBasicAuthHint": "Erforderlich, wenn ein passwortgeschützter Proxy den Server schützt", "apiKeyProxyConflict": "Proxy-Basisauthentifizierung kann nicht mit einem API-Schlüssel kombiniert werden — beide nutzen den Authorization-Header", + "customHeaders": "BENUTZERDEFINIERTE HEADER", + "useCustomHeaders": "Benutzerdefinierte HTTP-Header", + "useCustomHeadersHint": "Sendet bei jeder Anfrage zusätzliche Header — für Tunnel oder Proxys mit eigener headerbasierter Authentifizierung (z. B. Pangolin)", + "addHeader": "Header hinzufügen", + "removeHeader": "Header entfernen", "debugFullUrl": "Vollständige URL", "debugProtocol": "Protokoll", "debugHost": "Host", "debugPort": "Port", "debugLoginApi": "Login-API", "debugEmpty": "(leer)", - "debugDefaultPort": "Standard (80/443)" + "debugDefaultPort": "Standard (80/443)", + "debugCustomHeaders": "Benutzerdefinierte Header", + "debugCustomHeadersValue": "{{names}} (Werte ausgeblendet)", + "debugCustomHeadersNone": "Keine" }, "torrentDetail": { "notFound": "Torrent nicht gefunden", @@ -1079,6 +1089,8 @@ "fillNameAndHost": "Bitte Server-Name und Host ausfüllen", "fillUsernamePassword": "Bitte Benutzername und Passwort eingeben", "fillBasicAuthUsername": "Bitte Proxy-Benutzername für Basic Auth eingeben", + "fillCustomHeaderFields": "Bitte Name und Wert für jeden benutzerdefinierten Header ausfüllen oder den leeren entfernen", + "reservedHeaderName": "„{{name}}“ wird von qRemote verwaltet und kann nicht als benutzerdefinierter Header verwendet werden", "fillApiKey": "Bitte einen API-Schlüssel eingeben", "validPort": "Bitte gültige Portnummer eingeben (1-65535)", "fillFallbackHost": "Bitte einen Fallback-Host angeben", diff --git a/locales/en/translation.json b/locales/en/translation.json index 92f649c7..3c939df6 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -662,7 +662,9 @@ "enterKbs": "Enter KiB/s", "fallbackHost": "Fallback host or IP", "proxyUsername": "Proxy Username", - "proxyPassword": "Proxy Password" + "proxyPassword": "Proxy Password", + "headerName": "Header Name", + "headerValue": "Header Value" }, "actions": { "resume": "Resume", @@ -775,13 +777,21 @@ "useBasicAuth": "Basic Auth (Proxy)", "useBasicAuthHint": "Required when a password protected proxy protects the server", "apiKeyProxyConflict": "Proxy Basic Auth can't be combined with an API key — both use the Authorization header", + "customHeaders": "CUSTOM HEADERS", + "useCustomHeaders": "Custom HTTP Headers", + "useCustomHeadersHint": "Send extra headers with every request — for tunnels or proxies with their own header-based auth (e.g. Pangolin)", + "addHeader": "Add Header", + "removeHeader": "Remove header", "debugFullUrl": "Full URL", "debugProtocol": "Protocol", "debugHost": "Host", "debugPort": "Port", "debugLoginApi": "Login API", "debugEmpty": "(empty)", - "debugDefaultPort": "default (80/443)" + "debugDefaultPort": "default (80/443)", + "debugCustomHeaders": "Custom Headers", + "debugCustomHeadersValue": "{{names}} (values hidden)", + "debugCustomHeadersNone": "None" }, "torrentDetail": { "notFound": "Torrent not found", @@ -1100,6 +1110,8 @@ "fillNameAndHost": "Please fill in server name and host", "fillUsernamePassword": "Please fill in username and password", "fillBasicAuthUsername": "Please fill in the proxy username for Basic Auth", + "fillCustomHeaderFields": "Fill in both the name and value for each custom header, or remove the empty one", + "reservedHeaderName": "\"{{name}}\" is managed by qRemote and can't be used as a custom header", "fillApiKey": "Please enter an API key", "validPort": "Please enter a valid port number (1-65535)", "fillFallbackHost": "Please enter a fallback host", diff --git a/locales/es/translation.json b/locales/es/translation.json index 130fd1e3..b6379c96 100644 --- a/locales/es/translation.json +++ b/locales/es/translation.json @@ -641,7 +641,9 @@ "enterKbs": "Introducir KiB/s", "fallbackHost": "Host o IP de respaldo", "proxyUsername": "Usuario del proxy", - "proxyPassword": "Contraseña del proxy" + "proxyPassword": "Contraseña del proxy", + "headerName": "Nombre de cabecera", + "headerValue": "Valor de cabecera" }, "actions": { "resume": "Reanudar", @@ -754,13 +756,21 @@ "useBasicAuth": "Auth Básica (Proxy)", "useBasicAuthHint": "Necesario cuando un proxy protegido con contraseña protege el servidor", "apiKeyProxyConflict": "La Auth Básica del proxy no se puede combinar con una clave de API: ambas usan la cabecera Authorization", + "customHeaders": "CABECERAS PERSONALIZADAS", + "useCustomHeaders": "Cabeceras HTTP personalizadas", + "useCustomHeadersHint": "Envía cabeceras adicionales en cada solicitud, para túneles o proxies con su propia autenticación por cabecera (p. ej. Pangolin)", + "addHeader": "Añadir cabecera", + "removeHeader": "Eliminar cabecera", "debugFullUrl": "URL completa", "debugProtocol": "Protocolo", "debugHost": "Host", "debugPort": "Puerto", "debugLoginApi": "API de login", "debugEmpty": "(vacío)", - "debugDefaultPort": "predeterminado (80/443)" + "debugDefaultPort": "predeterminado (80/443)", + "debugCustomHeaders": "Cabeceras personalizadas", + "debugCustomHeadersValue": "{{names}} (valores ocultos)", + "debugCustomHeadersNone": "Ninguna" }, "torrentDetail": { "notFound": "Torrent no encontrado", @@ -1079,6 +1089,8 @@ "fillNameAndHost": "Rellena el nombre y host del servidor", "fillUsernamePassword": "Rellena usuario y contraseña", "fillBasicAuthUsername": "Introduce el usuario del proxy para la Auth Básica", + "fillCustomHeaderFields": "Completa el nombre y el valor de cada cabecera personalizada, o elimina la que esté vacía", + "reservedHeaderName": "\"{{name}}\" está gestionada por qRemote y no se puede usar como cabecera personalizada", "fillApiKey": "Introduce una clave de API", "validPort": "Introduce un número de puerto válido (1-65535)", "fillFallbackHost": "Introduce un host de respaldo", diff --git a/locales/fr/translation.json b/locales/fr/translation.json index f9102d1b..57b6fd4d 100644 --- a/locales/fr/translation.json +++ b/locales/fr/translation.json @@ -641,7 +641,9 @@ "enterKbs": "Entrer KiB/s", "fallbackHost": "Hôte ou IP de secours", "proxyUsername": "Nom d'utilisateur proxy", - "proxyPassword": "Mot de passe proxy" + "proxyPassword": "Mot de passe proxy", + "headerName": "Nom de l'en-tête", + "headerValue": "Valeur de l'en-tête" }, "actions": { "resume": "Reprendre", @@ -754,13 +756,21 @@ "useBasicAuth": "Auth Basique (Proxy)", "useBasicAuthHint": "Requis quand un proxy protégé par mot de passe protège le serveur", "apiKeyProxyConflict": "L'auth basique du proxy ne peut pas être combinée avec une clé API : les deux utilisent l'en-tête Authorization", + "customHeaders": "EN-TÊTES PERSONNALISÉS", + "useCustomHeaders": "En-têtes HTTP personnalisés", + "useCustomHeadersHint": "Envoie des en-têtes supplémentaires à chaque requête, pour les tunnels ou proxys ayant leur propre authentification par en-tête (ex. Pangolin)", + "addHeader": "Ajouter un en-tête", + "removeHeader": "Supprimer l'en-tête", "debugFullUrl": "URL complète", "debugProtocol": "Protocole", "debugHost": "Hôte", "debugPort": "Port", "debugLoginApi": "API de connexion", "debugEmpty": "(vide)", - "debugDefaultPort": "par défaut (80/443)" + "debugDefaultPort": "par défaut (80/443)", + "debugCustomHeaders": "En-têtes personnalisés", + "debugCustomHeadersValue": "{{names}} (valeurs masquées)", + "debugCustomHeadersNone": "Aucun" }, "torrentDetail": { "notFound": "Torrent introuvable", @@ -1079,6 +1089,8 @@ "fillNameAndHost": "Remplissez le nom et l'hôte du serveur", "fillUsernamePassword": "Remplissez le nom d'utilisateur et le mot de passe", "fillBasicAuthUsername": "Remplissez le nom d'utilisateur proxy pour l'Auth Basique", + "fillCustomHeaderFields": "Renseignez le nom et la valeur de chaque en-tête personnalisé, ou supprimez celui qui est vide", + "reservedHeaderName": "« {{name}} » est géré par qRemote et ne peut pas être utilisé comme en-tête personnalisé", "fillApiKey": "Veuillez saisir une clé API", "validPort": "Entrez un numéro de port valide (1-65535)", "fillFallbackHost": "Saisissez un hôte de secours", diff --git a/locales/ru/translation.json b/locales/ru/translation.json index fa785ad8..d769d562 100644 --- a/locales/ru/translation.json +++ b/locales/ru/translation.json @@ -662,7 +662,9 @@ "enterKbs": "КиБ/с", "fallbackHost": "Резервный хост или IP", "proxyUsername": "Имя пользователя прокси", - "proxyPassword": "Пароль прокси" + "proxyPassword": "Пароль прокси", + "headerName": "Имя заголовка", + "headerValue": "Значение заголовка" }, "actions": { "resume": "Запустить", @@ -775,13 +777,21 @@ "useBasicAuth": "Basic Auth (Прокси)", "useBasicAuthHint": "Требуется, если сервер защищён прокси с паролем", "apiKeyProxyConflict": "Basic Auth прокси нельзя использовать вместе с API-ключом — оба используют заголовок Authorization", + "customHeaders": "ПОЛЬЗОВАТЕЛЬСКИЕ ЗАГОЛОВКИ", + "useCustomHeaders": "Пользовательские HTTP-заголовки", + "useCustomHeadersHint": "Отправляет дополнительные заголовки с каждым запросом — для туннелей или прокси с собственной аутентификацией по заголовку (например, Pangolin)", + "addHeader": "Добавить заголовок", + "removeHeader": "Удалить заголовок", "debugFullUrl": "Полный URL", "debugProtocol": "Протокол", "debugHost": "Хост", "debugPort": "Порт", "debugLoginApi": "API входа", "debugEmpty": "(пусто)", - "debugDefaultPort": "по умолчанию (80/443)" + "debugDefaultPort": "по умолчанию (80/443)", + "debugCustomHeaders": "Пользовательские заголовки", + "debugCustomHeadersValue": "{{names}} (значения скрыты)", + "debugCustomHeadersNone": "Нет" }, "torrentDetail": { "notFound": "Торрент не найден", @@ -1100,6 +1110,8 @@ "fillNameAndHost": "Укажите имя сервера и хост", "fillUsernamePassword": "Укажите имя пользователя и пароль", "fillBasicAuthUsername": "Укажите имя пользователя прокси для Basic Auth", + "fillCustomHeaderFields": "Укажите имя и значение для каждого пользовательского заголовка либо удалите пустой", + "reservedHeaderName": "«{{name}}» управляется qRemote и не может использоваться как пользовательский заголовок", "fillApiKey": "Введите API-ключ", "validPort": "Укажите порт от 1 до 65535", "fillFallbackHost": "Введите резервный хост", diff --git a/locales/zh/translation.json b/locales/zh/translation.json index 686f9e90..ee56c596 100644 --- a/locales/zh/translation.json +++ b/locales/zh/translation.json @@ -641,7 +641,9 @@ "enterKbs": "输入 KiB/s", "fallbackHost": "备用主机或 IP", "proxyUsername": "代理用户名", - "proxyPassword": "代理密码" + "proxyPassword": "代理密码", + "headerName": "请求头名称", + "headerValue": "请求头值" }, "actions": { "resume": "恢复", @@ -759,8 +761,16 @@ "debugLoginApi": "登录 API", "debugEmpty": "(空)", "debugDefaultPort": "默认(80/443)", + "debugCustomHeaders": "自定义请求头", + "debugCustomHeadersValue": "{{names}}(值已隐藏)", + "debugCustomHeadersNone": "无", "useBasicAuthHint": "当密码保护的代理保护服务器时需要启用", - "apiKeyProxyConflict": "代理 Basic 认证无法与 API 密钥同时使用,两者都使用 Authorization 请求头" + "apiKeyProxyConflict": "代理 Basic 认证无法与 API 密钥同时使用,两者都使用 Authorization 请求头", + "customHeaders": "自定义请求头", + "useCustomHeaders": "自定义 HTTP 请求头", + "useCustomHeadersHint": "在每次请求中附加额外的请求头,适用于使用自有请求头认证的隧道或代理(例如 Pangolin)", + "addHeader": "添加请求头", + "removeHeader": "删除请求头" }, "torrentDetail": { "notFound": "未找到种子", @@ -1079,6 +1089,8 @@ "fillNameAndHost": "请填写服务器名称和主机", "fillUsernamePassword": "请填写用户名和密码", "fillBasicAuthUsername": "请填写 Basic 认证的代理用户名", + "fillCustomHeaderFields": "请为每个自定义请求头填写名称和值,或删除空白项", + "reservedHeaderName": "“{{name}}” 由 qRemote 管理,不能用作自定义请求头", "fillApiKey": "请输入 API 密钥", "validPort": "请输入有效端口号 (1-65535)", "fillFallbackHost": "请输入备用主机", diff --git a/services/api/client.ts b/services/api/client.ts index acf044cb..1ee6f0f2 100644 --- a/services/api/client.ts +++ b/services/api/client.ts @@ -8,6 +8,7 @@ import { ServerConfig } from '@/types/api'; import { clogDebug, clogInfo, clogWarn, clogError } from '@/services/connectivity-log'; import { ApiFeatures, getApiFeatures } from '@/utils/apiVersion'; import { basicAuthHeader } from '@/utils/basicAuth'; +import { isReservedHeaderName } from '@/utils/customHeaders'; /** An error from the API client, carrying the HTTP status when the request got a response. */ export interface ApiError extends Error { @@ -106,6 +107,24 @@ class ApiClient { // Add Origin header for CORS/authentication config.headers.Origin = `${protocol}://${host}${portPart}`; + // Custom headers (#228) — for tunnels/proxies with their own + // header-based auth (e.g. Pangolin), independent of qBittorrent's + // own auth mode. + // + // Reserved names are re-checked HERE rather than trusted from save-time + // validation. These are applied last, so a header named `Authorization` + // or `Cookie` would otherwise silently clobber the real auth this + // request depends on — and save-time validation is not the only way + // data reaches a ServerConfig (settings import spreads raw JSON). + // Enforce the invariant at the point where it actually matters. + if (this.currentServer.useCustomHeaders && this.currentServer.customHeaders?.length) { + for (const header of this.currentServer.customHeaders) { + if (header?.key && header?.value && !isReservedHeaderName(header.key)) { + config.headers[header.key] = header.value; + } + } + } + return config; }, (error) => { diff --git a/services/server-manager.ts b/services/server-manager.ts index 1149d2ae..73b62f44 100644 --- a/services/server-manager.ts +++ b/services/server-manager.ts @@ -116,8 +116,8 @@ export class ServerManager { /** * Build a shareable export of every saved server, with all secrets - * (password, proxy Basic Auth password, API key) stripped — see - * utils/server-export.ts. + * (password, proxy Basic Auth password, API key, custom headers) stripped + * — see utils/server-export.ts. */ static async exportServers(): Promise { const servers = await storageService.getServers(); @@ -151,6 +151,7 @@ export class ServerManager { password: current.password, basicAuthPassword: current.basicAuthPassword, apiKey: current.apiKey, + customHeaders: current.customHeaders, }); updated++; } else { diff --git a/services/storage.ts b/services/storage.ts index 1dfa0a2d..cdebd6db 100644 --- a/services/storage.ts +++ b/services/storage.ts @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import * as SecureStore from 'expo-secure-store'; import { ServerConfig } from '@/types/api'; import { AppPreferences } from '@/types/preferences'; +import { parseStoredCustomHeaders, sanitizeCustomHeaders } from '@/utils/customHeaders'; const STORAGE_KEYS = { SERVERS: 'servers', @@ -55,6 +56,9 @@ export const storageService = { // API key auth (key stored separately in SecureStore) useApiKey: s.useApiKey || false, apiKey: '', // Don't store API key in AsyncStorage + // Custom headers (#228) — values are treated as secrets, stored separately in SecureStore + useCustomHeaders: s.useCustomHeaders || false, + customHeaders: [], // Icon badge customization (#224) icon: s.icon || undefined, iconColor: s.iconColor || undefined, @@ -69,6 +73,13 @@ export const storageService = { server.basicAuthPassword ?? '', ); await SecureStore.setItemAsync(`server_api_key_${server.id}`, server.apiKey ?? ''); + // Sanitized at the chokepoint rather than trusting callers: settings import + // (app/(tabs)/settings/advanced.tsx) spreads arbitrary JSON into a + // ServerConfig, so this is the one place guaranteed to see every write. + await SecureStore.setItemAsync( + `server_custom_headers_${server.id}`, + JSON.stringify(sanitizeCustomHeaders(server.customHeaders)), + ); }, /** @@ -102,11 +113,15 @@ export const storageService = { const password = await readSecret(`server_password_${server.id}`); const basicAuthPassword = await readSecret(`server_basic_auth_password_${server.id}`); const apiKey = await readSecret(`server_api_key_${server.id}`); + const customHeaders = parseStoredCustomHeaders( + await readSecret(`server_custom_headers_${server.id}`), + ); return { ...server, password, basicAuthPassword, apiKey, + customHeaders, host: stripProtocol(server.host || ''), fallbackHost: server.fallbackHost ? stripProtocol(server.fallbackHost) @@ -144,7 +159,13 @@ export const storageService = { await AsyncStorage.setItem( STORAGE_KEYS.SERVERS, JSON.stringify( - filtered.map((s) => ({ ...s, password: '', basicAuthPassword: '', apiKey: '' })), + filtered.map((s) => ({ + ...s, + password: '', + basicAuthPassword: '', + apiKey: '', + customHeaders: [], + })), ), ); @@ -152,6 +173,7 @@ export const storageService = { await SecureStore.deleteItemAsync(`server_password_${id}`); await SecureStore.deleteItemAsync(`server_basic_auth_password_${id}`); await SecureStore.deleteItemAsync(`server_api_key_${id}`); + await SecureStore.deleteItemAsync(`server_custom_headers_${id}`); // If this was the current server, clear it const currentId = await this.getCurrentServerId(); diff --git a/tests/services/client-custom-headers.test.ts b/tests/services/client-custom-headers.test.ts new file mode 100644 index 00000000..ba998112 --- /dev/null +++ b/tests/services/client-custom-headers.test.ts @@ -0,0 +1,196 @@ +/** + * Tests that the apiClient request interceptor adds (or omits) custom HTTP + * headers based on ServerConfig.useCustomHeaders / customHeaders (#228). + */ + +jest.mock('@/services/connectivity-log', () => ({ + clogDebug: jest.fn(), + clogInfo: jest.fn(), + clogWarn: jest.fn(), + clogError: jest.fn(), +})); + +type RequestInterceptorFn = (config: Record) => Record; + +let capturedRequestInterceptor: RequestInterceptorFn | null = null; + +const mockAxiosInstance = { + interceptors: { + request: { + use: jest.fn((fn: RequestInterceptorFn) => { + capturedRequestInterceptor = fn; + }), + }, + response: { + use: jest.fn(), + }, + }, + defaults: { timeout: 10000 }, + post: jest.fn(), + get: jest.fn(), +}; + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: jest.fn(() => mockAxiosInstance), + }, + AxiosHeaders: class { + private headers: Record = {}; + constructor(initial?: Record) { + if (initial) Object.assign(this.headers, initial); + } + set(key: string, value: string) { + this.headers[key] = value; + } + get(key: string) { + return this.headers[key]; + } + }, + AxiosError: class extends Error {}, +})); + +jest.mock('@/utils/apiVersion', () => ({ + getApiFeatures: jest.fn(() => ({})), +})); + +import { apiClient } from '@/services/api/client'; +import type { ServerConfig } from '@/types/api'; + +function makeServer(overrides: Partial = {}): ServerConfig { + return { + id: 'test-server', + name: 'Test', + host: 'example.com', + port: 8080, + username: 'admin', + password: 'adminadmin', + useHttps: false, + bypassAuth: false, + ...overrides, + }; +} + +function runRequestInterceptor(server: ServerConfig): Record { + apiClient.setServer(server); + if (!capturedRequestInterceptor) throw new Error('Request interceptor not captured'); + const config = { headers: {} as Record, method: 'get', url: '/test' }; + return capturedRequestInterceptor(config) as Record; +} + +describe('apiClient request interceptor — custom headers', () => { + afterEach(() => { + apiClient.setServer(null); + }); + + it('does NOT add custom headers when useCustomHeaders is false', () => { + const config = runRequestInterceptor( + makeServer({ + useCustomHeaders: false, + customHeaders: [{ key: 'X-Pangolin-Token', value: 'tok' }], + }), + ); + const headers = config.headers as Record; + expect(headers['X-Pangolin-Token']).toBeUndefined(); + }); + + // Backwards compatibility (#228): a server saved before this feature has + // neither field set, and must behave exactly as it did before. + it('sends no extra headers for a legacy config with neither field set', () => { + const config = runRequestInterceptor(makeServer()); + const headers = config.headers as Record; + expect(Object.keys(headers).sort()).toEqual(['Origin', 'Referer']); + }); + + it('does NOT add custom headers when useCustomHeaders is undefined but headers exist', () => { + const config = runRequestInterceptor( + makeServer({ customHeaders: [{ key: 'X-Pangolin-Token', value: 'tok' }] }), + ); + const headers = config.headers as Record; + expect(headers['X-Pangolin-Token']).toBeUndefined(); + }); + + it('does NOT add custom headers when the list is empty', () => { + const config = runRequestInterceptor(makeServer({ useCustomHeaders: true, customHeaders: [] })); + const headers = config.headers as Record; + expect(Object.keys(headers)).not.toContain('X-Pangolin-Token'); + }); + + it('adds every configured header when useCustomHeaders is true', () => { + const config = runRequestInterceptor( + makeServer({ + useCustomHeaders: true, + customHeaders: [ + { key: 'X-Pangolin-Token', value: 'tok-secret' }, + { key: 'X-Client-Id', value: 'client-123' }, + ], + }), + ); + const headers = config.headers as Record; + expect(headers['X-Pangolin-Token']).toBe('tok-secret'); + expect(headers['X-Client-Id']).toBe('client-123'); + }); + + it('skips a header pair missing a key or value rather than crashing', () => { + const config = runRequestInterceptor( + makeServer({ + useCustomHeaders: true, + customHeaders: [ + { key: '', value: 'orphaned' }, + { key: 'X-Ok', value: 'ok' }, + ], + }), + ); + const headers = config.headers as Record; + expect(headers['X-Ok']).toBe('ok'); + expect(Object.values(headers)).not.toContain('orphaned'); + }); + + // Custom headers are applied last, so without an explicit guard a header + // named Authorization/Cookie would clobber the real auth for the request. + // Save-time validation is not the only way data reaches a ServerConfig. + it('refuses to let a custom header override the real Authorization header', () => { + const config = runRequestInterceptor( + makeServer({ + useApiKey: true, + apiKey: 'qbt_realkey1234567890123456789012', + useCustomHeaders: true, + customHeaders: [{ key: 'Authorization', value: 'Bearer attacker' }], + }), + ); + const headers = config.headers as Record; + expect(headers['Authorization']).toBe('Bearer qbt_realkey1234567890123456789012'); + }); + + it('refuses to let a custom header override Cookie, Referer, or Origin', () => { + const config = runRequestInterceptor( + makeServer({ + useCustomHeaders: true, + customHeaders: [ + { key: 'Cookie', value: 'SID=attacker' }, + { key: 'referer', value: 'https://evil.example.com/' }, + { key: 'Origin', value: 'https://evil.example.com' }, + ], + }), + ); + const headers = config.headers as Record; + expect(headers['Cookie']).toBeUndefined(); + expect(headers['referer']).toBeUndefined(); + expect(headers['Referer']).toBe('http://example.com:8080/'); + expect(headers['Origin']).toBe('http://example.com:8080'); + }); + + it('still sets Authorization alongside custom headers when both apply', () => { + const config = runRequestInterceptor( + makeServer({ + useApiKey: true, + apiKey: 'qbt_abcdefghijklmnopqrstuvwx1234', + useCustomHeaders: true, + customHeaders: [{ key: 'X-Pangolin-Token', value: 'tok-secret' }], + }), + ); + const headers = config.headers as Record; + expect(headers['Authorization']).toBe('Bearer qbt_abcdefghijklmnopqrstuvwx1234'); + expect(headers['X-Pangolin-Token']).toBe('tok-secret'); + }); +}); diff --git a/tests/services/storage.test.ts b/tests/services/storage.test.ts index f5a84a1e..c11a15f4 100644 --- a/tests/services/storage.test.ts +++ b/tests/services/storage.test.ts @@ -115,6 +115,83 @@ describe('storageService', () => { expect(raw[0].apiKey).toBe(''); }); + it('persists customHeaders separately, storing them in SecureStore', async () => { + await storageService.saveServer( + makeServer({ + useCustomHeaders: true, + customHeaders: [{ key: 'X-Pangolin-Token', value: 'tok_secret' }], + }), + ); + const servers = await storageService.getServers(); + expect(servers[0].useCustomHeaders).toBe(true); + expect(servers[0].customHeaders).toEqual([{ key: 'X-Pangolin-Token', value: 'tok_secret' }]); + const raw = JSON.parse(mockAsyncStorage['servers']); + expect(raw[0].customHeaders).toEqual([]); + expect(mockSecureStore['server_custom_headers_s1']).toBe( + JSON.stringify([{ key: 'X-Pangolin-Token', value: 'tok_secret' }]), + ); + }); + + // Backwards compatibility (#228): records written before custom headers + // existed have no useCustomHeaders/customHeaders keys in AsyncStorage and + // no server_custom_headers_{id} entry in SecureStore. There is no + // migration system, so these must keep loading untouched. + it('loads a legacy record that predates custom headers', async () => { + mockAsyncStorage['servers'] = JSON.stringify([ + { + id: 'legacy', + name: 'Legacy', + host: 'old.example.com', + port: 8080, + basePath: '/', + username: 'admin', + password: '', + useHttps: false, + bypassAuth: false, + }, + ]); + mockSecureStore['server_password_legacy'] = 'legacy-pass'; + + const servers = await storageService.getServers(); + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe('Legacy'); + expect(servers[0].password).toBe('legacy-pass'); + expect(servers[0].useCustomHeaders).toBeUndefined(); + expect(servers[0].customHeaders).toEqual([]); + }); + + it('degrades to no custom headers when the stored secret is corrupt', async () => { + await storageService.saveServer(makeServer()); + mockSecureStore['server_custom_headers_s1'] = '{not valid json'; + const servers = await storageService.getServers(); + expect(servers[0].customHeaders).toEqual([]); + }); + + it('drops malformed stored header entries instead of surfacing them', async () => { + await storageService.saveServer(makeServer()); + mockSecureStore['server_custom_headers_s1'] = + '[{"key":123},{"key":"X-Ok","value":"ok"},null]'; + const servers = await storageService.getServers(); + expect(servers[0].customHeaders).toEqual([{ key: 'X-Ok', value: 'ok' }]); + }); + + // saveServer is the one chokepoint every write passes through, including + // settings import, which spreads unvalidated JSON into a ServerConfig. + it('sanitizes on write so a caller cannot persist a reserved or malformed header', async () => { + await storageService.saveServer( + makeServer({ + useCustomHeaders: true, + customHeaders: [ + { key: 'Authorization', value: 'Bearer attacker' }, + { key: ' X-Token ', value: ' secret ' }, + { key: '', value: 'orphaned' }, + ], + }), + ); + const servers = await storageService.getServers(); + expect(servers[0].customHeaders).toEqual([{ key: 'X-Token', value: 'secret' }]); + }); + it('getServers returns [] when nothing stored', async () => { const servers = await storageService.getServers(); expect(servers).toEqual([]); @@ -169,6 +246,17 @@ describe('storageService', () => { expect(servers.find((s) => s.id === 's2')?.apiKey).toBe('qbt_keepme1234567890123456789012'); }); + it('removes the customHeaders secret on delete', async () => { + await storageService.saveServer( + makeServer({ + useCustomHeaders: true, + customHeaders: [{ key: 'X-Token', value: 'secret' }], + }), + ); + await storageService.deleteServer('s1'); + expect(mockSecureStore['server_custom_headers_s1']).toBeUndefined(); + }); + it('clears currentServerId when deleting the current server', async () => { await storageService.saveServer(makeServer()); await storageService.setCurrentServerId('s1'); diff --git a/tests/utils/customHeaders.test.ts b/tests/utils/customHeaders.test.ts new file mode 100644 index 00000000..a0f6c356 --- /dev/null +++ b/tests/utils/customHeaders.test.ts @@ -0,0 +1,150 @@ +import { + isReservedHeaderName, + parseStoredCustomHeaders, + sanitizeCustomHeaders, + validateCustomHeaders, +} from '@/utils/customHeaders'; + +describe('isReservedHeaderName', () => { + it.each(['Authorization', 'cookie', 'REFERER', 'Origin', 'content-type', 'Host'])( + 'flags %s as reserved (case-insensitive)', + (name) => { + expect(isReservedHeaderName(name)).toBe(true); + }, + ); + + it('does not flag an unrelated header name', () => { + expect(isReservedHeaderName('X-Pangolin-Token')).toBe(false); + }); + + it('trims surrounding whitespace before comparing', () => { + expect(isReservedHeaderName(' authorization ')).toBe(true); + }); +}); + +describe('sanitizeCustomHeaders', () => { + it('returns [] for undefined input', () => { + expect(sanitizeCustomHeaders(undefined)).toEqual([]); + }); + + it('trims whitespace from keys and values', () => { + expect(sanitizeCustomHeaders([{ key: ' X-Token ', value: ' secret ' }])).toEqual([ + { key: 'X-Token', value: 'secret' }, + ]); + }); + + it('drops rows where either side is blank', () => { + const result = sanitizeCustomHeaders([ + { key: 'X-Token', value: 'secret' }, + { key: '', value: 'orphaned-value' }, + { key: 'X-Empty', value: '' }, + { key: ' ', value: ' ' }, + ]); + expect(result).toEqual([{ key: 'X-Token', value: 'secret' }]); + }); +}); + +describe('sanitizeCustomHeaders — hostile input', () => { + // The settings-import path spreads unvalidated JSON into a ServerConfig, so + // the declared type is not a runtime guarantee here. + it('returns [] for a non-array masquerading as the right type', () => { + expect(sanitizeCustomHeaders('nope' as never)).toEqual([]); + expect(sanitizeCustomHeaders({ key: 'X', value: 'y' } as never)).toEqual([]); + }); + + it('drops entries whose key or value is not a string, without throwing', () => { + expect(() => sanitizeCustomHeaders([{ key: 123, value: 'y' }] as never)).not.toThrow(); + expect( + sanitizeCustomHeaders([ + { key: 123, value: 'y' }, + { key: 'X-Ok', value: 'ok' }, + null, + 'junk', + ] as never), + ).toEqual([{ key: 'X-Ok', value: 'ok' }]); + }); + + it('strips reserved names so an import cannot smuggle one in', () => { + expect( + sanitizeCustomHeaders([ + { key: 'Authorization', value: 'Bearer attacker' }, + { key: 'Cookie', value: 'SID=attacker' }, + { key: 'X-Ok', value: 'ok' }, + ]), + ).toEqual([{ key: 'X-Ok', value: 'ok' }]); + }); +}); + +describe('parseStoredCustomHeaders', () => { + it('returns [] for empty, null, or undefined input', () => { + expect(parseStoredCustomHeaders('')).toEqual([]); + expect(parseStoredCustomHeaders(null)).toEqual([]); + expect(parseStoredCustomHeaders(undefined)).toEqual([]); + }); + + it('returns [] for corrupt JSON', () => { + expect(parseStoredCustomHeaders('{not valid json')).toEqual([]); + }); + + it('returns [] for well-formed JSON that is not an array', () => { + expect(parseStoredCustomHeaders('{"key":"X","value":"y"}')).toEqual([]); + expect(parseStoredCustomHeaders('"a string"')).toEqual([]); + }); + + it('drops malformed entries that would crash callers on .trim()', () => { + expect(parseStoredCustomHeaders('[{"key":123},{"key":"X-Ok","value":"ok"},null]')).toEqual([ + { key: 'X-Ok', value: 'ok' }, + ]); + }); + + it('round-trips a well-formed payload', () => { + const headers = [{ key: 'X-Pangolin-Token', value: 'secret' }]; + expect(parseStoredCustomHeaders(JSON.stringify(headers))).toEqual(headers); + }); +}); + +describe('validateCustomHeaders', () => { + it('rejects an all-blank list', () => { + expect(validateCustomHeaders([{ key: '', value: '' }])).toEqual({ + valid: false, + error: 'empty', + }); + }); + + it('rejects a row with only a key filled in', () => { + expect(validateCustomHeaders([{ key: 'X-Token', value: '' }])).toEqual({ + valid: false, + error: 'incomplete', + }); + }); + + it('rejects a row with only a value filled in', () => { + expect(validateCustomHeaders([{ key: '', value: 'secret' }])).toEqual({ + valid: false, + error: 'incomplete', + }); + }); + + it('rejects a reserved header name and reports it', () => { + expect(validateCustomHeaders([{ key: 'Authorization', value: 'Bearer x' }])).toEqual({ + valid: false, + error: 'reserved', + reservedName: 'Authorization', + }); + }); + + it('accepts a fully filled, non-reserved header', () => { + expect(validateCustomHeaders([{ key: 'X-Pangolin-Token', value: 'secret' }])).toEqual({ + valid: true, + }); + }); + + it('ignores fully blank rows mixed in with a valid one', () => { + expect( + validateCustomHeaders([ + { key: 'X-Pangolin-Token', value: 'secret' }, + { key: '', value: '' }, + ]), + ).toEqual({ valid: true }); + }); +}); diff --git a/tests/utils/server-export.test.ts b/tests/utils/server-export.test.ts index 900b7226..5e7973a4 100644 --- a/tests/utils/server-export.test.ts +++ b/tests/utils/server-export.test.ts @@ -28,6 +28,8 @@ function makeServer(overrides: Partial = {}): ServerConfig { basicAuthPassword: 'proxy-secret', useApiKey: false, apiKey: 'key-secret', + useCustomHeaders: true, + customHeaders: [{ key: 'X-Pangolin-Token', value: 'header-secret' }], ...overrides, }; } @@ -38,6 +40,23 @@ describe('toExportedServer', () => { expect(exported.password).toBe(''); expect(exported.basicAuthPassword).toBe(''); expect(exported.apiKey).toBe(''); + expect(exported.customHeaders).toEqual([]); + }); + + it('keeps the useCustomHeaders flag, which is not a secret', () => { + const exported = toExportedServer(makeServer()); + expect(exported.useCustomHeaders).toBe(true); + }); + + // Backwards compatibility (#228): configs and export files written before + // custom headers existed carry neither field. + it('handles a legacy config with no custom-header fields', () => { + const legacy = makeServer(); + delete legacy.useCustomHeaders; + delete legacy.customHeaders; + const exported = toExportedServer(legacy); + expect(exported.useCustomHeaders).toBe(false); + expect(exported.customHeaders).toEqual([]); }); it('keeps connection settings, auth flags, and usernames', () => { @@ -89,6 +108,7 @@ describe('buildServerExport', () => { expect(json).not.toContain('super-secret'); expect(json).not.toContain('proxy-secret'); expect(json).not.toContain('key-secret'); + expect(json).not.toContain('header-secret'); }); }); @@ -106,6 +126,19 @@ describe('parseServerImport', () => { expect(servers[1]).toMatchObject({ id: 's2', name: 'Remote' }); }); + it('imports a legacy export file written before custom headers existed', () => { + const json = JSON.stringify({ + kind: SERVER_EXPORT_KIND, + version: SERVER_EXPORT_VERSION, + exportedAt: '2026-07-26T00:00:00.000Z', + servers: [{ id: 'old', name: 'Old', host: 'old.example.com', username: 'admin' }], + }); + const [server] = parseServerImport(json); + expect(server.name).toBe('Old'); + expect(server.useCustomHeaders).toBe(false); + expect(server.customHeaders).toEqual([]); + }); + it('rejects non-JSON text', () => { expect(() => parseServerImport('not json at all')).toThrow('Not a valid JSON file.'); }); @@ -154,6 +187,7 @@ describe('parseServerImport', () => { password: 'injected', basicAuthPassword: 'injected', apiKey: 'injected', + customHeaders: [{ key: 'X-Injected', value: 'injected' }], }, ], }); @@ -161,5 +195,6 @@ describe('parseServerImport', () => { expect(server.password).toBe(''); expect(server.basicAuthPassword).toBe(''); expect(server.apiKey).toBe(''); + expect(server.customHeaders).toEqual([]); }); }); diff --git a/types/api.ts b/types/api.ts index 39e579e8..d05cfdde 100644 --- a/types/api.ts +++ b/types/api.ts @@ -53,6 +53,11 @@ export interface ServerConfig { icon?: string; /** Hex color for the server's icon badge. Falls back to utils/server.ts avatarColor(name) when unset. */ iconColor?: string; + + /** When true, send extra HTTP headers on every request (for tunnels/proxies with their own header-based auth, e.g. Pangolin). */ + useCustomHeaders?: boolean; + /** Custom header name/value pairs (in-memory + SecureStore only — values are treated as secrets). */ + customHeaders?: { key: string; value: string }[]; } export type ServerEndpointKind = 'primary' | 'fallback'; diff --git a/utils/customHeaders.ts b/utils/customHeaders.ts new file mode 100644 index 00000000..846175c5 --- /dev/null +++ b/utils/customHeaders.ts @@ -0,0 +1,117 @@ +/** + * customHeaders.ts — Validation and sanitization for per-server custom HTTP headers (#228). + * + * Headers are sent on every request to support reverse-proxy/tunnel setups + * (Pangolin, Cloudflare Access, etc.) that gate access with their own header + * based token auth, layered independently of qBittorrent's own auth. + * + * Key exports: CustomHeaderPair, isReservedHeaderName, sanitizeCustomHeaders, validateCustomHeaders + */ + +export interface CustomHeaderPair { + key: string; + value: string; +} + +/** + * Header names qRemote already manages. Letting a custom header collide with + * one of these would silently break auth, cookie handling, or CORS instead of + * doing what the user intended. + */ +const RESERVED_HEADER_NAMES = new Set([ + 'authorization', + 'cookie', + 'referer', + 'origin', + 'content-type', + 'host', +]); + +export function isReservedHeaderName(name: string): boolean { + if (typeof name !== 'string') return false; + return RESERVED_HEADER_NAMES.has(name.trim().toLowerCase()); +} + +/** + * Parse the SecureStore payload back into header pairs. + * + * Deliberately paranoid: this is persisted data in an app with no migration + * system, so anything already on a device is there forever. Validates the + * *shape* of each entry, not just that the JSON parses — a well-formed + * `[{"key": 123}]` would otherwise reach `isReservedHeaderName` and throw on + * `.trim()`. Anything unrecognized degrades to [] rather than propagating. + */ +export function parseStoredCustomHeaders(raw: string | null | undefined): CustomHeaderPair[] { + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (entry): entry is CustomHeaderPair => + !!entry && + typeof entry === 'object' && + typeof (entry as CustomHeaderPair).key === 'string' && + typeof (entry as CustomHeaderPair).value === 'string', + ); +} + +function isCustomHeaderPair(entry: unknown): entry is CustomHeaderPair { + return ( + !!entry && + typeof entry === 'object' && + typeof (entry as CustomHeaderPair).key === 'string' && + typeof (entry as CustomHeaderPair).value === 'string' + ); +} + +/** + * Trim every pair, drop rows left blank, and drop reserved names. Used before + * persisting and before sending. + * + * Tolerates arbitrary input rather than trusting the declared type: the + * settings-import path spreads unvalidated JSON into a ServerConfig, so this + * can genuinely receive a non-array or malformed entries at runtime. + */ +export function sanitizeCustomHeaders(headers: CustomHeaderPair[] | undefined): CustomHeaderPair[] { + if (!Array.isArray(headers)) return []; + return headers + .filter(isCustomHeaderPair) + .map((header) => ({ key: header.key.trim(), value: header.value.trim() })) + .filter( + (header) => + header.key.length > 0 && header.value.length > 0 && !isReservedHeaderName(header.key), + ); +} + +export type CustomHeaderValidationError = 'empty' | 'incomplete' | 'reserved'; + +export interface CustomHeaderValidation { + valid: boolean; + error?: CustomHeaderValidationError; + reservedName?: string; +} + +/** + * Validates the raw (unsanitized) row state from the add/edit server forms. + * Called only when the "use custom headers" toggle is on, mirroring how + * useBasicAuth requires a username. + */ +export function validateCustomHeaders(headers: CustomHeaderPair[]): CustomHeaderValidation { + const nonBlankRows = headers.filter((header) => header.key.trim() || header.value.trim()); + if (nonBlankRows.length === 0) { + return { valid: false, error: 'empty' }; + } + for (const header of nonBlankRows) { + if (!header.key.trim() || !header.value.trim()) { + return { valid: false, error: 'incomplete' }; + } + if (isReservedHeaderName(header.key)) { + return { valid: false, error: 'reserved', reservedName: header.key.trim() }; + } + } + return { valid: true }; +} diff --git a/utils/server-export.ts b/utils/server-export.ts index f9df0f0e..456b39b7 100644 --- a/utils/server-export.ts +++ b/utils/server-export.ts @@ -2,8 +2,9 @@ * server-export.ts — Pure build/parse logic for the server-list export file * (Settings → Servers → Export/Import Servers). * - * Secrets (`password`, `basicAuthPassword`, `apiKey`) are NEVER written to an - * export and are forced empty on import regardless of what a file contains — + * Secrets (`password`, `basicAuthPassword`, `apiKey`, `customHeaders`) are + * NEVER written to an export and are forced empty on import regardless of + * what a file contains — * the same rule `services/storage.ts` applies before anything reaches * AsyncStorage. Auth mode flags and usernames are kept so an imported server * only needs its secret re-entered. @@ -60,6 +61,8 @@ export function toExportedServer(server: ServerConfig): ServerConfig { basicAuthPassword: '', useApiKey: server.useApiKey === true, apiKey: '', + useCustomHeaders: server.useCustomHeaders === true, + customHeaders: [], icon: optionalString(server.icon), iconColor: optionalString(server.iconColor), };