blocked all nextcloud requests in demo mode to stop fake-credential logins tripping brute-force protection

This commit is contained in:
2026-09-27 21:40:55 +02:00
parent c455bb1cae
commit eddd294a34
6 changed files with 55 additions and 3 deletions
+28
View File
@@ -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<http.StreamedResponse> 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;
}
+6
View File
@@ -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',
);
}
@@ -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> {
T genericParams;
@@ -18,6 +20,14 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> 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();
+7 -1
View File
@@ -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<void> main() async {
// Everything runs inside the zone so every package:http client created by the
// app (Talk, avatars, image caches) goes through DemoHttpClient.
Future<void> main() => http.runWithClient(_main, DemoHttpClient.new);
Future<void> _main() async {
log('MarianumMobile started');
WidgetsFlutterBinding.ensureInitialized();
SessionManager().markUiEngine();
@@ -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.
+2 -1
View File
@@ -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<T extends StatefulWidget> on State<T> {
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,