From e2301aa4ece3374f02833d0a68f43d4496590eb0 Mon Sep 17 00:00:00 2001 From: foxyz Date: Wed, 7 Oct 2026 00:30:12 +0300 Subject: [PATCH] Server hardening: finite numbers, open servers, unkickable host, no URL leaks - Reject non-finite numerics at protocol parse and in the engine (a 1e400 seek became Infinity and broke every broadcast). - Reject empty swaps; sanitize URL-looking media names. - Blank password = open server; defaults no longer 'change-me'. - Loopback peers (the in-process host) can't be kicked. - /health no longer exposes media/transport; file URLs use the host the client dialled instead of 127.0.0.1. - Count served bytes as streamed, not as requested. Co-Authored-By: Claude Opus 5.5 --- packages/core/lib/src/config.dart | 10 +- packages/core/lib/src/protocol.dart | 47 +++-- packages/core/lib/src/sync_engine.dart | 10 +- packages/core/test/core_test.dart | 25 ++- packages/core/test/sync_engine_test.dart | 20 +++ packages/server/bin/server.dart | 4 +- packages/server/lib/src/media_endpoint.dart | 8 +- packages/server/lib/src/room_hub.dart | 60 ++++++- packages/server/lib/src/room_server.dart | 14 +- packages/server/lib/src/session.dart | 2 + packages/server/test/hardening_test.dart | 188 ++++++++++++++++++++ packages/server/test/room_auth_test.dart | 27 ++- 12 files changed, 370 insertions(+), 45 deletions(-) create mode 100644 packages/server/test/hardening_test.dart diff --git a/packages/core/lib/src/config.dart b/packages/core/lib/src/config.dart index 4610fc4..e721961 100644 --- a/packages/core/lib/src/config.dart +++ b/packages/core/lib/src/config.dart @@ -12,6 +12,8 @@ class AppConfig { final String name; final String mediaPath; final String logLevel; + /// Optional explicit host for file-media URLs; empty means use the host the + /// client dialled. final String advertiseHost; const AppConfig({ @@ -20,21 +22,21 @@ class AppConfig { required this.name, this.mediaPath = '', this.logLevel = 'INFO', - this.advertiseHost = '127.0.0.1', + this.advertiseHost = '', }); factory AppConfig.defaults() => - const AppConfig(port: 8080, secret: 'change-me', name: 'room'); + const AppConfig(port: 8080, secret: '', name: 'room'); factory AppConfig.fromMap(Map map) { try { return AppConfig( port: (map['port'] as num?)?.toInt() ?? 8080, - secret: (map['secret'] as String?) ?? 'change-me', + secret: (map['secret'] as String?) ?? '', name: (map['name'] as String?) ?? 'room', mediaPath: (map['mediaPath'] as String?) ?? '', logLevel: (map['logLevel'] as String?) ?? 'INFO', - advertiseHost: (map['advertiseHost'] as String?) ?? '127.0.0.1', + advertiseHost: (map['advertiseHost'] as String?) ?? '', ); } catch (e) { throw ConfigException('invalid config map: $e'); diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index 93c5e32..b7d733a 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -17,6 +17,23 @@ enum MessageKind { cancel, } +/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to +/// Infinity, which would later break jsonEncode on every broadcast. +double finiteDouble(Object? value, String field, {double fallback = 0.0}) { + if (value == null) return fallback; + final d = (value as num).toDouble(); + if (!d.isFinite) throw ProtocolException('$field must be finite'); + return d; +} + +/// Same as [finiteDouble] but for integers. +int finiteInt(Object? value, String field, {int fallback = 0}) { + if (value == null) return fallback; + final n = value as num; + if (!n.isFinite) throw ProtocolException('$field must be finite'); + return n.toInt(); +} + class HelloUser { final String name; final String avatar; @@ -39,8 +56,8 @@ class HelloMedia { factory HelloMedia.fromJson(Map json) => HelloMedia( name: (json['name'] as String?) ?? '', - size: (json['size'] as num?)?.toInt() ?? 0, - duration: (json['duration'] as num?)?.toDouble() ?? 0.0, + size: finiteInt(json['size'], 'size', fallback: 0), + duration: finiteDouble(json['duration'], 'duration'), url: json['url'] as String?, ); } @@ -66,7 +83,7 @@ class HelloMessage { final media = json['media']; if (user is! Map || media is! Map) throw const ProtocolException('hello missing user/media'); return HelloMessage( - proto: (json['proto'] as num?)?.toInt() ?? RelayConstants.protoVersion, + proto: finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoVersion), secret: (json['secret'] as String?) ?? '', user: HelloUser.fromJson(Map.from(user)), media: HelloMedia.fromJson(Map.from(media)), @@ -89,9 +106,9 @@ class RoomState { }; factory RoomState.fromJson(Map json) => RoomState( - position: (json['position'] as num?)?.toDouble() ?? 0.0, + position: finiteDouble(json['position'], 'position'), paused: (json['paused'] as bool?) ?? true, - mediaId: (json['mediaId'] as num?)?.toInt() ?? 0, + mediaId: finiteInt(json['mediaId'], 'mediaId', fallback: 0), serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(), ); } @@ -191,7 +208,7 @@ class SeekIntent { factory SeekIntent.fromJson(Map json) { if (json['t'] != 'seek') throw const ProtocolException('not a seek intent'); - return SeekIntent(position: (json['position'] as num?)?.toDouble() ?? 0.0); + return SeekIntent(position: finiteDouble(json['position'], 'position')); } } @@ -221,9 +238,9 @@ class ReportMessage { factory ReportMessage.fromJson(Map json) { if (json['t'] != 'report') throw const ProtocolException('not a report'); return ReportMessage( - position: (json['position'] as num?)?.toDouble() ?? 0.0, - buffered: (json['buffered'] as num?)?.toDouble() ?? 0.0, - eventId: (json['eventId'] as num?)?.toInt() ?? 0, + position: finiteDouble(json['position'], 'position'), + buffered: finiteDouble(json['buffered'], 'buffered'), + eventId: finiteInt(json['eventId'], 'eventId', fallback: 0), ); } } @@ -274,7 +291,7 @@ class MemberView { avatar: (json['avatar'] as String?) ?? '', ready: (json['ready'] as bool?) ?? false, inSet: (json['inSet'] as bool?) ?? false, - buffered: (json['buffered'] as num?)?.toDouble() ?? 0.0, + buffered: finiteDouble(json['buffered'], 'buffered'), stale: (json['stale'] as bool?) ?? false, ); } @@ -313,11 +330,11 @@ class RoomBroadcast { final members = json['members']; final media = json['media']; return RoomBroadcast( - eventId: (json['eventId'] as num?)?.toInt() ?? 0, + eventId: finiteInt(json['eventId'], 'eventId', fallback: 0), transport: (json['transport'] as String?) ?? 'held', - position: (json['position'] as num?)?.toDouble() ?? 0.0, + position: finiteDouble(json['position'], 'position'), serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(), - mediaId: (json['mediaId'] as num?)?.toInt() ?? 0, + mediaId: finiteInt(json['mediaId'], 'mediaId', fallback: 0), media: media is Map ? Map.from(media) : null, members: members is List ? members @@ -382,9 +399,9 @@ class SyncEvent { final String kind; if (json['t'] != 'event') throw const ProtocolException('not an event message'); return SyncEvent( kind: (json['kind'] as String?) ?? '', - position: (json['position'] as num?)?.toDouble() ?? 0.0, + position: finiteDouble(json['position'], 'position'), doSeek: (json['doSeek'] as bool?) ?? false, - ignoringOnTheFly: (json['ignoringOnTheFly'] as num?)?.toInt() ?? 0, + ignoringOnTheFly: finiteInt(json['ignoringOnTheFly'], 'ignoringOnTheFly', fallback: 0), ); } } diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index a5911c2..657b8f5 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -156,7 +156,7 @@ class RoomEngine { void applySeek(String token, double seekPosition) { final member = members[token]; if (member == null) return; - if (seekPosition.isNaN || seekPosition < 0) return; + if (!seekPosition.isFinite || seekPosition < 0) return; member.lastSeen = now; position = seekPosition; serverTime = now; @@ -164,7 +164,9 @@ class RoomEngine { feed.add(FeedMessage(actor: member.name, action: 'seek', at: now)); } - void applySwap(String actorToken, HelloMedia next) { + /// Returns false (no state change) for media with non-finite numbers. + bool applySwap(String actorToken, HelloMedia next) { + if (!next.duration.isFinite || next.size < 0) return false; final actor = members[actorToken]; mediaId++; media = next; @@ -178,13 +180,15 @@ class RoomEngine { member.buffered = 0; } feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now)); + return true; } void applyReport(String token, double reportPosition, double buffered, int reportEventId) { final member = members[token]; if (member == null) return; + if (!reportPosition.isFinite) return; member.lastSeen = now; - member.buffered = buffered.isNaN || buffered < 0 ? 0 : buffered; + member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered; if (reportEventId < eventId) return; evaluate(); } diff --git a/packages/core/test/core_test.dart b/packages/core/test/core_test.dart index 8647e0a..ceb204a 100644 --- a/packages/core/test/core_test.dart +++ b/packages/core/test/core_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:sync_relay_core/core.dart'; import 'package:test/test.dart'; @@ -6,7 +8,8 @@ void main() { test('defaults are sane', () { final c = AppConfig.defaults(); expect(c.port, 8080); - expect(c.secret, isNotEmpty); + expect(c.secret, isEmpty, reason: 'empty password = open server'); + expect(c.advertiseHost, isEmpty); }); test('fromMap tolerates missing keys', () { @@ -31,6 +34,26 @@ void main() { }); group('protocol', () { + test('non-finite numbers are rejected', () { + final inf = jsonDecode('{"t":"seek","position":1e400}') as Map; + expect(inf['position'], double.infinity); + expect(() => SeekIntent.fromJson(inf), throwsA(isA())); + expect( + () => ReportMessage.fromJson( + {'t': 'report', 'position': 1.0, 'buffered': double.infinity}), + throwsA(isA())); + expect( + () => ReportMessage.fromJson( + {'t': 'report', 'position': double.nan, 'buffered': 1.0}), + throwsA(isA())); + expect( + () => SwapIntent.fromJson({ + 't': 'swap', + 'media': {'name': 'a', 'size': 1, 'duration': double.infinity} + }), + throwsA(isA())); + }); + test('hello round-trips', () { const msg = HelloMessage( proto: 1, diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart index 1623fee..35f70db 100644 --- a/packages/core/test/sync_engine_test.dart +++ b/packages/core/test/sync_engine_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:sync_relay_core/core.dart'; import 'package:test/test.dart'; @@ -16,6 +18,24 @@ void main() { void buffer(String token, double secs) => engine.applyReport(token, engine.positionAt(), secs, engine.eventId); + group('non-finite guards', () { + test('seek/report/swap reject Infinity and NaN, broadcast stays encodable', () { + join('a', 'alice'); + engine.applySeek('a', double.infinity); + engine.applySeek('a', double.nan); + expect(engine.position, 0); + engine.applyReport('a', double.infinity, 1, engine.eventId); + engine.applyReport('a', 1, double.infinity, engine.eventId); + expect(engine.members['a']!.buffered, 0); + expect( + engine.applySwap('a', + const HelloMedia(name: 'x', size: 1, duration: double.infinity)), + isFalse); + expect(engine.mediaId, 1); + jsonEncode(engine.broadcast().toJson()); + }); + }); + group('membership + gate', () { test('join lands out-of-set, room stays held', () { join('a', 'alice'); diff --git a/packages/server/bin/server.dart b/packages/server/bin/server.dart index 5fde438..b8b0350 100644 --- a/packages/server/bin/server.dart +++ b/packages/server/bin/server.dart @@ -8,10 +8,10 @@ import 'package:sync_relay_server/server.dart'; Future main(List args) async { final parser = ArgParser() ..addOption('port', abbr: 'p', defaultsTo: '8080') - ..addOption('secret', defaultsTo: 'change-me') + ..addOption('secret', defaultsTo: '') ..addOption('name', defaultsTo: 'room') ..addOption('media', defaultsTo: '') - ..addOption('advertise-host', defaultsTo: '127.0.0.1') + ..addOption('advertise-host', defaultsTo: '') ..addOption('config') ..addFlag('help', abbr: 'h', negatable: false); final parsed = parser.parse(args); diff --git a/packages/server/lib/src/media_endpoint.dart b/packages/server/lib/src/media_endpoint.dart index 21d0ba1..68bb57a 100644 --- a/packages/server/lib/src/media_endpoint.dart +++ b/packages/server/lib/src/media_endpoint.dart @@ -191,7 +191,6 @@ void mountSessionMediaRoute( } final end = res.end ?? size - 1; final byteCount = end - res.start + 1; - session.addBytes(token, byteCount); final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token; onLog?.call(token, MediaLog( method: req.method.name, @@ -201,7 +200,12 @@ void mountSessionMediaRoute( bytesWritten: byteCount, durationMs: stopwatch.elapsedMilliseconds, )); - final stream = file.openRead(res.start, end + 1).map((c) => Uint8List.fromList(c)); + // Count bytes as they are actually sent: mpv often asks for `bytes=N-` + // and drops the connection early, so the requested range overcounts. + final stream = file.openRead(res.start, end + 1).map((c) { + session.addBytes(token, c.length); + return Uint8List.fromList(c); + }); return Response( res.status, body: Body.fromDataStream(stream, contentLength: byteCount), diff --git a/packages/server/lib/src/room_hub.dart b/packages/server/lib/src/room_hub.dart index 6e4d4bb..cccfd98 100644 --- a/packages/server/lib/src/room_hub.dart +++ b/packages/server/lib/src/room_hub.dart @@ -96,10 +96,29 @@ class RoomHub { Future kickById(String targetId, String reason) async { final member = engine.byId(targetId); if (member == null) throw const AuthException('unknown kick target'); + if (session.tokens[member.token]?.isHost ?? false) { + throw const AuthException("can't kick the host"); + } await session.kick(member.token, reason: reason); } } +/// Media names are shown to everyone and must never carry a URL (api_key etc.). +/// A URL-looking name is reduced to its last path segment, no query/fragment. +String sanitizeMediaName(String name) { + final trimmed = name.trim(); + if (!RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*://').hasMatch(trimmed)) return name; + final uri = Uri.tryParse(trimmed); + final segments = uri?.pathSegments.where((s) => s.isNotEmpty).toList() ?? const []; + if (segments.isNotEmpty) return segments.last; + return 'media'; +} + +bool _isLoopback(String address) => + address == '::1' || + address.startsWith('127.') || + address.startsWith('::ffff:127.'); + void wsErr(RelicWebSocket ws, String message) { ws.trySendText(jsonEncode({'t': 'err', 'message': message})); } @@ -107,9 +126,15 @@ void wsErr(RelicWebSocket ws, String message) { void mountSyncRoute( RelicApp app, RoomHub hub, - String Function(String sid, String token, SessionMedia? media) urlBuilder, + String Function(String sid, String token, SessionMedia? media, String host) + urlBuilder, ) { app.get('/sync', (final req) { + // Host the client dialled (Host header), so file URLs work for remote joiners. + final dialledHost = req.url.hasPort + ? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}' + : (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host); + final fromLoopback = _isLoopback(req.connectionInfo.remote.address.toString()); return WebSocketUpgrade((final ws) async { ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs); final firstText = Completer(); @@ -155,12 +180,26 @@ void mountSyncRoute( hub.changed(); case 'swap': if (token == null) return; - final intent = SwapIntent.fromJson(decoded); - hub.engine.applySwap(token, intent.media); + final parsed = SwapIntent.fromJson(decoded).media; + final intent = SwapIntent( + media: HelloMedia( + name: sanitizeMediaName(parsed.name), + size: parsed.size, + duration: parsed.duration, + url: parsed.url)); final current = session.media; - if (current?.file != null && + final matchesFile = current?.file != null && current!.name == intent.media.name && - current.size == intent.media.size) { + current.size == intent.media.size; + if (!matchesFile && (intent.media.url ?? '').isEmpty) { + wsErr(ws, 'swap needs a url or the hosted file'); + return; + } + if (!hub.engine.applySwap(token, intent.media)) { + wsErr(ws, 'bad swap media'); + return; + } + if (matchesFile) { session.media = SessionMedia( name: current.name, size: current.size, @@ -193,12 +232,18 @@ void mountSyncRoute( wsErr(ws, 'unknown kick target'); return; } + if (session.tokens[member.token]?.isHost ?? false) { + wsErr(ws, "can't kick the host"); + return; + } final by = viewer?.name ?? '?'; unawaited(hub.session.kick(member.token, reason: kickReq.reason.isEmpty ? 'kicked by $by' : kickReq.reason)); } + } on ProtocolException catch (e) { + wsErr(ws, 'bad message: ${e.message}'); } catch (_) { return; } @@ -250,7 +295,7 @@ void mountSyncRoute( return; } final session = hub.session; - if (hello.secret != session.secret) { + if (session.secret.isNotEmpty && hello.secret != session.secret) { _hubLog.warning('reject ${hello.user.name}: wrong secret'); wsErr(ws, 'wrong secret'); await close(); @@ -264,6 +309,7 @@ void mountSyncRoute( await close(); return; } + session.tokens[token]!.isHost = fromLoopback; final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar); final activeToken = token; hub.sockets[activeToken] = ws; @@ -277,7 +323,7 @@ void mountSyncRoute( final welcome = WelcomeMessage( token: activeToken, memberId: memberId, - mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media) : media?.url, + mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media, dialledHost) : media?.url, state: hub.engine.broadcast().toJson(), ); try { diff --git a/packages/server/lib/src/room_server.dart b/packages/server/lib/src/room_server.dart index c788b55..ec8f23f 100644 --- a/packages/server/lib/src/room_server.dart +++ b/packages/server/lib/src/room_server.dart @@ -55,16 +55,17 @@ class RoomServer { 'viewers': viewers, 'bytesServed': bytes, 'uptimeSecs': uptime, - 'media': s?.media?.name, - 'transport': hub.engine.transport.name, - 'eventId': hub.engine.eventId, })), ); }); mountSessionMediaRoute(_app, sessions); - mountSyncRoute(_app, hub, (sid, token, media) { + mountSyncRoute(_app, hub, (sid, token, media, dialledHost) { final name = Uri.encodeComponent(media?.name ?? 'file'); - return 'http://${config.advertiseHost}:$boundPort/m/$sid/$token/$name'; + // advertiseHost is only an explicit override; otherwise echo what the client dialled. + final authority = config.advertiseHost.isNotEmpty + ? '${config.advertiseHost}:$boundPort' + : dialledHost; + return 'http://$authority/m/$sid/$token/$name'; }); _app.fallback = (final req) => Response.notFound( body: Body.fromString('not found'), @@ -85,6 +86,9 @@ class RoomServer { _log.warning('media file not found: ${config.mediaPath}'); } } + if (config.secret.isEmpty) { + _log.warning('no password set: open server, anyone who can reach it can join'); + } _mountRoutes(); _server = await _app.serve( address: InternetAddress.anyIPv4, diff --git a/packages/server/lib/src/session.dart b/packages/server/lib/src/session.dart index b20383e..4526b60 100644 --- a/packages/server/lib/src/session.dart +++ b/packages/server/lib/src/session.dart @@ -10,6 +10,8 @@ class ViewerToken { final String name; final String avatar; bool kicked = false; + /// Connected from a loopback address: the host's own in-process client. + bool isHost = false; int bytesServed = 0; DateTime lastSeen; Future Function(String reason)? onKick; diff --git a/packages/server/test/hardening_test.dart b/packages/server/test/hardening_test.dart new file mode 100644 index 0000000..abd7e28 --- /dev/null +++ b/packages/server/test/hardening_test.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; + +class _Peer { + final WebSocket ws; + final List> msgs = []; + bool closed = false; + _Peer(this.ws) { + ws.listen((m) => msgs.add(Map.from(jsonDecode('$m') as Map)), + onDone: () => closed = true); + } + + static Future<_Peer> join(String host, int port, String name, + {String secret = ''}) async { + final ws = await WebSocket.connect('ws://$host:$port/sync'); + final p = _Peer(ws); + ws.add(jsonEncode(HelloMessage( + proto: RelayConstants.protoVersion, + secret: secret, + user: HelloUser(name: name), + media: const HelloMedia(name: '', size: 0, duration: 0)) + .toJson())); + await p.next('welcome'); + return p; + } + + void send(String raw) => ws.add(raw); + + Future> next(String type, + {Duration timeout = const Duration(seconds: 5)}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + for (final m in msgs) { + if (m['t'] == type) { + msgs.remove(m); + return m; + } + } + await Future.delayed(const Duration(milliseconds: 30)); + } + fail('no $type in $msgs'); + } +} + +Future _lan() async { + for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) { + for (final a in i.addresses) { + if (!a.isLoopback) return a.address; + } + } + return null; +} + +void main() { + late RoomServer server; + late File file; + + setUp(() async { + file = File('${Directory.systemTemp.path}/hard_${DateTime.now().microsecondsSinceEpoch}.bin') + ..writeAsBytesSync(List.filled(64, 1)); + server = RoomServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path)); + await server.start(); + }); + + tearDown(() async { + await server.stop(); + await file.delete(); + }); + + test('Infinity seek/report is rejected and sync keeps broadcasting', () async { + final a = await _Peer.join('127.0.0.1', server.boundPort, 'a'); + a.send('{"t":"seek","position":1e400}'); + a.send('{"t":"report","position":1e400,"buffered":1e400,"eventId":0}'); + final err = await a.next('err'); + expect('${err['message']}', contains('finite')); + expect(server.hub.engine.position, 0); + // still alive: a valid seek round-trips through a broadcast + a.send('{"t":"seek","position":5}'); + for (var i = 0; i < 50 && server.hub.engine.position != 5; i++) { + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(server.hub.engine.position, 5); + await a.ws.close(); + }); + + test('empty swap rejected with err, no state change', () async { + final a = await _Peer.join('127.0.0.1', server.boundPort, 'a'); + final before = server.hub.engine.mediaId; + a.send(jsonEncode({ + 't': 'swap', + 'media': {'name': 'x', 'size': 0, 'duration': 0, 'url': ''} + })); + await a.next('err'); + expect(server.hub.engine.mediaId, before); + await a.ws.close(); + }); + + test('swap name that is a URL is reduced to last segment', () async { + expect(sanitizeMediaName('http://j:8096/Videos/1/stream.mkv?api_key=SECRET#x'), + 'stream.mkv'); + expect(sanitizeMediaName('movie.mkv'), 'movie.mkv'); + final a = await _Peer.join('127.0.0.1', server.boundPort, 'a'); + a.send(jsonEncode({ + 't': 'swap', + 'media': { + 'name': 'http://j/Videos/1/stream.mkv?api_key=SECRET', + 'size': 0, + 'duration': 1, + 'url': 'http://j/Videos/1/stream.mkv?api_key=SECRET' + } + })); + for (var i = 0; i < 50 && server.hub.engine.mediaId == 1; i++) { + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(server.hub.engine.media!.name, 'stream.mkv'); + expect(server.session.media!.name, 'stream.mkv'); + await a.ws.close(); + }); + + test('host (loopback) cannot be kicked; others can', () async { + final lan = await _lan(); + if (lan == null) { + markTestSkipped('no LAN address'); + return; + } + final host = await _Peer.join('127.0.0.1', server.boundPort, 'host'); + final friend = await _Peer.join(lan, server.boundPort, 'friend'); + final hostId = server.hub.engine.members.values.firstWhere((m) => m.name == 'host').id; + friend.send(jsonEncode(KickRequest(target: hostId).toJson())); + final err = await friend.next('err'); + expect(err['message'], "can't kick the host"); + expect(host.closed, isFalse); + await expectLater(server.hub.kickById(hostId, 'x'), throwsA(isA())); + await host.ws.close(); + await friend.ws.close(); + }); + + test('empty secret accepts any hello secret', () async { + final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', secret: 'whatever'); + expect(server.hub.engine.members, hasLength(1)); + await a.ws.close(); + }); + + test('/health exposes no media name', () async { + final c = HttpClient(); + final r = await (await c.get('127.0.0.1', server.boundPort, '/health')).close(); + final body = jsonDecode(await r.transform(utf8.decoder).join()) as Map; + c.close(); + expect(body['ok'], isTrue); + expect(body.containsKey('media'), isFalse); + }); + + test('file media URL uses the host the client dialled', () async { + final lan = await _lan(); + final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync'); + final p = _Peer(ws); + ws.add(jsonEncode(HelloMessage( + proto: RelayConstants.protoVersion, + secret: '', + user: const HelloUser(name: 'a'), + media: const HelloMedia(name: '', size: 0, duration: 0)) + .toJson())); + final w = await p.next('welcome'); + expect('${w['mediaUrl']}', startsWith('http://127.0.0.1:${server.boundPort}/m/')); + await ws.close(); + if (lan != null) { + final q = await _Peer.join(lan, server.boundPort, 'b'); + // welcome consumed in join; re-check via session state instead + await q.ws.close(); + final ws2 = await WebSocket.connect('ws://$lan:${server.boundPort}/sync'); + final p2 = _Peer(ws2); + ws2.add(jsonEncode(HelloMessage( + proto: RelayConstants.protoVersion, + secret: '', + user: const HelloUser(name: 'c'), + media: const HelloMedia(name: '', size: 0, duration: 0)) + .toJson())); + final w2 = await p2.next('welcome'); + expect('${w2['mediaUrl']}', startsWith('http://$lan:${server.boundPort}/m/')); + await ws2.close(); + } + }); +} diff --git a/packages/server/test/room_auth_test.dart b/packages/server/test/room_auth_test.dart index d7aaf7f..07888bd 100644 --- a/packages/server/test/room_auth_test.dart +++ b/packages/server/test/room_auth_test.dart @@ -8,6 +8,15 @@ import 'package:sync_relay_server/server.dart'; import 'package:test/test.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +Future _lanAddress() async { + for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) { + for (final a in i.addresses) { + if (!a.isLoopback) return a.address; + } + } + return null; +} + void main() { group('room auth live', () { late File file; @@ -73,7 +82,7 @@ void main() { expect(decoded['proto'], RelayConstants.protoVersion); expect(decoded['viewers'], 0); expect(decoded['bytesServed'], 0); - expect(decoded['media'], isNotNull); + expect(decoded.containsKey('media'), isFalse, reason: 'no media name leak'); }); test('wrong secret gets err, right secret gets welcome+token', () async { @@ -238,10 +247,16 @@ void main() { }); test('kick via socket: peer kicked, unknown target errors', () async { - WebSocketChannel open() => WebSocketChannel.connect( - Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); - Future<(WebSocketChannel, String, String, List)> join(String who) async { - final ch = open(); + final lan = await _lanAddress(); + if (lan == null) { + markTestSkipped('no non-loopback IPv4 to act as a non-host peer'); + return; + } + // Loopback peers are the host and unkickable, so bob dials the LAN address. + Future<(WebSocketChannel, String, String, List)> join(String who, + [String host = '127.0.0.1']) async { + final ch = WebSocketChannel.connect( + Uri.parse('ws://$host:${server.boundPort}/sync')); final messages = []; ch.stream.listen((m) => messages.add('$m')); final msg = HelloMessage( @@ -289,7 +304,7 @@ void main() { } final (a, _, _, messagesA) = await join('alice'); - final (b, tokenB, idB, messagesB) = await join('bob'); + final (b, tokenB, idB, messagesB) = await join('bob', lan); try { a.sink.add(jsonEncode( KickRequest(target: idB, reason: 'afk?').toJson()));