From eddd294a348cdf3413476918a96eab830b01eb13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 21:40:55 +0200 Subject: [PATCH] blocked all nextcloud requests in demo mode to stop fake-credential logins tripping brute-force protection --- lib/api/demo/demo_http_client.dart | 28 +++++++++++++++++++ lib/api/errors/network_exception.dart | 6 ++++ lib/api/marianumcloud/webdav/webdav_api.dart | 10 +++++++ lib/main.dart | 8 +++++- .../app/modules/chat/bloc/chat_bloc.dart | 3 +- lib/widget/downloads/download_trigger.dart | 3 +- 6 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 lib/api/demo/demo_http_client.dart diff --git a/lib/api/demo/demo_http_client.dart b/lib/api/demo/demo_http_client.dart new file mode 100644 index 0000000..e23c2d6 --- /dev/null +++ b/lib/api/demo/demo_http_client.dart @@ -0,0 +1,28 @@ +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +import '../../model/endpoint_data.dart'; +import '../errors/network_exception.dart'; +import 'demo_mode.dart'; + +/// Demo sessions carry made-up credentials. Every request they sent to the +/// real Nextcloud would count as a failed login there and, in bulk, trip the +/// server's brute-force throttle for the whole IP (HTTP 429 for everyone +/// behind it). Installed app-wide via [http.runWithClient]. +class DemoHttpClient extends http.BaseClient { + final http.Client _inner = IOClient(); + + @override + Future send(http.BaseRequest request) { + if (DemoMode.active && isNextcloudHost(request.url)) { + return Future.error(NetworkException.demoBlocked(request.url)); + } + return _inner.send(request); + } + + @override + void close() => _inner.close(); + + static bool isNextcloudHost(Uri url) => + url.host == EndpointData().nextcloud().domain; +} diff --git a/lib/api/errors/network_exception.dart b/lib/api/errors/network_exception.dart index 06b38df..56afe99 100644 --- a/lib/api/errors/network_exception.dart +++ b/lib/api/errors/network_exception.dart @@ -13,4 +13,10 @@ class NetworkException extends AppException { 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', technicalDetails: technicalDetails, ); + + /// A request a demo session must never send (see `DemoHttpClient`). + factory NetworkException.demoBlocked(Uri url) => NetworkException( + userMessage: 'Im Demo-Modus nicht verfügbar.', + technicalDetails: 'demo mode blocked $url', + ); } diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 7bd905d..781406a 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -3,6 +3,8 @@ import 'package:nextcloud/nextcloud.dart'; import '../../../model/endpoint_data.dart'; import '../../../session/session_manager.dart'; import '../../api_response.dart'; +import '../../demo/demo_mode.dart'; +import '../../errors/network_exception.dart'; abstract class WebdavApi { T genericParams; @@ -18,6 +20,14 @@ abstract class WebdavApi { /// changes (app password minted/renewed, account switch) so it never keeps /// authenticating with stale credentials. static Future get webdav { + // The WebDAV client talks dart:io directly, past DemoHttpClient. + if (DemoMode.active) { + return Future.error( + NetworkException.demoBlocked( + Uri.parse(EndpointData().nextcloud().origin()), + ), + ); + } // Keyed by user too: two accounts may share a password (no app password // minted), and the client would keep the previous login name. final nextcloud = SessionManager().requireNextcloud(); diff --git a/lib/main.dart b/lib/main.dart index 842c8d8..b328ff3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,11 +11,13 @@ import 'package:flutter/scheduler.dart' show timeDilation; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:http/http.dart' as http; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import 'api/demo/demo_http_client.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart'; @@ -117,7 +119,11 @@ void _installErrorHandlers() { }; } -Future main() async { +// Everything runs inside the zone so every package:http client created by the +// app (Talk, avatars, image caches) goes through DemoHttpClient. +Future main() => http.runWithClient(_main, DemoHttpClient.new); + +Future _main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); SessionManager().markUiEngine(); diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index 59f5d23..83b2bc8 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter/widgets.dart'; +import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_history.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart'; @@ -262,7 +263,7 @@ class ChatBloc } void _startLongPoll(String token) { - if (!_appResumed) return; + if (!_appResumed || DemoMode.active) return; // A load chain may finish after the user switched chats (A→B); without this // guard the stale chain hijacks the long-poll back to A and merges A's // messages into B's state while B never receives live updates. diff --git a/lib/widget/downloads/download_trigger.dart b/lib/widget/downloads/download_trigger.dart index aa5f49f..7386d25 100644 --- a/lib/widget/downloads/download_trigger.dart +++ b/lib/widget/downloads/download_trigger.dart @@ -6,6 +6,7 @@ import '../../share_intent/remote_file_ref.dart'; import '../../utils/downloads/download_job.dart'; import '../../utils/downloads/download_manager.dart'; import '../confirm_dialog.dart'; +import '../demo_restricted.dart'; /// Shared download trigger logic for the Files list and Talk chat bubbles. /// @@ -71,7 +72,7 @@ mixin DownloadTrigger on State { RemoteFileRef? remoteFile, }) async { final path = downloadRemotePath; - if (path == null) return; + if (path == null || guardDemoAction(context)) return; final job = await DownloadManager.instance.start( remotePath: path, name: name,