From 88bad4b5c2244ffd1127de2db67e365f8ccd853a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 13 Jun 2026 09:09:09 +0000 Subject: [PATCH] security(lottie): validate postMessage origin on web HTML renderer The Lottie web HTML renderer listened for window messages without checking event.origin and used wildcard postMessage targets. A cross-origin page with a window reference could spoof onComplete/onForward callbacks and trigger YAML-defined actions. Restrict iframe and host traffic to the app origin and add regression tests. Co-authored-by: Sharjeel Yunus --- .../widget/lottie/lottie_post_message.dart | 44 ++++++++++++ .../lib/widget/lottie/web/lottiestate.dart | 48 ++++++++----- .../test/lottie_post_message_test.dart | 69 +++++++++++++++++++ 3 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 modules/ensemble/lib/widget/lottie/lottie_post_message.dart create mode 100644 modules/ensemble/test/lottie_post_message_test.dart diff --git a/modules/ensemble/lib/widget/lottie/lottie_post_message.dart b/modules/ensemble/lib/widget/lottie/lottie_post_message.dart new file mode 100644 index 000000000..c45189a79 --- /dev/null +++ b/modules/ensemble/lib/widget/lottie/lottie_post_message.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:meta/meta.dart'; + +/// Returns true when [origin] matches the host page [pageOrigin]. +@visibleForTesting +bool isAllowedLottiePostMessageOrigin(String origin, String pageOrigin) { + if (pageOrigin.isEmpty) { + return false; + } + return origin == pageOrigin; +} + +/// Parses a Lottie iframe callback JSON payload for [tag]. +/// +/// Returns null when [rawData] is not a valid callback object. +@visibleForTesting +Map? parseLottieCallbackMessage({ + required String rawData, + required String tag, +}) { + if (!rawData.contains('{')) { + return null; + } + try { + final json = jsonDecode(rawData); + if (json is! Map) { + return null; + } + if (json['tag'] != tag) { + return null; + } + if (json['data'] is! String) { + return null; + } + return Map.from(json); + } catch (_) { + return null; + } +} + +/// JSON-encoded origin literal safe to embed in generated iframe JavaScript. +@visibleForTesting +String lottieParentOriginLiteral(String pageOrigin) => jsonEncode(pageOrigin); diff --git a/modules/ensemble/lib/widget/lottie/web/lottiestate.dart b/modules/ensemble/lib/widget/lottie/web/lottiestate.dart index 765e65f7d..08ad0af55 100644 --- a/modules/ensemble/lib/widget/lottie/web/lottiestate.dart +++ b/modules/ensemble/lib/widget/lottie/web/lottiestate.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; import 'dart:ui_web' as ui; import 'package:ensemble/framework/error_handling.dart'; @@ -10,6 +9,7 @@ import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/box_wrapper.dart'; import 'package:ensemble/widget/helpers/widgets.dart'; import 'package:ensemble/widget/lottie/lottie.dart'; +import 'package:ensemble/widget/lottie/lottie_post_message.dart'; import 'package:ensemble/widget/widget_util.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -50,14 +50,18 @@ class LottieState extends EWidgetState widget.controller.lottieAction = this; } + String get _postMessageOrigin => html.window.location.origin; + @override - void forward() => html.window.postMessage('forward_$divId', "*"); + void forward() => + html.window.postMessage('forward_$divId', _postMessageOrigin); @override - void reset() => html.window.postMessage('reset_$divId', "*"); + void reset() => html.window.postMessage('reset_$divId', _postMessageOrigin); @override - void reverse() => html.window.postMessage('reverse_$divId', "*"); + void reverse() => + html.window.postMessage('reverse_$divId', _postMessageOrigin); @override - void stop() => html.window.postMessage('stop_$divId', "*"); + void stop() => html.window.postMessage('stop_$divId', _postMessageOrigin); @override void dispose() { html.window.close(); // To prevent memory leaks @@ -115,6 +119,7 @@ class LottieState extends EWidgetState // the image will throw exception. We have to use a permanent placeholder // until the binding engages // HTML & JS code for the web html renderer + final parentOriginLiteral = lottieParentOriginLiteral(_postMessageOrigin); final htmlString = ''' @@ -127,6 +132,7 @@ class LottieState extends EWidgetState @@ -188,13 +197,17 @@ class LottieState extends EWidgetState // Event listener for the messages that are sent from JS to Dart html.window.onMessage.listen( (event) async { + if (!isAllowedLottiePostMessageOrigin( + event.origin, _postMessageOrigin)) { + return; + } final String data = event.data; - // Need to check if the data is in json format as there are also other events from JS - if (data.contains('{')) { - final json = jsonDecode(data); - // Segregating the latest event from old events using then html tag and the id which is just a counter which increments by 1 for each event - if (lastEventId != json['id'] && divId == json['tag']) { - lastEventId = json['id']; + final json = parseLottieCallbackMessage(rawData: data, tag: divId); + if (json == null) { + return; + } + if (lastEventId != json['id']) { + lastEventId = json['id']; // Mapping the events to their respective callbacks if (json['data'] == "onForward" && widget.controller.onForward != null) { @@ -228,7 +241,6 @@ class LottieState extends EWidgetState event: EnsembleEvent(widget), ); } - } } }, ); diff --git a/modules/ensemble/test/lottie_post_message_test.dart b/modules/ensemble/test/lottie_post_message_test.dart new file mode 100644 index 000000000..f069387bf --- /dev/null +++ b/modules/ensemble/test/lottie_post_message_test.dart @@ -0,0 +1,69 @@ +import 'package:ensemble/widget/lottie/lottie_post_message.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isAllowedLottiePostMessageOrigin', () { + test('allows messages from the host page origin', () { + expect( + isAllowedLottiePostMessageOrigin( + 'https://app.example.com', + 'https://app.example.com', + ), + isTrue, + ); + }); + + test('rejects cross-origin messages', () { + expect( + isAllowedLottiePostMessageOrigin( + 'https://evil.example.com', + 'https://app.example.com', + ), + isFalse, + ); + }); + + test('rejects empty page origin', () { + expect(isAllowedLottiePostMessageOrigin('https://app.example.com', ''), + isFalse); + }); + }); + + group('parseLottieCallbackMessage', () { + test('parses valid callback payloads for the expected tag', () { + final json = parseLottieCallbackMessage( + rawData: '{"data": "onComplete", "id": 3, "tag": "lottie_123"}', + tag: 'lottie_123', + ); + expect(json, isNotNull); + expect(json!['data'], 'onComplete'); + expect(json['id'], 3); + }); + + test('rejects spoofed tags', () { + expect( + parseLottieCallbackMessage( + rawData: '{"data": "onComplete", "id": 3, "tag": "other"}', + tag: 'lottie_123', + ), + isNull, + ); + }); + + test('rejects non-json payloads', () { + expect( + parseLottieCallbackMessage(rawData: 'not-json', tag: 'lottie_123'), + isNull, + ); + }); + }); + + group('lottieParentOriginLiteral', () { + test('JSON-encodes origin for safe embedding in iframe JavaScript', () { + expect( + lottieParentOriginLiteral('https://app.example.com'), + '"https://app.example.com"', + ); + }); + }); +}