From fa9acdf7d94236cd7b052a690def1b0ebaa7fdad Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 19:31:19 +1100 Subject: [PATCH] fix(mobile): allow relay-sized join policy responses Mobile read GET /api/join-policy through the generic 64 KiB hosted response cap, but the relay allows each policy markdown document up to 256 KiB and Desktop accepts a 4 MiB policy response. A self-hosted relay with a valid policy over 64 KiB was joinable on Desktop but failed on Mobile with an invalid-response error before consent could render, and every retry hit the same cap. boundedHttpResponse and readJoinResponse now take a maxBytes that defaults to the existing 64 KiB hostedResponseMaxBytes. Only loadJoinPolicy opts into the 4 MiB cap that matches Desktop, so the accept-policy, claim, and other hosted replies keep their limit. The reader accumulates into a BytesBuilder so a multi-MiB body is not held as a list of ints. The invite consent journey now serves a policy over 64 KiB and asserts that Mobile loads its full terms and claims only after native acceptance. The hosted-admission "oversized policy" row used bare whitespace, which failed as invalid JSON whatever the cap was. It now sends valid JSON padded past 4 MiB. A focused test serves a relay-sized policy alongside an accept-policy reply over 64 KiB: the policy reaches consent, the reply is rejected, and no claim is sent. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../shared/hosted/bounded_http_response.dart | 12 +++-- .../lib/shared/hosted/hosted_join_policy.dart | 8 +++- .../invites/invite_join_provider_test.dart | 5 ++- .../shared/hosted/hosted_admission_test.dart | 45 ++++++++++++++++++- 4 files changed, 62 insertions(+), 8 deletions(-) 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 {