diff --git a/mobile/lib/shared/hosted/bounded_http_response.dart b/mobile/lib/shared/hosted/bounded_http_response.dart index 5a7505baf1..98e93859dd 100644 --- a/mobile/lib/shared/hosted/bounded_http_response.dart +++ b/mobile/lib/shared/hosted/bounded_http_response.dart @@ -1,13 +1,17 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; +/// The body cap for hosted responses that do not declare their own. +const hostedResponseMaxBytes = 64 * 1024; + /// One deadline covers headers and the bounded body. Abort only this request, /// leaving the shared client usable for sign-out and subsequent attempts. Future boundedHttpResponse( http.Client client, http.Request source, { - int maxBytes = 65536, + int maxBytes = hostedResponseMaxBytes, }) async { final abort = Completer(); final request = @@ -27,15 +31,15 @@ Future boundedHttpResponse( await iterator.cancel(); throw http.RequestAbortedException(); } - final bytes = []; + final bytes = BytesBuilder(); while (await iterator.moveNext()) { if (bytes.length + iterator.current.length > maxBytes) { throw const FormatException('Response too large'); } - bytes.addAll(iterator.current); + bytes.add(iterator.current); } return http.Response.bytes( - bytes, + bytes.takeBytes(), response.statusCode, headers: response.headers, ); diff --git a/mobile/lib/shared/hosted/hosted_join_policy.dart b/mobile/lib/shared/hosted/hosted_join_policy.dart index 62dde22dfe..30a5795c20 100644 --- a/mobile/lib/shared/hosted/hosted_join_policy.dart +++ b/mobile/lib/shared/hosted/hosted_join_policy.dart @@ -5,6 +5,10 @@ import 'package:http/http.dart' as http; import 'builderlab_account.dart'; import 'bounded_http_response.dart'; +/// Each relay policy document is capped at 256 KiB before JSON encoding. Four +/// MiB covers two maximally escaped documents plus the envelope, as on Desktop. +const _joinPolicyResponseMaxBytes = 4 * 1024 * 1024; + /// Public policy text bound to the exact version the user accepts. class HostedJoinPolicy { const HostedJoinPolicy({ @@ -33,7 +37,7 @@ Future> readJoinResponse( http.Client client, http.Request request, { bool allowNotFound = false, - int maxBytes = 65536, + int maxBytes = hostedResponseMaxBytes, }) async { final http.Response response; final Map data; @@ -141,7 +145,7 @@ Future loadJoinPolicy({ client, request, allowNotFound: allowLegacyRelay, - maxBytes: 4 * 1024 * 1024, + maxBytes: _joinPolicyResponseMaxBytes, ); final raw = data['policy']; if (raw == null) return null; diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 2aa99ace74..384e69a009 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -25,6 +25,8 @@ void main() { final auth = _RecordingAuthNotifier(); final requests = []; var version = 'v1'; + // Relay-valid policy text can exceed the generic 64 KiB hosted cap. + final terms = 'x' * 65536; final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(storage), @@ -40,7 +42,7 @@ void main() { jsonEncode({ 'policy': { 'version': version, - 'terms_markdown': 'Terms', + 'terms_markdown': terms, 'age_attestation_required': true, }, }), @@ -78,6 +80,7 @@ void main() { ); await _joinPolicySettled(container); expect(container.read(inviteJoinProvider).joinPolicy?.version, 'v1'); + expect(container.read(inviteJoinProvider).joinPolicy?.terms, terms); await notifier.confirmJoin(); expect(requests.where((path) => path != '/api/join-policy'), isEmpty); version = 'v2'; diff --git a/mobile/test/shared/hosted/hosted_admission_test.dart b/mobile/test/shared/hosted/hosted_admission_test.dart index b4a6ec0cdd..2c01d01664 100644 --- a/mobile/test/shared/hosted/hosted_admission_test.dart +++ b/mobile/test/shared/hosted/hosted_admission_test.dart @@ -110,7 +110,7 @@ void main() { ('redirect', '{}', 302, AccountFailure.denied), ( 'oversized policy', - '{}${' ' * (4 * 1024 * 1024)}', + '{"policy":null}${' ' * (4 * 1024 * 1024)}', 200, AccountFailure.invalidResponse, ), @@ -143,6 +143,49 @@ void main() { }); } + test('only policy discovery admits relay-sized responses', () async { + await store.create(keys.nsec); + await backup(); + final terms = 'x' * 65536; + HostedJoinPolicy? confirmed; + admission.dispose(); + admission = HostedAdmission( + account: account, + relayAuth: HostedRelayAuth(account, enabled: false), + store: store, + probe: (_) async {}, + confirmPolicy: (policy) async { + confirmed = policy; + return true; + }, + client: MockClient((request) async { + if (request.url.path == '/api/join-policy') { + return http.Response( + jsonEncode({ + 'policy': { + 'version': 'v1', + 'age_attestation_required': false, + 'terms_markdown': terms, + }, + }), + 200, + ); + } + if (request.url.path == '/api/invites/accept-policy') { + return http.Response('{"receipt":"fresh"}${' ' * 65536}', 200); + } + claims.add(request); + return http.Response('{}', 200); + }), + ); + await expectLater( + admission.prepare(invite, 'owner', keys.public), + throwsA(AccountFailure.invalidResponse), + ); + expect(confirmed?.terms, terms); + expect(claims, isEmpty); + }); + testWidgets('total policy deadline cancels body and permits policy retry', ( tester, ) async {