Files
buzz/mobile/lib/shared/hosted/bounded_http_response.dart
Matt Toohey fa9acdf7d9 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>
2026-11-04 14:56:40 +11:00

56 lines
1.6 KiB
Dart

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 = hostedResponseMaxBytes,
}) async {
final abort = Completer<void>();
final request =
http.AbortableRequest(
source.method,
source.url,
abortTrigger: abort.future,
)
..followRedirects = false
..headers.addAll(source.headers)
..bodyBytes = source.bodyBytes;
StreamIterator<List<int>>? body;
Future<http.Response> read() async {
final response = await client.send(request);
final iterator = body = StreamIterator(response.stream);
if (abort.isCompleted) {
await iterator.cancel();
throw http.RequestAbortedException();
}
final bytes = BytesBuilder();
while (await iterator.moveNext()) {
if (bytes.length + iterator.current.length > maxBytes) {
throw const FormatException('Response too large');
}
bytes.add(iterator.current);
}
return http.Response.bytes(
bytes.takeBytes(),
response.statusCode,
headers: response.headers,
);
}
try {
return await read().timeout(const Duration(seconds: 30));
} finally {
abort.complete();
// Do not let transport cleanup hold the account's serial queue hostage.
unawaited(body?.cancel());
}
}