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