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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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]]!);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user