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

+6 -4
View File
@@ -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<String, Object?> 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');
+32 -15
View File
@@ -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<String, Object?> 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<String, Object?>.from(user)),
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
@@ -89,9 +106,9 @@ class RoomState {
};
factory RoomState.fromJson(Map<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?>.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),
);
}
}
+7 -3
View File
@@ -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();
}
+24 -1
View File
@@ -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<String, Object?>;
expect(inf['position'], double.infinity);
expect(() => SeekIntent.fromJson(inf), throwsA(isA<ProtocolException>()));
expect(
() => ReportMessage.fromJson(
{'t': 'report', 'position': 1.0, 'buffered': double.infinity}),
throwsA(isA<ProtocolException>()));
expect(
() => ReportMessage.fromJson(
{'t': 'report', 'position': double.nan, 'buffered': 1.0}),
throwsA(isA<ProtocolException>()));
expect(
() => SwapIntent.fromJson({
't': 'swap',
'media': {'name': 'a', 'size': 1, 'duration': double.infinity}
}),
throwsA(isA<ProtocolException>()));
});
test('hello round-trips', () {
const msg = HelloMessage(
proto: 1,
+20
View File
@@ -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');