diff --git a/lib/core/theme/theme_remote_install_policy.dart b/lib/core/theme/theme_remote_install_policy.dart index f41aa758..516b21fb 100644 --- a/lib/core/theme/theme_remote_install_policy.dart +++ b/lib/core/theme/theme_remote_install_policy.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/foundation.dart'; /// HTTPS trust rules for remote theme install (TP-F4). @@ -15,32 +16,54 @@ abstract final class ThemeRemoteInstallPolicy { return allowLocalhostInDebug; } - final ipv4 = _parseIpv4(host); - if (ipv4 != null) { - if (_isLoopbackIpv4(ipv4) || _isPrivateIpv4(ipv4) || _isLinkLocalIpv4(ipv4)) { + final ip = InternetAddress.tryParse(host); + if (ip != null) { + if (ip.isLoopback || ip.isLinkLocal) { return allowLocalhostInDebug; } + + if (ip.type == InternetAddressType.IPv4) { + if (_isPrivateIpv4(ip.rawAddress)) { + return allowLocalhostInDebug; + } + } else if (ip.type == InternetAddressType.IPv6) { + final bytes = ip.rawAddress; + + // Check for Unique Local Address (fc00::/7) -> first byte is 0xfc or 0xfd + final isUla = bytes[0] == 0xfc || bytes[0] == 0xfd; + + // Check for Multicast (ff00::/8) -> first byte is 0xff + final isMulticast = bytes[0] == 0xff; + + // Check for Unspecified (::) -> all 16 bytes are 0 + final isUnspecified = bytes.every((b) => b == 0); + + if (isUla || isMulticast || isUnspecified) { + return allowLocalhostInDebug; + } + + // Check for IPv4-mapped IPv6 address (::ffff:x.x.x.x) + if (_isIpv4Mapped(bytes)) { + final ipv4Bytes = bytes.sublist(12, 16); + if (ipv4Bytes[0] == 127 || // Loopback + (ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254) || // Link-local + _isPrivateIpv4(ipv4Bytes)) { + return allowLocalhostInDebug; + } + } + } } return true; } - static List? _parseIpv4(String host) { - final parts = host.split('.'); - if (parts.length != 4) return null; - final bytes = []; - for (final part in parts) { - final value = int.tryParse(part); - if (value == null || value < 0 || value > 255) return null; - bytes.add(value); + static bool _isIpv4Mapped(List bytes) { + for (var i = 0; i < 10; i++) { + if (bytes[i] != 0) return false; } - return bytes; + return bytes[10] == 0xff && bytes[11] == 0xff; } - static bool _isLoopbackIpv4(List ip) => ip[0] == 127; - - static bool _isLinkLocalIpv4(List ip) => ip[0] == 169 && ip[1] == 254; - static bool _isPrivateIpv4(List ip) { if (ip[0] == 10) return true; if (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31) return true; diff --git a/test/core/theme/theme_remote_install_policy_test.dart b/test/core/theme/theme_remote_install_policy_test.dart index 3114256b..b908cc00 100644 --- a/test/core/theme/theme_remote_install_policy_test.dart +++ b/test/core/theme/theme_remote_install_policy_test.dart @@ -57,5 +57,53 @@ void main() { isFalse, ); }); + + test('rejects link-local, unique-local, unspecified, and multicast IPv6 addresses', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[fe80::1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[fd00::1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[ff02::1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[::]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('rejects loopback and private IPv4-mapped IPv6 addresses', () { + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[::ffff:127.0.0.1]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + expect( + ThemeRemoteInstallPolicy.isAllowedUrl( + Uri.parse('https://[::ffff:192.168.1.10]/theme.json'), + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); }); }