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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-10-29 19:31:19 +11:00
parent 4ec7b8f0eb
commit fa9acdf7d9
4 changed files with 62 additions and 8 deletions

View File

@@ -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<http.Response> boundedHttpResponse(
http.Client client,
http.Request source, {
int maxBytes = 65536,
int maxBytes = hostedResponseMaxBytes,
}) async {
final abort = Completer<void>();
final request =
@@ -27,15 +31,15 @@ Future<http.Response> boundedHttpResponse(
await iterator.cancel();
throw http.RequestAbortedException();
}
final bytes = <int>[];
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,
);

View File

@@ -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<Map<String, dynamic>> readJoinResponse(
http.Client client,
http.Request request, {
bool allowNotFound = false,
int maxBytes = 65536,
int maxBytes = hostedResponseMaxBytes,
}) async {
final http.Response response;
final Map<String, dynamic> data;
@@ -141,7 +145,7 @@ Future<HostedJoinPolicy?> loadJoinPolicy({
client,
request,
allowNotFound: allowLegacyRelay,
maxBytes: 4 * 1024 * 1024,
maxBytes: _joinPolicyResponseMaxBytes,
);
final raw = data['policy'];
if (raw == null) return null;

View File

@@ -25,6 +25,8 @@ void main() {
final auth = _RecordingAuthNotifier();
final requests = <String>[];
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';

View File

@@ -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 {