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');
+ });
+}