fix(jellyfin): percent-encode the MediaBrowser auth header

Since real device names started reaching the header, an accented one
made login impossible: dart:io refuses header values above 0x7F, and
CFNetwork puts the raw code unit on the wire as a Latin-1 byte, which
Kestrel rejects as a malformed request with 400 before Jellyfin routes
POST /Users/AuthenticateByName.

Encode every field the way the official Jellyfin SDK does; the server
already reverses it with WebUtility.UrlDecode, so the wire value stays
pure ASCII while the device list shows the real name. Quotes, commas
and `=` no longer need stripping either. sanitizeHeaderValue, which
still guards the Plex headers, now folds Latin letters to their base
form instead of emitting bytes no transport accepts.

close #1685
This commit is contained in:
edde746
2026-07-28 05:07:00 +02:00
parent 31b3689740
commit 53535e1678
6 changed files with 147 additions and 37 deletions
@@ -443,9 +443,12 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: clientVersion, deviceName: deviceName); 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<String> _resolveDeviceName() async { Future<String> _resolveDeviceName() async {
final identity = await DeviceIdentityService.resolve(); final identity = await DeviceIdentityService.resolve();
return sanitizeHeaderValue(identity.deviceName) ?? 'Plezy'; final name = identity.deviceName?.trim();
return name == null || name.isEmpty ? 'Plezy' : name;
} }
@override @override
+37 -21
View File
@@ -1,14 +1,24 @@
import '../utils/device_identity.dart'; import '../utils/device_identity.dart';
/// Build the `MediaBrowser` Authorization header value the way the Jellyfin /// Build the `MediaBrowser` Authorization header value the way the official
/// SDK formats it. Used at auth time and on every authenticated request so /// Jellyfin SDK formats it: every field value is percent-encoded, and the
/// the server sees a consistent client identity. /// 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 /// Encoding is what keeps the header sendable at all. A device name like
/// non-empty client, device, and version fields when creating a session, so /// `Bjørn PC` cannot travel verbatim: `dart:io` rejects header values above
/// those values use stable fallbacks. An empty device ID is omitted for /// 0x7F outright, and CFNetwork puts the raw code unit on the wire as a
/// authenticated requests, where Jellyfin can recover it from the token; /// Latin-1 byte, which Kestrel — the HTTP server hosting Jellyfin — refuses
/// unauthenticated entry points must call [requireJellyfinDeviceId]. /// 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({ String buildJellyfinAuthHeader({
required String clientName, required String clientName,
required String clientVersion, required String clientVersion,
@@ -16,27 +26,33 @@ String buildJellyfinAuthHeader({
required String deviceId, required String deviceId,
String? accessToken, 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 effectiveClient = client.isEmpty ? 'Plezy' : client;
final device = clean(deviceName); final device = _meaningful(deviceName);
final effectiveDevice = device.isEmpty ? effectiveClient : device; final version = _meaningful(clientVersion);
final id = clean(deviceId); final id = _meaningful(deviceId);
final version = clean(clientVersion); final token = _meaningful(accessToken ?? '');
final token = accessToken == null ? '' : clean(accessToken);
String quoted(String value) => '"$value"';
final parts = <String>[ final parts = <String>[
'Client=${quoted(effectiveClient)}', field('Client', effectiveClient),
'Device=${quoted(effectiveDevice)}', field('Device', device.isEmpty ? effectiveClient : device),
if (id.isNotEmpty) 'DeviceId=${quoted(id)}', if (id.isNotEmpty) field('DeviceId', id),
'Version=${quoted(version.isEmpty ? '1.0' : version)}', field('Version', version.isEmpty ? '1.0' : version),
if (token.isNotEmpty) 'Token=${quoted(token)}', if (token.isNotEmpty) field('Token', token),
]; ];
return 'MediaBrowser ${parts.join(', ')}'; 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 /// Validates the stable device identity required by unauthenticated Jellyfin
/// session creation. Never substitute a placeholder: Jellyfin keys sessions /// session creation. Never substitute a placeholder: Jellyfin keys sessions
/// and access tokens by this value, so a shared fallback would collide across /// and access tokens by this value, so a shared fallback would collide across
+3 -1
View File
@@ -141,9 +141,11 @@ class JellyfinClient
} catch (_) { } catch (_) {
// Tests / non-platform contexts — keep the fallback version. // Tests / non-platform contexts — keep the fallback version.
} }
// Raw, not header-sanitized: [buildJellyfinAuthHeader] percent-encodes it.
String? deviceName; String? deviceName;
try { try {
deviceName = sanitizeHeaderValue((await DeviceIdentityService.resolve()).deviceName); final resolved = (await DeviceIdentityService.resolve()).deviceName?.trim();
if (resolved != null && resolved.isNotEmpty) deviceName = resolved;
} catch (_) { } catch (_) {
// Tests / non-platform contexts — keep the fallback name. // Tests / non-platform contexts — keep the fallback name.
} }
+48 -6
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:unorm_dart/unorm_dart.dart';
import 'app_logger.dart'; import 'app_logger.dart';
import 'platform_detector.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: /// Makes a free-form device name safe to send as an HTTP header value on
/// drops HTTP control characters and code units above 0xFF (dart:io's /// every transport Plezy uses: folds Latin letters to their base form
/// HttpHeaders rejects them), trims, and returns null when nothing usable /// (`Bjørn PC` → `Bjorn PC`), drops whatever is still outside printable
/// remains. /// 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) { String? sanitizeHeaderValue(String? value) {
if (value == null) return null; if (value == null) return null;
final filtered = String.fromCharCodes(value.codeUnits.where((unit) => unit >= 0x20 && unit != 0x7F && unit <= 0xFF)); final buffer = StringBuffer();
final trimmed = filtered.trim(); 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; 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<String, String> _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]]!);
+44 -5
View File
@@ -1,6 +1,19 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/jellyfin_auth_header.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<String, String> 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() { void main() {
group('buildJellyfinAuthHeader', () { group('buildJellyfinAuthHeader', () {
test('formats the SDK-style MediaBrowser header', () { test('formats the SDK-style MediaBrowser header', () {
@@ -13,7 +26,7 @@ void main() {
); );
expect( expect(
header, 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( final header = buildJellyfinAuthHeader(
clientName: 'Plezy', clientName: 'Plezy',
clientVersion: '1.2.3', clientVersion: '2.10.0',
deviceName: 'My "cool" TV', deviceName: deviceName,
deviceId: 'dev-1', deviceId: 'dev-1',
accessToken: 'tok', 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', () { test('uses non-empty fallbacks for required session identity fields', () {
+11 -3
View File
@@ -3,12 +3,20 @@ import 'package:plezy/utils/device_identity.dart';
void main() { void main() {
group('sanitizeHeaderValue', () { 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(' 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('📱 Bob\'s iPhone'), "Bob's iPhone");
expect(sanitizeHeaderValue('电视'), isNull); expect(sanitizeHeaderValue('电视'), isNull);
}); });