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.
This commit is contained in:
edde746
2026-07-27 17:44:52 +02:00
parent 6b87b6551e
commit 5f77da93d1
2 changed files with 143 additions and 1 deletions
@@ -1,10 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Media servers may be HTTP or use a private CA installed by the device owner. -->
<!-- Fallback for user-entered hosts only: a self-hosted Plex/Jellyfin/Seerr
server, or a custom Watch Together relay, may be plain HTTP or fronted
by a private CA the device owner installed. Their hostnames are not
knowable at build time, so they can only be covered here. -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<!-- First-party / fixed endpoints. These carry account tokens, OAuth
codes, crash payloads and update metadata, and by construction never
live behind a private CA. System anchors only, i.e. the pre-2.9.1
behaviour. Every hard-coded https:// host in lib/ must appear here.
Pinned by test/android/network_security_config_test.dart. -->
<domain-config>
<!-- plex.tv, clients.plex.tv, app.plex.tv, discover.provider.plex.tv,
epg.provider.plex.tv (lib/services/plex_auth_service.dart:53-54,167,
lib/services/plex_discover_client.dart:57,
lib/services/plex_client/parts/live_tv.dart:3).
Does NOT match *.plex.direct — user PMS keeps base-config. -->
<domain includeSubdomains="true">plex.tv</domain>
<!-- ice.plezy.app: relay + /auth OAuth proxy + /posters
(lib/watch_together/services/watch_together_relay_endpoint.dart:8,
lib/services/trackers/oauth_proxy_client.dart:22,
lib/services/discord_rpc_service.dart:55);
bugs.plezy.app: Sentry DSN (lib/main.dart:90). -->
<domain includeSubdomains="true">plezy.app</domain>
<!-- api.trakt.tv incl. /oauth/device/*, /oauth/token
(lib/services/trakt/trakt_constants.dart:16,21-24). -->
<domain includeSubdomains="true">trakt.tv</domain>
<!-- api.myanimelist.net + myanimelist.net/v1/oauth2/token
(lib/services/trackers/mal/mal_constants.dart:19-20). -->
<domain includeSubdomains="true">myanimelist.net</domain>
<!-- graphql.anilist.co (lib/services/trackers/anilist/anilist_constants.dart:9). -->
<domain includeSubdomains="true">anilist.co</domain>
<!-- api.simkl.com + simkl.com/pin
(lib/services/trackers/simkl/simkl_constants.dart:21,33). -->
<domain includeSubdomains="true">simkl.com</domain>
<!-- data.simkl.in (lib/services/trackers/simkl/simkl_constants.dart:22). -->
<domain includeSubdomains="true">simkl.in</domain>
<!-- cdn.jsdelivr.net: appcast (lib/services/update_service.dart:18) and
anime-list mappings (anime_lists_mapping_store.dart:37,
fribb_mapping_store.dart:49). -->
<domain includeSubdomains="true">jsdelivr.net</domain>
<!-- Release check (lib/services/update_service.dart:161). -->
<domain includeSubdomains="true">api.github.com</domain>
<!-- TMDB artwork (lib/services/catalog/seerr_catalog_source.dart:163). -->
<domain includeSubdomains="true">image.tmdb.org</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>
@@ -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 = <String>{
'plex.tv',
'plezy.app',
'trakt.tv',
'myanimelist.net',
'anilist.co',
'simkl.com',
'simkl.in',
'jsdelivr.net',
'api.github.com',
'image.tmdb.org',
};
const _fixedEndpointSourcePaths = <String>[
'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 = <String>{};
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');
});
}