From 5f77da93d16dd6f72001253c4c86e7b501bbe345 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:44:52 +0200 Subject: [PATCH] fix(android): keep fixed endpoints on system certificate authorities Scope user-installed CA trust to the user-entered server hosts that need it and pin the hard-coded first-party hosts to system anchors. The base config applied user CAs to every host, including plex.tv token exchange and the OAuth proxy. --- .../main/res/xml/network_security_config.xml | 49 +++++++++- .../android/network_security_config_test.dart | 95 +++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 test/android/network_security_config_test.dart diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index e1a44ef0..e00f189f 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -1,10 +1,57 @@ - + + + + + + plex.tv + + plezy.app + + trakt.tv + + myanimelist.net + + anilist.co + + simkl.com + + simkl.in + + jsdelivr.net + + api.github.com + + image.tmdb.org + + + + diff --git a/test/android/network_security_config_test.dart b/test/android/network_security_config_test.dart new file mode 100644 index 00000000..b00a29d4 --- /dev/null +++ b/test/android/network_security_config_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; + +const _configPath = 'android/app/src/main/res/xml/network_security_config.xml'; +const _expectedDomains = { + 'plex.tv', + 'plezy.app', + 'trakt.tv', + 'myanimelist.net', + 'anilist.co', + 'simkl.com', + 'simkl.in', + 'jsdelivr.net', + 'api.github.com', + 'image.tmdb.org', +}; +const _fixedEndpointSourcePaths = [ + 'lib/services/plex_auth_service.dart', + 'lib/services/plex_discover_client.dart', + 'lib/services/plex_client/parts/live_tv.dart', + 'lib/services/trakt/trakt_constants.dart', + 'lib/services/trackers/mal/mal_constants.dart', + 'lib/services/trackers/anilist/anilist_constants.dart', + 'lib/services/trackers/simkl/simkl_constants.dart', + 'lib/watch_together/services/watch_together_relay_endpoint.dart', + 'lib/main.dart', +]; + +void main() { + late XmlDocument config; + + setUpAll(() { + config = XmlDocument.parse(File(_configPath).readAsStringSync()); + }); + + test('base config retains user-installed certificate authorities', () { + final baseConfig = config.rootElement.findElements('base-config').single; + final certificateSources = baseConfig + .findAllElements('certificates') + .map((certificate) => certificate.getAttribute('src')); + + expect(certificateSources, contains('user')); + }); + + test('fixed endpoint domains trust only system certificate authorities', () { + final domainConfigs = config.rootElement.findElements('domain-config').toList(); + + expect(domainConfigs, hasLength(1)); + for (final domainConfig in domainConfigs) { + final certificateSources = domainConfig + .findAllElements('certificates') + .map((certificate) => certificate.getAttribute('src')); + expect(certificateSources, isNot(contains('user'))); + } + + final firstPartyConfig = domainConfigs.single; + final domains = firstPartyConfig.findElements('domain').toList(); + expect(domains.map((domain) => domain.innerText.trim()).toSet(), unorderedEquals(_expectedDomains)); + expect( + domains.map((domain) => domain.getAttribute('includeSubdomains')), + everyElement('true'), + reason: 'Every fixed parent domain must also protect its hard-coded subdomains', + ); + + final trustAnchors = firstPartyConfig.findElements('trust-anchors').single; + final certificates = trustAnchors.findElements('certificates').toList(); + expect(certificates, hasLength(1)); + expect(certificates.single.getAttribute('src'), 'system'); + }); + + test('hard-coded HTTPS hosts remain covered by a system-only domain', () { + final httpsLiteralPattern = RegExp("https://[^'\"\\s]+"); + final discoveredHosts = {}; + + for (final sourcePath in _fixedEndpointSourcePaths) { + final sourceLines = File(sourcePath).readAsLinesSync(); + for (final line in sourceLines.where((line) => !line.trimLeft().startsWith('//'))) { + for (final match in httpsLiteralPattern.allMatches(line)) { + final literal = match.group(0)!; + final host = Uri.parse(literal).host.toLowerCase(); + discoveredHosts.add(host); + expect( + _expectedDomains.any((domain) => host == domain || host.endsWith('.$domain')), + isTrue, + reason: '$literal in $sourcePath is not covered by $_configPath', + ); + } + } + } + + expect(discoveredHosts, isNotEmpty, reason: 'The fixed-endpoint source scan must discover HTTPS literals'); + }); +}