mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user