From 5c9122c6f1824f12421e4e87f96ef4ca466f7ce2 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 15 Oct 2026 15:07:32 -0700 Subject: [PATCH] fix(mobile): route staging communities through hosted setup Keep community domains bound to their trusted account environment and reject mismatched realms before setup or admission. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- mobile/lib/hosted_app_gate.dart | 17 +-- .../lib/shared/hosted/hosted_admission.dart | 3 +- .../lib/shared/hosted/hosted_environment.dart | 34 +++++ mobile/lib/shared/hosted/hosted_flow.dart | 16 +++ .../hosted/hosted_flow_problem_message.dart | 2 + .../hosted_app_gate_environment_test.dart | 91 ++++++++++++++ .../hosted/hosted_environment_flow_test.dart | 117 ++++++++++++++++++ .../hosted/hosted_environment_test.dart | 51 ++++++++ 8 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 mobile/test/features/onboarding/hosted_app_gate_environment_test.dart create mode 100644 mobile/test/shared/hosted/hosted_environment_flow_test.dart create mode 100644 mobile/test/shared/hosted/hosted_environment_test.dart diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index af621795d2..410264acfc 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -14,6 +14,7 @@ import 'shared/hosted/builderlab_account.dart'; import 'shared/hosted/hosted_admission.dart'; import 'shared/hosted/hosted_flow.dart'; import 'shared/hosted/hosted_identity_service.dart'; +import 'shared/hosted/hosted_environment.dart'; // Set by a community-entry surface while it owns setup presentation. final hostedInlineEntryProvider = NotifierProvider( @@ -134,11 +135,7 @@ class HostedAppGate extends HookConsumerWidget { return true; } final host = Uri.parse(relay).host.toLowerCase(); - final reserved = - host == 'buzz.xyz' || - host.endsWith('.buzz.xyz') || - host == 'builderlab.xyz' || - host.endsWith('.builderlab.xyz'); + final reserved = HostedEnvironment.isReservedHost(host); if (!reserved && link is AddCommunityDeepLink) { if (routeContext.mounted) { await showDialog( @@ -161,14 +158,14 @@ class HostedAppGate extends HookConsumerWidget { } return true; } - if (reserved && !host.endsWith('.communities.buzz.xyz')) { + if (reserved && !HostedEnvironment.supportsRelay(Uri.parse(relay))) { if (routeContext.mounted) { await showDialog( context: routeContext, builder: (context) => AlertDialog( title: const Text('Community setup unavailable'), content: const Text( - 'This community does not support account setup in this build yet.', + 'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.', ), actions: [ TextButton( @@ -185,11 +182,7 @@ class HostedAppGate extends HookConsumerWidget { if (link is InviteDeepLink) { final host = Uri.parse(link.relayUrl).host; // Reserved hosted domains must never fall into legacy key generation. - final reserved = - host == 'buzz.xyz' || - host.endsWith('.buzz.xyz') || - host == 'builderlab.xyz' || - host.endsWith('.builderlab.xyz'); + final reserved = HostedEnvironment.isReservedHost(host); if (!reserved) return false; } await flow.start(link); diff --git a/mobile/lib/shared/hosted/hosted_admission.dart b/mobile/lib/shared/hosted/hosted_admission.dart index a9ea13cff1..8fd6452e3b 100644 --- a/mobile/lib/shared/hosted/hosted_admission.dart +++ b/mobile/lib/shared/hosted/hosted_admission.dart @@ -8,6 +8,7 @@ import '../relay/relay_session.dart'; import '../relay/relay_socket.dart'; import 'account_identity_store.dart'; import 'builderlab_account.dart'; +import 'hosted_environment.dart'; /// Builds a community only after scoped backup evidence and current binding. /// The caller persists it through the existing community transition owner. @@ -49,7 +50,7 @@ class HostedAdmission { uri.hasQuery || uri.hasFragment || (uri.path.isNotEmpty && uri.path != '/') || - !uri.host.endsWith('.communities.buzz.xyz') || + !HostedEnvironment.supportsRelay(uri) || (uri.hasPort && uri.port != 443)) { throw AccountFailure.denied; } diff --git a/mobile/lib/shared/hosted/hosted_environment.dart b/mobile/lib/shared/hosted/hosted_environment.dart index 2c8e16f963..6395f9760f 100644 --- a/mobile/lib/shared/hosted/hosted_environment.dart +++ b/mobile/lib/shared/hosted/hosted_environment.dart @@ -2,6 +2,40 @@ abstract final class HostedEnvironment { static final Uri origin = _origin(); + /// Reserved domains cannot fall back to Nostr-only onboarding, even when + /// this build does not support their account environment. + static bool isReservedHost(String host) { + final normalized = host.toLowerCase(); + return [ + 'buzz.xyz', + 'builderlab.xyz', + 'builderlabstaging.xyz', + ].any((domain) => normalized == domain || normalized.endsWith('.$domain')); + } + + /// Explicit community-to-account mapping. Never infer an account endpoint + /// from arbitrary relay metadata or send this build's session across realms. + static Uri? accountOriginFor(Uri relay) { + if (!['wss', 'https'].contains(relay.scheme) || + relay.userInfo.isNotEmpty || + relay.hasQuery || + relay.hasFragment || + (relay.path.isNotEmpty && relay.path != '/') || + (relay.hasPort && relay.port != 443)) { + return null; + } + final host = relay.host.toLowerCase(); + if (host.endsWith('.communities.buzz.xyz')) { + return Uri.parse('https://app.builderlab.xyz'); + } + if (host.endsWith('.communities.public.builderlabstaging.xyz')) { + return Uri.parse('https://app.builderlabstaging.xyz'); + } + return null; + } + + static bool supportsRelay(Uri relay) => accountOriginFor(relay) == origin; + static Uri _origin() { const configured = String.fromEnvironment( 'BUZZ_BUILDERLAB_URL', diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 26eebabb48..89134edf82 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -34,6 +34,7 @@ enum HostedFlowProblem { identityUnavailable, backupRequired, bindingRequired, + unsupportedEnvironment, } /// Resolves only after relay admission is proven and local persistence completes. @@ -195,6 +196,7 @@ class HostedFlow extends ChangeNotifier { _set(HostedFlowStage.working); return _enqueue(() async { if (generation != _generation) return; + if (!_acceptTarget(target)) return; final restored = await account.restore(); if (generation != _generation) return; if (restored == null) { @@ -224,6 +226,7 @@ class HostedFlow extends ChangeNotifier { final target = _target; final session = account.account; if (target == null || session == null) throw AccountFailure.signedOut; + if (!_acceptTarget(target)) return; var resolved = await identity.resolve(); if (target is! NostrBindDeepLink) { for (var step = 0; step < 2; step++) { @@ -321,6 +324,19 @@ class HostedFlow extends ChangeNotifier { _set(HostedFlowStage.complete); } + bool _acceptTarget(BuzzDeepLink target) { + final relay = switch (target) { + InviteDeepLink(:final relayUrl) => relayUrl, + AddCommunityDeepLink(:final relayUrl) => relayUrl, + _ => null, + }; + if (relay != null && !HostedEnvironment.supportsRelay(Uri.parse(relay))) { + _set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedEnvironment); + return false; + } + return true; + } + Future _verifyBinding({ required String subject, required String challengeId, diff --git a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart index 7d4e756184..01569941e0 100644 --- a/mobile/lib/shared/hosted/hosted_flow_problem_message.dart +++ b/mobile/lib/shared/hosted/hosted_flow_problem_message.dart @@ -3,6 +3,8 @@ import 'hosted_flow.dart'; String hostedFlowProblemMessage( HostedFlowProblem? problem, ) => switch (problem) { + HostedFlowProblem.unsupportedEnvironment => + 'This community needs a different or unsupported Builderlab environment. Use an app build configured for this community.', HostedFlowProblem.denied => 'This account does not have access.', HostedFlowProblem.differentAccount => 'Your browser signed in to a different account. Sign in to your original account on the web, then reopen setup. Your saved account and keys have been kept.', diff --git a/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart b/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart new file mode 100644 index 0000000000..c635c7ee41 --- /dev/null +++ b/mobile/test/features/onboarding/hosted_app_gate_environment_test.dart @@ -0,0 +1,91 @@ +import 'package:buzz/features/channels/deep_link_dispatcher.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _Flow extends HostedFlow { + _Flow(BuilderlabAccountClient account, AccountIdentityStore store) + : super( + account: account, + store: store, + browser: BrowserAuth(), + identity: HostedIdentityService(account: account, store: store), + enterCommunity: (_, _, _, _) async {}, + ); + BuzzDeepLink? started; + @override + Future initialize() async {} + @override + Future start(BuzzDeepLink target) async { + started = target; + } +} + +void main() { + testWidgets('app dispatch keeps staging invites out of legacy generation', ( + tester, + ) async { + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + final account = BuilderlabAccountClient(); + final flow = _Flow(account, AccountIdentityStore()); + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + flow.dispose(); + await flow.browser.dispose(); + account.dispose(); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [hostedFlowProvider.overrideWithValue(flow)], + child: const MaterialApp( + home: HostedAppGate( + authenticated: false, + child: Scaffold(body: Text('entry')), + ), + ), + ), + ); + await tester.pump(); + final dispatcher = tester.widget( + find.byType(DeepLinkDispatcher), + ); + final context = tester.element(find.byType(DeepLinkDispatcher)); + const staging = InviteDeepLink( + relayUrl: 'wss://team.communities.public.builderlabstaging.xyz', + code: 'fixture', + ); + final pending = dispatcher.onboardingHandler!(context, staging); + await tester.pumpAndSettle(); + if (HostedEnvironment.origin.host == 'app.builderlabstaging.xyz') { + expect(await pending, isTrue); + expect(flow.started, staging); + } else { + expect(find.text('Community setup unavailable'), findsOneWidget); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(await pending, isTrue); + expect(flow.started, isNull); + } + expect( + await dispatcher.onboardingHandler!( + context, + const InviteDeepLink( + relayUrl: 'wss://community.example.org', + code: 'fixture', + ), + ), + isFalse, + ); + }); +} diff --git a/mobile/test/shared/hosted/hosted_environment_flow_test.dart b/mobile/test/shared/hosted/hosted_environment_flow_test.dart new file mode 100644 index 0000000000..65266a1dec --- /dev/null +++ b/mobile/test/shared/hosted/hosted_environment_flow_test.dart @@ -0,0 +1,117 @@ +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_admission.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class _Account extends BuilderlabAccountClient { + _Account(this.pubkey); + final String pubkey; + var reads = 0; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'owner', expiresAt: DateTime.utc(2100)); + @override + Future restore() async { + reads++; + return account; + } + + @override + Future> currentIdentity() async { + reads++; + return { + 'identity': {'pubkey_hex': pubkey}, + }; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + test( + 'real flow and admission use only the configured community realm', + () async { + final staging = + HostedEnvironment.origin.host == 'app.builderlabstaging.xyz'; + final matching = staging + ? 'team.communities.public.builderlabstaging.xyz' + : 'team.communities.buzz.xyz'; + final opposite = staging + ? 'team.communities.buzz.xyz' + : 'team.communities.public.builderlabstaging.xyz'; + final keys = nostr.Keys('1'.padLeft(64, '0')); + final account = _Account(keys.public); + final store = AccountIdentityStore(); + await store.create(keys.nsec); + await store.confirmBackup( + BackupReceipt( + backend: HostedEnvironment.origin.origin, + subject: 'owner', + pubkey: keys.public, + backupId: 'receipt', + ), + ); + final claims = []; + final admission = HostedAdmission( + account: account, + store: store, + probe: (_) async {}, + client: MockClient((request) async { + claims.add(request.url); + return http.Response('{}', 200); + }), + ); + final browser = BrowserAuth( + openBrowser: (_) async => fail('Saved account must not open browser'), + ); + var entered = 0; + final flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService(account: account, store: store), + enterCommunity: (link, subject, pubkey, isCurrent) async { + await admission.prepare(link, subject, pubkey, isCurrent: isCurrent); + entered++; + }, + ); + addTearDown(() async { + flow.dispose(); + await browser.dispose(); + account.dispose(); + admission.dispose(); + }); + await flow.start( + InviteDeepLink(relayUrl: 'wss://$opposite', code: 'test'), + ); + expect(flow.problem, HostedFlowProblem.unsupportedEnvironment); + expect(account.reads, 0); + expect(claims, isEmpty); + // Admission independently enforces the boundary, even without the UI/flow. + await expectLater( + admission.prepare( + InviteDeepLink(relayUrl: 'wss://$opposite', code: 'test'), + 'owner', + keys.public, + ), + throwsA(AccountFailure.denied), + ); + expect(account.reads, 0); + await flow.start( + InviteDeepLink(relayUrl: 'wss://$matching', code: 'test'), + ); + expect(flow.stage, HostedFlowStage.complete); + expect(entered, 1); + expect(claims.single.host, matching); + }, + ); +} diff --git a/mobile/test/shared/hosted/hosted_environment_test.dart b/mobile/test/shared/hosted/hosted_environment_test.dart new file mode 100644 index 0000000000..f5d2539d47 --- /dev/null +++ b/mobile/test/shared/hosted/hosted_environment_test.dart @@ -0,0 +1,51 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('community realms map only to their trusted account origin', () { + expect( + HostedEnvironment.accountOriginFor( + Uri.parse('wss://team.communities.buzz.xyz'), + ), + Uri.parse('https://app.builderlab.xyz'), + ); + expect( + HostedEnvironment.accountOriginFor( + Uri.parse('wss://team.communities.public.builderlabstaging.xyz'), + ), + Uri.parse('https://app.builderlabstaging.xyz'), + ); + for (final value in [ + 'wss://team.communities.public.builderlabstaging.xyz.attacker.test', + 'wss://communities.public.builderlabstaging.xyz', + 'wss://team.communities.public.builderlabstaging.xyz.', + 'wss://team.communities.public.builderlabstaging.xyz:8443', + 'ws://team.communities.public.builderlabstaging.xyz', + 'wss://user@team.communities.public.builderlabstaging.xyz', + 'wss://team.communities.public.builderlabstaging.xyz/path', + 'wss://team.communities.public.builderlabstaging.xyz?account=other', + 'wss://team.communities.public.builderlabstaging.xyz#fragment', + 'wss://team.builderlabstaging.xyz', + ]) { + expect( + HostedEnvironment.accountOriginFor(Uri.parse(value)), + isNull, + reason: value, + ); + } + }); + + test('reserved unsupported hosts never become self-hosted', () { + for (final host in [ + 'buzz.xyz', + 'app.builderlab.xyz', + 'TEAM.builderlabstaging.xyz', + ]) { + expect(HostedEnvironment.isReservedHost(host), isTrue); + } + expect( + HostedEnvironment.isReservedHost('builderlabstaging.xyz.attacker.test'), + isFalse, + ); + }); +}