diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 3c0f7a2b..ae25bcc6 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -443,9 +443,12 @@ class _AddJellyfinScreenState extends State with AsyncFormSta return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: clientVersion, deviceName: deviceName); } + /// The raw name, not a header-sanitized one: the Jellyfin `MediaBrowser` + /// header percent-encodes it, so the device list shows it verbatim. Future _resolveDeviceName() async { final identity = await DeviceIdentityService.resolve(); - return sanitizeHeaderValue(identity.deviceName) ?? 'Plezy'; + final name = identity.deviceName?.trim(); + return name == null || name.isEmpty ? 'Plezy' : name; } @override diff --git a/lib/services/jellyfin_auth_header.dart b/lib/services/jellyfin_auth_header.dart index 624a1b4b..be1821b4 100644 --- a/lib/services/jellyfin_auth_header.dart +++ b/lib/services/jellyfin_auth_header.dart @@ -1,14 +1,24 @@ import '../utils/device_identity.dart'; -/// Build the `MediaBrowser` Authorization header value the way the Jellyfin -/// SDK formats it. Used at auth time and on every authenticated request so -/// the server sees a consistent client identity. +/// Build the `MediaBrowser` Authorization header value the way the official +/// Jellyfin SDK formats it: every field value is percent-encoded, and the +/// server reverses that with `WebUtility.UrlDecode` while parsing the header. +/// Used at auth time and on every authenticated request so the server sees a +/// consistent client identity. /// -/// Unsafe header characters and embedded quotes are removed. Jellyfin requires -/// non-empty client, device, and version fields when creating a session, so -/// those values use stable fallbacks. An empty device ID is omitted for -/// authenticated requests, where Jellyfin can recover it from the token; -/// unauthenticated entry points must call [requireJellyfinDeviceId]. +/// Encoding is what keeps the header sendable at all. A device name like +/// `Bjørn PC` cannot travel verbatim: `dart:io` rejects header values above +/// 0x7F outright, and CFNetwork puts the raw code unit on the wire as a +/// Latin-1 byte, which Kestrel — the HTTP server hosting Jellyfin — refuses +/// as a malformed header with 400 before the request is ever routed. It also +/// removes the grammar hazards the header has no escape for: quotes, commas, +/// and `=` inside a value. +/// +/// Jellyfin requires non-empty client, device, and version fields when +/// creating a session, so those values use stable fallbacks. An empty device +/// ID is omitted for authenticated requests, where Jellyfin can recover it +/// from the token; unauthenticated entry points must call +/// [requireJellyfinDeviceId]. String buildJellyfinAuthHeader({ required String clientName, required String clientVersion, @@ -16,27 +26,33 @@ String buildJellyfinAuthHeader({ required String deviceId, String? accessToken, }) { - String clean(String value) => (sanitizeHeaderValue(value) ?? '').replaceAll('"', ''); + String field(String name, String value) => '$name="${Uri.encodeComponent(value)}"'; - final client = clean(clientName); + final client = _meaningful(clientName); final effectiveClient = client.isEmpty ? 'Plezy' : client; - final device = clean(deviceName); - final effectiveDevice = device.isEmpty ? effectiveClient : device; - final id = clean(deviceId); - final version = clean(clientVersion); - final token = accessToken == null ? '' : clean(accessToken); - String quoted(String value) => '"$value"'; + final device = _meaningful(deviceName); + final version = _meaningful(clientVersion); + final id = _meaningful(deviceId); + final token = _meaningful(accessToken ?? ''); final parts = [ - 'Client=${quoted(effectiveClient)}', - 'Device=${quoted(effectiveDevice)}', - if (id.isNotEmpty) 'DeviceId=${quoted(id)}', - 'Version=${quoted(version.isEmpty ? '1.0' : version)}', - if (token.isNotEmpty) 'Token=${quoted(token)}', + field('Client', effectiveClient), + field('Device', device.isEmpty ? effectiveClient : device), + if (id.isNotEmpty) field('DeviceId', id), + field('Version', version.isEmpty ? '1.0' : version), + if (token.isNotEmpty) field('Token', token), ]; return 'MediaBrowser ${parts.join(', ')}'; } +final RegExp _controlCharacters = RegExp(r'[\x00-\x1f\x7f-\x9f]'); + +/// Percent-encoding makes any byte transportable, so the only values worth +/// filtering are the ones that carry no identity at all — a name of control +/// characters would otherwise reach Jellyfin's device list as `%00` noise +/// instead of falling back to a readable label. +String _meaningful(String value) => value.replaceAll(_controlCharacters, '').trim(); + /// Validates the stable device identity required by unauthenticated Jellyfin /// session creation. Never substitute a placeholder: Jellyfin keys sessions /// and access tokens by this value, so a shared fallback would collide across diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index a34205cf..7e3dbbf0 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -141,9 +141,11 @@ class JellyfinClient } catch (_) { // Tests / non-platform contexts — keep the fallback version. } + // Raw, not header-sanitized: [buildJellyfinAuthHeader] percent-encodes it. String? deviceName; try { - deviceName = sanitizeHeaderValue((await DeviceIdentityService.resolve()).deviceName); + final resolved = (await DeviceIdentityService.resolve()).deviceName?.trim(); + if (resolved != null && resolved.isNotEmpty) deviceName = resolved; } catch (_) { // Tests / non-platform contexts — keep the fallback name. } diff --git a/lib/utils/device_identity.dart b/lib/utils/device_identity.dart index 28187f60..22aea2dc 100644 --- a/lib/utils/device_identity.dart +++ b/lib/utils/device_identity.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:unorm_dart/unorm_dart.dart'; import 'app_logger.dart'; import 'platform_detector.dart'; @@ -98,13 +99,54 @@ class DeviceIdentityService { } } -/// Makes a free-form device name safe to send as an HTTP header value: -/// drops HTTP control characters and code units above 0xFF (dart:io's -/// HttpHeaders rejects them), trims, and returns null when nothing usable -/// remains. +/// Makes a free-form device name safe to send as an HTTP header value on +/// every transport Plezy uses: folds Latin letters to their base form +/// (`Bjørn PC` → `Bjorn PC`), drops whatever is still outside printable +/// ASCII, trims, and returns null when nothing usable remains. +/// +/// The ASCII restriction is not cosmetic. `dart:io` rejects header values +/// containing anything above 0x7F with a `FormatException`, and CFNetwork +/// puts the raw code unit on the wire as a Latin-1 byte, which HTTP servers +/// decoding headers as UTF-8 (Kestrel, hosting Jellyfin) reject as a +/// malformed request. Headers with a documented percent-encoded wire format +/// carry the name intact instead — see `buildJellyfinAuthHeader`. String? sanitizeHeaderValue(String? value) { if (value == null) return null; - final filtered = String.fromCharCodes(value.codeUnits.where((unit) => unit >= 0x20 && unit != 0x7F && unit <= 0xFF)); - final trimmed = filtered.trim(); + final buffer = StringBuffer(); + for (final unit in nfd(_foldNonDecomposableLatin(value)).codeUnits) { + if (unit >= 0x20 && unit < 0x7F) buffer.writeCharCode(unit); + } + final trimmed = buffer.toString().trim(); return trimmed.isEmpty ? null : trimmed; } + +/// Latin letters NFD leaves alone because they are single code points rather +/// than base + combining mark. Without this, Nordic and Central European +/// device names lose whole letters instead of being transliterated. +const Map _nonDecomposableLatin = { + 'æ': 'ae', + 'Æ': 'AE', + 'œ': 'oe', + 'Œ': 'OE', + 'ø': 'o', + 'Ø': 'O', + 'ß': 'ss', + 'đ': 'd', + 'Đ': 'D', + 'ð': 'd', + 'Ð': 'D', + 'þ': 'th', + 'Þ': 'Th', + 'ł': 'l', + 'Ł': 'L', + 'ħ': 'h', + 'Ħ': 'H', + 'ı': 'i', + 'ŧ': 't', + 'Ŧ': 'T', +}; + +final RegExp _nonDecomposableLatinPattern = RegExp('[${_nonDecomposableLatin.keys.join()}]'); + +String _foldNonDecomposableLatin(String value) => + value.replaceAllMapped(_nonDecomposableLatinPattern, (match) => _nonDecomposableLatin[match[0]]!); diff --git a/test/services/jellyfin_auth_header_test.dart b/test/services/jellyfin_auth_header_test.dart index 1da891fe..cf310fa6 100644 --- a/test/services/jellyfin_auth_header_test.dart +++ b/test/services/jellyfin_auth_header_test.dart @@ -1,6 +1,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/jellyfin_auth_header.dart'; +/// Every field value Jellyfin reads back out of the header, mirroring the +/// server's own parse: split on the top-level commas, strip the quotes, then +/// `UrlDecode` (`WebUtility.UrlDecode` in `AuthorizationContext.GetParts`). +Map parseAsJellyfinWould(String header) { + expect(header, startsWith('MediaBrowser ')); + return { + for (final part in header.substring('MediaBrowser '.length).split(', ')) + part.substring(0, part.indexOf('=')): Uri.decodeComponent( + part.substring(part.indexOf('=') + 1).replaceAll('"', ''), + ), + }; +} + void main() { group('buildJellyfinAuthHeader', () { test('formats the SDK-style MediaBrowser header', () { @@ -13,7 +26,7 @@ void main() { ); expect( header, - 'MediaBrowser Client="Plezy", Device="Living Room TV", DeviceId="dev-1", Version="1.2.3", Token="tok"', + 'MediaBrowser Client="Plezy", Device="Living%20Room%20TV", DeviceId="dev-1", Version="1.2.3", Token="tok"', ); }); @@ -30,15 +43,41 @@ void main() { } }); - test('strips embedded quotes so a device name cannot corrupt the header', () { + // Regression: 2.9.0 started sending the real device name verbatim, so a + // non-ASCII one made dart:io reject the header outright and made CFNetwork + // emit a Latin-1 byte that Jellyfin's host rejects with 400 before the + // login request is routed. + test('keeps a non-ASCII device name on the wire as ASCII the server decodes back', () { + const deviceName = 'Bjørn stue-TV 客厅 📺'; final header = buildJellyfinAuthHeader( clientName: 'Plezy', - clientVersion: '1.2.3', - deviceName: 'My "cool" TV', + clientVersion: '2.10.0', + deviceName: deviceName, deviceId: 'dev-1', accessToken: 'tok', ); - expect(header, contains('Device="My cool TV"')); + + // dart:io's own header-value rule: printable ASCII only. + expect(header, matches(RegExp(r'^[\x20-\x7e]+$'))); + expect(parseAsJellyfinWould(header)['Device'], deviceName); + }); + + test('keeps a device name that would corrupt the header grammar intact', () { + const deviceName = 'My "cool", TV = 1+2 100%'; + final header = buildJellyfinAuthHeader( + clientName: 'Plezy', + clientVersion: '1.2.3', + deviceName: deviceName, + deviceId: 'dev-1', + accessToken: 'tok', + ); + + final parsed = parseAsJellyfinWould(header); + expect(parsed['Device'], deviceName); + expect(parsed['Client'], 'Plezy'); + expect(parsed['DeviceId'], 'dev-1'); + expect(parsed['Version'], '1.2.3'); + expect(parsed['Token'], 'tok'); }); test('uses non-empty fallbacks for required session identity fields', () { diff --git a/test/utils/device_identity_test.dart b/test/utils/device_identity_test.dart index e52d7fd8..b39e8df6 100644 --- a/test/utils/device_identity_test.dart +++ b/test/utils/device_identity_test.dart @@ -3,12 +3,20 @@ import 'package:plezy/utils/device_identity.dart'; void main() { group('sanitizeHeaderValue', () { - test('passes plain latin-1 names through trimmed', () { + test('passes plain ASCII names through trimmed', () { expect(sanitizeHeaderValue(' Living Room TV '), 'Living Room TV'); - expect(sanitizeHeaderValue("Édouard's Mac"), "Édouard's Mac"); }); - test('strips code units above latin-1 (emoji would break dart:io headers)', () { + // Regression: header values above 0x7F make dart:io throw and CFNetwork + // emit a Latin-1 byte that UTF-8 header parsers reject as a bad request. + test('folds accented Latin letters instead of emitting them', () { + expect(sanitizeHeaderValue("Édouard's Mac"), "Edouard's Mac"); + expect(sanitizeHeaderValue('Bjørn stue-TV'), 'Bjorn stue-TV'); + expect(sanitizeHeaderValue('Håkons Æra Straße'), 'Hakons AEra Strasse'); + expect(sanitizeHeaderValue('Łukasz Đorđe'), 'Lukasz Dorde'); + }); + + test('drops code units that have no ASCII equivalent', () { expect(sanitizeHeaderValue('📱 Bob\'s iPhone'), "Bob's iPhone"); expect(sanitizeHeaderValue('电视'), isNull); });