diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index b2ac017d2..ed4f00ead 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -3494,6 +3494,11 @@ class CursorModel with ChangeNotifier { final hoty = double.parse(evt['hoty']); final width = int.parse(evt['width']); final height = int.parse(evt['height']); + // Bound physical allocation before density normalization or image decoding. + if (!_validCursorRasterSize(width.toDouble(), height.toDouble())) { + debugPrint('Rejected cursor $id: invalid source size ${width}x$height'); + return; + } final pixelRatio = double.tryParse(evt['scale'] ?? '0'); if (pixelRatio == null || !pixelRatio.isFinite || pixelRatio < 0 || (pixelRatio > 0 && @@ -3502,6 +3507,11 @@ class CursorModel with ChangeNotifier { return; } List colors = json.decode(evt['colors']); + const bytesPerPixel = 4; + if (colors.length != width * height * bytesPerPixel) { + debugPrint('Rejected cursor $id: invalid RGBA length ${colors.length}'); + return; + } final rgba = Uint8List.fromList(colors.map((s) => s as int).toList()); final ui.Image? image; final platform = parent.target?.ffiModel.pi.platform; diff --git a/flutter/test/cursor_source_validation_test.dart b/flutter/test/cursor_source_validation_test.dart new file mode 100644 index 000000000..36f10c0d9 --- /dev/null +++ b/flutter/test/cursor_source_validation_test.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'cursor_test_utils.dart'; + +const _side = 32; +const _rgbaChannels = 4; +const _sourceLimit = 4096; +const _invalidSizes = [ + (0, _side), + (-1, _side), + (_side, 0), + (_side, -1), + (_sourceLimit + 1, 1), + (1, _sourceLimit + 1), + (_sourceLimit * 4, _sourceLimit * 4), +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + for (final platform in [ + kPeerPlatformMacOS, + kPeerPlatformWindows, + kPeerPlatformLinux, + ]) { + for (final density in [null, '0', '4']) { + test('cursor source validation $platform density=$density', + () => _checkPackets(platform, density)); + } + } +} + +Future _checkPackets(String platform, String? density) async { + final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 1); + final ffi = CursorTestFFI(canvas)..ffiModel.pi.platform = platform; + final cursor = CursorModel(WeakReference(ffi))..id = 'valid'; + final messages = []; + final originalPrint = debugPrint; + debugPrint = (message, {wrapWidth}) => messages.add(message); + addTearDown(() { + debugPrint = originalPrint; + expect(cursor.parent.target, same(ffi)); + cursor.disposeImages(); + cursor.dispose(); + canvas.dispose(); + }); + await cursor.updateCursorData(_event(density)); + final previous = cursor.cache; + for (final length in [ + 0, + 4, + _side * _side * _rgbaChannels - 1, + _side * _side * _rgbaChannels + 1 + ]) { + await _expectRejected(cursor, _event(density, length: length)); + expect(messages, contains(contains('RGBA length'))); + messages.clear(); + } + for (final (width, height) in _invalidSizes) { + // Invalid JSON detects decoding before geometry validation, without risking + // the oversized allocation if the production size check regresses. + final packet = _event(density, width: width, height: height, length: 4) + ..['colors'] = 'must not decode an invalid source size'; + await _expectRejected(cursor, packet); + expect(messages, contains(contains('source size'))); + messages.clear(); + } + for (final (width, height) in [ + (1, 1), + (_sourceLimit, 1), + (1, _sourceLimit) + ]) { + cursor.id = 'valid'; + await cursor + .updateCursorData(_event(density, width: width, height: height)); + expect((cursor.image!.width, cursor.image!.height), (width, height)); + expect(cursor.cache, isNot(same(previous))); + } +} + +Future _expectRejected( + CursorModel cursor, Map packet) async { + final previous = cursor.cache; + final image = cursor.image; + final hotspot = (cursor.hotx, cursor.hoty); + cursor.id = 'invalid'; + await cursor.updateCursorData({...packet, 'id': 'invalid'}); + expect(cursor.cache, same(previous)); + expect(cursor.image, same(image)); + expect((cursor.hotx, cursor.hoty), hotspot); +} + +Map _event(String? density, + {int width = _side, int height = _side, int? length}) => + { + 'id': 'valid', + 'width': '$width', + 'height': '$height', + 'hotx': '0', + 'hoty': '0', + if (density != null) 'scale': density, + 'colors': jsonEncode( + List.filled(length ?? width * height * _rgbaChannels, 255)), + };