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
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user