fix(mobile): Remote video playback and asset download on Android with mTLS (#16403)

* Add class to apply SSL options

* Apply client certificate for native Android code

* Refactor self-signed check

* Allow self-signed certificates

* Fix Dart analysis

* Add HostnameVerifier

Android explicitly does NOT check the Common Name of a certificate,
only the Subject Alt Names. Chances are that someone who self-signs a
certificate doesn't go through the extra steps to add a SAN, and in
that case the connection would be prevented by the HostnameVerifier
even thought the TrustManager was fine with the certificate itself.

* Rename parameter like in Dart

* Fix NPE

* Catch all native errors in HttpSSLOptionsPlugin

* Workaround for too early onChanged() callback

* Fix formatting

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Robert Vollmer
2025-05-08 15:45:11 +02:00
committed by GitHub
parent 3a1e3e82e7
commit f75d853e9a
8 changed files with 218 additions and 32 deletions

View File

@@ -27,7 +27,7 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:logging/logging.dart';
@@ -42,7 +42,7 @@ void main() async {
// Warm-up isolate pool for worker manager
await workerManager.init(dynamicSpawning: true);
await migrateDatabaseIfNeeded(db);
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
runApp(
ProviderScope(

View File

@@ -32,7 +32,7 @@ import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -359,7 +359,7 @@ class BackgroundService {
],
);
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
ref
.read(apiServiceProvider)
.setAccessToken(Store.get(StoreKey.accessToken));

View File

@@ -1,16 +1,20 @@
import 'dart:io';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart';
class HttpSSLCertOverride extends HttpOverrides {
static final Logger _log = Logger("HttpSSLCertOverride");
final bool _allowSelfSignedSSLCert;
final String? _serverHost;
final SSLClientCertStoreVal? _clientCert;
late final SecurityContext? _ctxWithCert;
HttpSSLCertOverride() : _clientCert = SSLClientCertStoreVal.load() {
HttpSSLCertOverride(
this._allowSelfSignedSSLCert,
this._serverHost,
this._clientCert,
) {
if (_clientCert != null) {
_ctxWithCert = SecurityContext(withTrustedRoots: true);
if (_ctxWithCert != null) {
@@ -47,28 +51,15 @@ class HttpSSLCertOverride extends HttpOverrides {
return super.createHttpClient(context)
..badCertificateCallback = (X509Certificate cert, String host, int port) {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
// Check if user has allowed self signed SSL certificates.
bool selfSignedCertsAllowed =
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
bool isLoggedIn = Store.tryGet(StoreKey.currentUser) != null;
// Conduct server host checks if user is logged in to avoid making
// insecure SSL connections to services that are not the immich server.
if (isLoggedIn && selfSignedCertsAllowed) {
String serverHost =
Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
selfSignedCertsAllowed &= serverHost.contains(host);
if (_allowSelfSignedSSLCert) {
// Conduct server host checks if user is logged in to avoid making
// insecure SSL connections to services that are not the immich server.
if (_serverHost == null || _serverHost.contains(host)) {
return true;
}
}
if (!selfSignedCertsAllowed) {
_log.severe("Invalid SSL certificate for $host:$port");
}
return selfSignedCertsAllowed;
_log.severe("Invalid SSL certificate for $host:$port");
return false;
};
}
}

View File

@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:logging/logging.dart';
class HttpSSLOptions {
static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions');
static void apply() {
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
bool allowSelfSignedSSLCert =
Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
_apply(allowSelfSignedSSLCert);
}
static void applyFromSettings(bool newValue) {
_apply(newValue);
}
static void _apply(bool allowSelfSignedSSLCert) {
String? serverHost;
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
}
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
HttpOverrides.global =
HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
if (Platform.isAndroid) {
_channel.invokeMethod("apply", [
allowSelfSignedSSLCert,
serverHost,
clientCert?.data,
clientCert?.password,
]).onError<PlatformException>((e, _) {
final log = Logger("HttpSSLOptions");
log.severe('Failed to set SSL options', e.message);
});
}
}
}

View File

@@ -10,7 +10,7 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custome_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
@@ -104,7 +104,7 @@ class AdvancedSettings extends HookConsumerWidget {
valueNotifier: allowSelfSignedSSLCert,
title: "advanced_settings_self_signed_ssl_title".tr(),
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
onChanged: (_) => HttpOverrides.global = HttpSSLCertOverride(),
onChanged: HttpSSLOptions.applyFromSettings,
),
const CustomeProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn});
@@ -103,7 +104,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
return;
}
cert.save();
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
setState(
() => isCertExist = true,
);
@@ -152,7 +153,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
void removeCert(BuildContext context) {
SSLClientCertStoreVal.delete();
HttpOverrides.global = HttpSSLCertOverride();
HttpSSLOptions.apply();
setState(
() => isCertExist = false,
);