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);
}
/// 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 {
final identity = await DeviceIdentityService.resolve();
return sanitizeHeaderValue(identity.deviceName) ?? 'Plezy';
final name = identity.deviceName?.trim();
return name == null || name.isEmpty ? 'Plezy' : name;
}
@override
+37 -21
View File
@@ -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 = <String>[
'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
+3 -1
View File
@@ -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.
}
+48 -6
View File
@@ -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<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: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() {
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', () {
+11 -3
View File
@@ -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);
});