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:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:30:12 +03:00
1 parent 32848aaf71
commit e2301aa4ec
12 files changed
+370 -45

No files matched your search

+2 -2
View File
@@ -8,10 +8,10 @@ import 'package:sync_relay_server/server.dart';
Future<void> main(List<String> 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);
+6 -2
View File
@@ -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),
+53 -7
View File
@@ -96,10 +96,29 @@ class RoomHub {
Future<void> 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<String>();
@@ -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 {
+9 -5
View File
@@ -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,
+2
View File
@@ -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<void> Function(String reason)? onKick;
+188
View File
@@ -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();
}
});
}
+21 -6
View File
@@ -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<String?> _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<String>)> 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<String>)> join(String who,
[String host = '127.0.0.1']) async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://$host:${server.boundPort}/sync'));
final messages = <String>[];
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()));