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 <noreply@anthropic.com>
This commit is contained in:
1 parent
32848aaf71
commit
e2301aa4ec
12 files changed
+370
-45
No files matched your search
@@ -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<Map<String, Object?>> msgs = [];
|
||||
bool closed = false;
|
||||
_Peer(this.ws) {
|
||||
ws.listen((m) => msgs.add(Map<String, Object?>.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<Map<String, Object?>> 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<String?> _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<AuthException>()));
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user