diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs index 4f9ee189..55850b3b 100644 --- a/crates/sprout-relay/src/api/events.rs +++ b/crates/sprout-relay/src/api/events.rs @@ -65,6 +65,12 @@ pub async fn get_event( .await .map_err(|_| not_found("event not found"))?; } else { + // Channel-restricted tokens must not access global events — they are + // scoped to specific channels and global events fall outside that scope. + if ctx.channel_ids.is_some() { + return Err(not_found("event not found")); + } + // Global event — scope-aware allowlist. let event_kind = event_kind_u32(&stored_event.event); diff --git a/crates/sprout-relay/src/api/workflows.rs b/crates/sprout-relay/src/api/workflows.rs index 132be08f..fb0ed5dc 100644 --- a/crates/sprout-relay/src/api/workflows.rs +++ b/crates/sprout-relay/src/api/workflows.rs @@ -223,9 +223,8 @@ pub async fn get_workflow( if let Some(channel_id) = workflow.channel_id { check_token_channel_access(&ctx, &channel_id)?; check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } else if workflow.owner_pubkey != pubkey_bytes { - return Err(forbidden("not authorized to access this workflow")); } + require_workflow_owner(&workflow, &pubkey_bytes, "view")?; Ok(Json(workflow_record_to_json(&workflow))) } @@ -399,7 +398,7 @@ pub async fn list_workflow_runs( check_token_channel_access(&ctx, &channel_id)?; check_channel_access(&state, channel_id, &pubkey_bytes).await?; } - require_workflow_owner(&workflow, &pubkey_bytes, "trigger")?; + require_workflow_owner(&workflow, &pubkey_bytes, "view runs for")?; let limit = params.limit.unwrap_or(20).min(100) as i64; let runs = state @@ -439,9 +438,8 @@ pub async fn list_run_approvals( if let Some(channel_id) = workflow.channel_id { check_token_channel_access(&ctx, &channel_id)?; check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } else if workflow.owner_pubkey != pubkey_bytes { - return Err(forbidden("not authorized to access this workflow")); } + require_workflow_owner(&workflow, &pubkey_bytes, "view approvals for")?; let approvals = state .db @@ -478,9 +476,8 @@ pub async fn trigger_workflow( if let Some(channel_id) = workflow.channel_id { check_token_channel_access(&ctx, &channel_id)?; check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } else if workflow.owner_pubkey != pubkey_bytes { - return Err(forbidden("not authorized to access this workflow")); } + require_workflow_owner(&workflow, &pubkey_bytes, "trigger")?; let trigger_ctx = sprout_workflow::executor::TriggerContext { channel_id: workflow diff --git a/crates/sprout-relay/src/handlers/req.rs b/crates/sprout-relay/src/handlers/req.rs index c9c12e25..c459b717 100644 --- a/crates/sprout-relay/src/handlers/req.rs +++ b/crates/sprout-relay/src/handlers/req.rs @@ -142,6 +142,16 @@ pub async fn handle_req( } } + // Channel-restricted tokens must not open global subscriptions — they would + // receive events outside the token's channel allowlist. + if channel_id.is_none() && token_channel_ids.is_some() { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: channel-scoped tokens cannot open global subscriptions", + )); + return; + } + // Check channel access BEFORE registering the subscription. if let Some(ch_id) = channel_id { if !accessible_channels.contains(&ch_id) { diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 94fd1770..f8faceab 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -8,10 +8,10 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; -import '../profile/presence_cache_provider.dart'; import '../profile/profile_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'dm_presence_avatar.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; @@ -1842,37 +1842,8 @@ class _DmAppBarTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final profiles = ref.watch(userCacheProvider); - final presenceMap = ref.watch(presenceCacheProvider); - final normalizedCurrent = currentPubkey?.toLowerCase(); - - String? otherPubkey; - for (final pk in channel.participantPubkeys) { - if (pk.toLowerCase() != normalizedCurrent) { - otherPubkey = pk.toLowerCase(); - break; - } - } - - final profile = otherPubkey != null ? profiles[otherPubkey] : null; - - if (otherPubkey != null) { - if (profile == null) { - ref.read(userCacheProvider.notifier).preload([otherPubkey]); - } - ref.read(presenceCacheProvider.notifier).track([otherPubkey]); - } - - final avatarUrl = profile?.avatarUrl; - final initial = - profile?.initial ?? - (channel.participants.isNotEmpty - ? channel.participants.first[0].toUpperCase() - : '?'); - final presence = otherPubkey != null - ? (presenceMap[otherPubkey] ?? 'offline') - : 'offline'; - final presenceLabel = switch (presence) { + final data = resolveDmPresence(ref, channel, currentPubkey); + final presenceLabel = switch (data.presence) { 'online' => 'Online', 'away' => 'Away', _ => 'Offline', @@ -1880,52 +1851,11 @@ class _DmAppBarTitle extends ConsumerWidget { return Row( children: [ - SizedBox( - width: 30, - height: 30, - child: Stack( - clipBehavior: Clip.none, - children: [ - CircleAvatar( - radius: 14, - backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null - ? NetworkImage(avatarUrl) - : null, - child: avatarUrl == null - ? Text( - initial, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ) - : null, - ), - Positioned( - right: -1, - bottom: -1, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: switch (presence) { - 'online' => context.appColors.success, - 'away' => context.appColors.warning, - _ => context.colors.outline, - }, - shape: BoxShape.circle, - border: Border.all( - color: - context.theme.appBarTheme.backgroundColor ?? - context.theme.scaffoldBackgroundColor, - width: 1.5, - ), - ), - ), - ), - ], - ), + DmPresenceAvatar( + channel: channel, + currentPubkey: currentPubkey, + size: 30, + dotSize: 10, ), const SizedBox(width: Grid.xxs), Expanded( diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 4474943f..a65858d8 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -11,12 +11,11 @@ import '../../shared/theme/theme.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; import '../settings/settings_page.dart'; -import '../profile/presence_cache_provider.dart'; -import '../profile/user_cache_provider.dart'; import 'channel.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; import 'channels_provider.dart'; +import 'dm_presence_avatar.dart'; enum _QuickAction { createChannel, createForum, newDm } @@ -424,7 +423,7 @@ class _ChannelTile extends ConsumerWidget { child: Row( children: [ if (channel.isDm) - _DmAvatar(channel: channel, currentPubkey: currentPubkey) + DmPresenceAvatar(channel: channel, currentPubkey: currentPubkey) else Icon( channelIcon(channel), @@ -483,98 +482,6 @@ class _ChannelTile extends ConsumerWidget { } } -class _DmAvatar extends ConsumerWidget { - final Channel channel; - final String? currentPubkey; - - const _DmAvatar({required this.channel, required this.currentPubkey}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final profiles = ref.watch(userCacheProvider); - final presenceMap = ref.watch(presenceCacheProvider); - final normalizedCurrent = currentPubkey?.toLowerCase(); - - // Find the other participant's pubkey. - String? otherPubkey; - for (final pk in channel.participantPubkeys) { - if (pk.toLowerCase() != normalizedCurrent) { - otherPubkey = pk.toLowerCase(); - break; - } - } - - final profile = otherPubkey != null ? profiles[otherPubkey] : null; - - // Trigger fetches if not cached yet. - if (otherPubkey != null) { - if (profile == null) { - ref.read(userCacheProvider.notifier).preload([otherPubkey]); - } - ref.read(presenceCacheProvider.notifier).track([otherPubkey]); - } - - final avatarUrl = profile?.avatarUrl; - final initial = - profile?.initial ?? - (channel.participants.isNotEmpty - ? channel.participants.first[0].toUpperCase() - : '?'); - final presence = otherPubkey != null - ? (presenceMap[otherPubkey] ?? 'offline') - : 'offline'; - - return SizedBox( - width: 22, - height: 22, - child: Stack( - clipBehavior: Clip.none, - children: [ - CircleAvatar( - radius: 10, - backgroundColor: context.colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, - child: avatarUrl == null - ? Text( - initial, - style: context.textTheme.labelSmall?.copyWith( - fontSize: 9, - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ) - : null, - ), - Positioned( - right: -1, - bottom: -1, - child: Container( - width: 9, - height: 9, - decoration: BoxDecoration( - color: _presenceColor(context, presence), - shape: BoxShape.circle, - border: Border.all( - color: context.theme.scaffoldBackgroundColor, - width: 1.5, - ), - ), - ), - ), - ], - ), - ); - } - - Color _presenceColor(BuildContext context, String presence) { - return switch (presence) { - 'online' => context.appColors.success, - 'away' => context.appColors.warning, - _ => context.colors.outline, - }; - } -} - class _QuickActionsSheet extends StatelessWidget { const _QuickActionsSheet(); diff --git a/mobile/lib/features/channels/dm_presence_avatar.dart b/mobile/lib/features/channels/dm_presence_avatar.dart new file mode 100644 index 00000000..e44430be --- /dev/null +++ b/mobile/lib/features/channels/dm_presence_avatar.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; +import '../profile/presence_cache_provider.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'channel.dart'; + +/// Resolved DM presence data for the other participant in a DM channel. +class DmPresenceData { + final UserProfile? profile; + final String presence; + final String initial; + final String? avatarUrl; + + const DmPresenceData({ + this.profile, + required this.presence, + required this.initial, + this.avatarUrl, + }); +} + +/// Resolve the other participant's presence data from a DM channel. +/// +/// Triggers lazy fetches for profile and presence if not cached. +DmPresenceData resolveDmPresence( + WidgetRef ref, + Channel channel, + String? currentPubkey, +) { + final profiles = ref.watch(userCacheProvider); + final presenceMap = ref.watch(presenceCacheProvider); + final normalizedCurrent = currentPubkey?.toLowerCase(); + + String? otherPubkey; + for (final pk in channel.participantPubkeys) { + if (pk.toLowerCase() != normalizedCurrent) { + otherPubkey = pk.toLowerCase(); + break; + } + } + + final profile = otherPubkey != null ? profiles[otherPubkey] : null; + + if (otherPubkey != null) { + if (profile == null) { + ref.read(userCacheProvider.notifier).preload([otherPubkey]); + } + ref.read(presenceCacheProvider.notifier).track([otherPubkey]); + } + + final avatarUrl = profile?.avatarUrl; + final initial = + profile?.initial ?? + (channel.participants.isNotEmpty + ? channel.participants.first[0].toUpperCase() + : '?'); + final presence = otherPubkey != null + ? (presenceMap[otherPubkey] ?? 'offline') + : 'offline'; + + return DmPresenceData( + profile: profile, + presence: presence, + initial: initial, + avatarUrl: avatarUrl, + ); +} + +/// Presence indicator dot color. +Color presenceColor(BuildContext context, String presence) { + return switch (presence) { + 'online' => context.appColors.success, + 'away' => context.appColors.warning, + _ => context.colors.outline, + }; +} + +/// Compact DM avatar with presence dot, for use in channel lists. +class DmPresenceAvatar extends ConsumerWidget { + final Channel channel; + final String? currentPubkey; + final double size; + final double dotSize; + + const DmPresenceAvatar({ + super.key, + required this.channel, + required this.currentPubkey, + this.size = 22, + this.dotSize = 9, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final data = resolveDmPresence(ref, channel, currentPubkey); + final radius = size / 2 - 1; + + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + CircleAvatar( + radius: radius, + backgroundColor: context.colors.primaryContainer, + backgroundImage: data.avatarUrl != null + ? NetworkImage(data.avatarUrl!) + : null, + child: data.avatarUrl == null + ? Text( + data.initial, + style: context.textTheme.labelSmall?.copyWith( + fontSize: radius * 0.9, + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ) + : null, + ), + Positioned( + right: -1, + bottom: -1, + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: presenceColor(context, data.presence), + shape: BoxShape.circle, + border: Border.all( + color: context.theme.scaffoldBackgroundColor, + width: 1.5, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 9707ba14..72a55542 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io' show InternetAddress, InternetAddressType; import 'dart:math' as math; import 'package:flutter/foundation.dart'; @@ -596,34 +597,77 @@ class PairingNotifier extends Notifier { } final host = uri.host.toLowerCase(); - if (host == 'localhost' || host == '127.0.0.1' || host == '::1') { + + // Localhost check: allow in debug, block in production. + // DNS rebinding is mitigated by the HTTPS requirement in production — + // the TLS certificate pins the hostname to the server, so a rebind to a + // private address will fail the certificate check before any data is sent. + if (host == 'localhost') { if (!kDebugMode) { throw const FormatException('Relay URL cannot target localhost'); } return; } - final ip = Uri.tryParse('http://$host')?.host ?? host; - if (_isPrivateHost(ip)) { + if (_isPrivateHost(host)) { throw const FormatException( 'Relay URL cannot target private network addresses', ); } } + /// Returns true if [host] resolves to a private, loopback, link-local, + /// or unspecified address that must not be reachable from a relay URL. + /// + /// Uses [InternetAddress.tryParse] to normalise all IP representations + /// (dotted-quad, decimal, octal, hex, IPv6, IPv4-mapped IPv6) before + /// checking ranges, so alternative encodings cannot bypass the filter. static bool _isPrivateHost(String host) { - final parts = host.split('.'); - if (parts.length != 4) return false; - final octets = parts.map(int.tryParse).toList(); - if (octets.any((o) => o == null)) return false; - - final a = octets[0]!; - final b = octets[1]!; - - if (a == 10) return true; - if (a == 172 && b >= 16 && b <= 31) return true; - if (a == 192 && b == 168) return true; - if (a == 169 && b == 254) return true; + // Strip IPv6 brackets if present (e.g. "[::1]" → "::1"). + final bare = host.startsWith('[') && host.endsWith(']') + ? host.substring(1, host.length - 1) + : host; + + final addr = InternetAddress.tryParse(bare); + if (addr == null) { + // Not a bare IP literal — it's a hostname; let DNS + TLS handle it. + return false; + } + + if (addr.type == InternetAddressType.IPv4) { + return _isPrivateIPv4(addr.rawAddress); + } + + // IPv6 + final raw = addr.rawAddress; // 16 bytes, big-endian + // Unspecified: :: + if (raw.every((b) => b == 0)) return true; + // Loopback: ::1 + if (addr.isLoopback) return true; + // Link-local: fe80::/10 — first 10 bits are 1111 1110 10 + if (raw[0] == 0xfe && (raw[1] & 0xc0) == 0x80) return true; + // Unique local: fc00::/7 — first 7 bits are 1111 110 + if ((raw[0] & 0xfe) == 0xfc) return true; + // IPv4-mapped: ::ffff:x.x.x.x — bytes 0-9 are 0, bytes 10-11 are 0xff + if (raw[10] == 0xff && + raw[11] == 0xff && + raw.sublist(0, 10).every((b) => b == 0)) { + return _isPrivateIPv4(raw.sublist(12)); + } + + return false; + } + + /// Checks whether a 4-byte big-endian IPv4 address is private/reserved. + static bool _isPrivateIPv4(List raw) { + final a = raw[0]; + final b = raw[1]; + if (a == 0) return true; // 0.0.0.0/8 unspecified + if (a == 10) return true; // 10.0.0.0/8 + if (a == 127) return true; // 127.0.0.0/8 loopback + if (a == 169 && b == 254) return true; // 169.254.0.0/16 link-local + if (a == 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a == 192 && b == 168) return true; // 192.168.0.0/16 return false; } } diff --git a/mobile/lib/features/profile/presence_cache_provider.dart b/mobile/lib/features/profile/presence_cache_provider.dart index fcdd4500..bc436727 100644 --- a/mobile/lib/features/profile/presence_cache_provider.dart +++ b/mobile/lib/features/profile/presence_cache_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -6,17 +7,29 @@ import '../../shared/relay/relay.dart'; /// In-memory cache of other users' presence, fetched in batches. /// Periodically refreshes to keep presence status up to date. +/// +/// Tracked set is capped at [_maxTracked] entries; oldest entries are evicted +/// when the cap is exceeded. Fetch failures trigger exponential backoff up to +/// [_maxBackoff]. class PresenceCacheNotifier extends Notifier> { static const _refreshInterval = Duration(seconds: 30); + static const _maxBackoff = Duration(minutes: 5); + static const _maxTracked = 200; - final Set _tracked = {}; + // List-backed so insertion order is preserved for eviction. + final List _tracked = []; + final Set _trackedSet = {}; final Set _pending = {}; Timer? _batchTimer; Timer? _refreshTimer; + int _consecutiveFailures = 0; @override Map build() { ref.watch(relayClientProvider); + _tracked.clear(); + _trackedSet.clear(); + _consecutiveFailures = 0; ref.onDispose(() { _batchTimer?.cancel(); _batchTimer = null; @@ -34,7 +47,19 @@ class PresenceCacheNotifier extends Notifier> { .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) .toList(); - _tracked.addAll(normalized); + for (final pk in normalized) { + if (!_trackedSet.contains(pk)) { + _tracked.add(pk); + _trackedSet.add(pk); + } + } + + // Evict oldest entries if over cap. + while (_tracked.length > _maxTracked) { + final evicted = _tracked.removeAt(0); + _trackedSet.remove(evicted); + } + _ensureRefreshTimer(); if (uncached.isEmpty) return; @@ -42,8 +67,41 @@ class PresenceCacheNotifier extends Notifier> { _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); } + /// Stop tracking presence for [pubkeys]. Cancels the refresh timer if + /// [_tracked] becomes empty. + void untrack(List pubkeys) { + final normalized = pubkeys.map((pk) => pk.toLowerCase()).toList(); + for (final pk in normalized) { + if (_trackedSet.remove(pk)) { + _tracked.remove(pk); + } + } + if (_tracked.isEmpty) { + _refreshTimer?.cancel(); + _refreshTimer = null; + } + } + void _ensureRefreshTimer() { - _refreshTimer ??= Timer.periodic(_refreshInterval, (_) => _refreshAll()); + if (_refreshTimer != null) return; + _scheduleRefresh(); + } + + void _scheduleRefresh() { + _refreshTimer?.cancel(); + final delay = _consecutiveFailures == 0 + ? _refreshInterval + : _clampedBackoff(_consecutiveFailures); + _refreshTimer = Timer(delay, () async { + _refreshTimer = null; + await _refreshAll(); + if (_tracked.isNotEmpty) _scheduleRefresh(); + }); + } + + Duration _clampedBackoff(int failures) { + final seconds = _refreshInterval.inSeconds * pow(2, failures - 1); + return Duration(seconds: min(seconds.toInt(), _maxBackoff.inSeconds)); } Future _refreshAll() async { @@ -75,8 +133,10 @@ class PresenceCacheNotifier extends Notifier> { updated[pk] = (json[pk] as String?) ?? 'offline'; } state = updated; + _consecutiveFailures = 0; } catch (_) { // Silently fail — default to offline. + _consecutiveFailures++; } } }