Skip to content

Commit af2bf1a

Browse files
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 <sharjeelyunus@users.noreply.github.com>
1 parent 628d9b1 commit af2bf1a

3 files changed

Lines changed: 143 additions & 18 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'dart:convert';
2+
3+
import 'package:meta/meta.dart';
4+
5+
/// Returns true when [origin] matches the host page [pageOrigin].
6+
@visibleForTesting
7+
bool isAllowedLottiePostMessageOrigin(String origin, String pageOrigin) {
8+
if (pageOrigin.isEmpty) {
9+
return false;
10+
}
11+
return origin == pageOrigin;
12+
}
13+
14+
/// Parses a Lottie iframe callback JSON payload for [tag].
15+
///
16+
/// Returns null when [rawData] is not a valid callback object.
17+
@visibleForTesting
18+
Map<String, dynamic>? parseLottieCallbackMessage({
19+
required String rawData,
20+
required String tag,
21+
}) {
22+
if (!rawData.contains('{')) {
23+
return null;
24+
}
25+
try {
26+
final json = jsonDecode(rawData);
27+
if (json is! Map) {
28+
return null;
29+
}
30+
if (json['tag'] != tag) {
31+
return null;
32+
}
33+
if (json['data'] is! String) {
34+
return null;
35+
}
36+
return Map<String, dynamic>.from(json);
37+
} catch (_) {
38+
return null;
39+
}
40+
}
41+
42+
/// JSON-encoded origin literal safe to embed in generated iframe JavaScript.
43+
@visibleForTesting
44+
String lottieParentOriginLiteral(String pageOrigin) => jsonEncode(pageOrigin);

modules/ensemble/lib/widget/lottie/web/lottiestate.dart

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import 'dart:async';
2-
import 'dart:convert';
32
import 'dart:math';
43
import 'dart:ui_web' as ui;
54
import 'package:ensemble/framework/error_handling.dart';
@@ -10,6 +9,7 @@ import 'package:ensemble/util/utils.dart';
109
import 'package:ensemble/widget/helpers/box_wrapper.dart';
1110
import 'package:ensemble/widget/helpers/widgets.dart';
1211
import 'package:ensemble/widget/lottie/lottie.dart';
12+
import 'package:ensemble/widget/lottie/lottie_post_message.dart';
1313
import 'package:ensemble/widget/widget_util.dart';
1414
import 'package:flutter/material.dart';
1515
import 'package:flutter/widgets.dart';
@@ -50,14 +50,18 @@ class LottieState extends EWidgetState<EnsembleLottie>
5050
widget.controller.lottieAction = this;
5151
}
5252

53+
String get _postMessageOrigin => html.window.location.origin;
54+
5355
@override
54-
void forward() => html.window.postMessage('forward_$divId', "*");
56+
void forward() =>
57+
html.window.postMessage('forward_$divId', _postMessageOrigin);
5558
@override
56-
void reset() => html.window.postMessage('reset_$divId', "*");
59+
void reset() => html.window.postMessage('reset_$divId', _postMessageOrigin);
5760
@override
58-
void reverse() => html.window.postMessage('reverse_$divId', "*");
61+
void reverse() =>
62+
html.window.postMessage('reverse_$divId', _postMessageOrigin);
5963
@override
60-
void stop() => html.window.postMessage('stop_$divId', "*");
64+
void stop() => html.window.postMessage('stop_$divId', _postMessageOrigin);
6165
@override
6266
void dispose() {
6367
html.window.close(); // To prevent memory leaks
@@ -115,6 +119,7 @@ class LottieState extends EWidgetState<EnsembleLottie>
115119
// the image will throw exception. We have to use a permanent placeholder
116120
// until the binding engages
117121
// HTML & JS code for the web html renderer
122+
final parentOriginLiteral = lottieParentOriginLiteral(_postMessageOrigin);
118123
final htmlString = '''
119124
<html>
120125
<body>
@@ -127,6 +132,7 @@ class LottieState extends EWidgetState<EnsembleLottie>
127132
<script type="text/javascript">
128133
let direction = 1; // Variable to define the direction ie to run animation forward or backward
129134
let player_$divId = document.getElementById("$divId");
135+
const parentOrigin = $parentOriginLiteral;
130136
// A counter variable which increments upon each event and thus making each event unique and allowing to segregate from old events
131137
let counter = 0;
132138
player_$divId.load("$source");
@@ -135,6 +141,9 @@ class LottieState extends EWidgetState<EnsembleLottie>
135141
136142
// Function to handle all the messages that are received from dart to js
137143
function handleMessage(e) {
144+
if (e.origin !== parentOrigin) {
145+
return;
146+
}
138147
var data = e.data;
139148
if (data == "forward_$divId") {
140149
direction = 1;
@@ -149,7 +158,7 @@ class LottieState extends EWidgetState<EnsembleLottie>
149158
}
150159
if (data == "stop_$divId") {
151160
player_$divId.pause();
152-
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', "*");
161+
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
153162
counter++;
154163
}
155164
if (data == "reset_$divId") {
@@ -158,20 +167,20 @@ class LottieState extends EWidgetState<EnsembleLottie>
158167
}
159168
// Event Listener for specific actions for animation like onComplete, onStart, onLoad and so on
160169
player_$divId.addEventListener("play", () => {
161-
if (direction == 1) window.parent.postMessage('{"data": "onForward", "id": ' + counter + ', "tag": "$divId"}', "*");
162-
else window.parent.postMessage('{"data": "onReverse", "id": ' + counter + ', "tag": "$divId"}', "*");
170+
if (direction == 1) window.parent.postMessage('{"data": "onForward", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
171+
else window.parent.postMessage('{"data": "onReverse", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
163172
counter++;
164173
});
165174
player_$divId.addEventListener("complete", () => {
166-
window.parent.postMessage('{"data": "onComplete", "id": ' + counter + ', "tag": "$divId"}', "*");
175+
window.parent.postMessage('{"data": "onComplete", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
167176
counter++;
168177
});
169178
player_$divId.addEventListener("pause", () => {
170-
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', "*");
179+
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
171180
counter++;
172181
});
173182
player_$divId.addEventListener("stop", () => {
174-
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', "*");
183+
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
175184
counter++;
176185
});
177186
</script>
@@ -188,13 +197,17 @@ class LottieState extends EWidgetState<EnsembleLottie>
188197
// Event listener for the messages that are sent from JS to Dart
189198
html.window.onMessage.listen(
190199
(event) async {
200+
if (!isAllowedLottiePostMessageOrigin(
201+
event.origin, _postMessageOrigin)) {
202+
return;
203+
}
191204
final String data = event.data;
192-
// Need to check if the data is in json format as there are also other events from JS
193-
if (data.contains('{')) {
194-
final json = jsonDecode(data);
195-
// 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
196-
if (lastEventId != json['id'] && divId == json['tag']) {
197-
lastEventId = json['id'];
205+
final json = parseLottieCallbackMessage(rawData: data, tag: divId);
206+
if (json == null) {
207+
return;
208+
}
209+
if (lastEventId != json['id']) {
210+
lastEventId = json['id'];
198211
// Mapping the events to their respective callbacks
199212
if (json['data'] == "onForward" &&
200213
widget.controller.onForward != null) {
@@ -228,7 +241,6 @@ class LottieState extends EWidgetState<EnsembleLottie>
228241
event: EnsembleEvent(widget),
229242
);
230243
}
231-
}
232244
}
233245
},
234246
);
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import 'package:ensemble/widget/lottie/lottie_post_message.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
4+
void main() {
5+
group('isAllowedLottiePostMessageOrigin', () {
6+
test('allows messages from the host page origin', () {
7+
expect(
8+
isAllowedLottiePostMessageOrigin(
9+
'https://app.example.com',
10+
'https://app.example.com',
11+
),
12+
isTrue,
13+
);
14+
});
15+
16+
test('rejects cross-origin messages', () {
17+
expect(
18+
isAllowedLottiePostMessageOrigin(
19+
'https://evil.example.com',
20+
'https://app.example.com',
21+
),
22+
isFalse,
23+
);
24+
});
25+
26+
test('rejects empty page origin', () {
27+
expect(isAllowedLottiePostMessageOrigin('https://app.example.com', ''),
28+
isFalse);
29+
});
30+
});
31+
32+
group('parseLottieCallbackMessage', () {
33+
test('parses valid callback payloads for the expected tag', () {
34+
final json = parseLottieCallbackMessage(
35+
rawData: '{"data": "onComplete", "id": 3, "tag": "lottie_123"}',
36+
tag: 'lottie_123',
37+
);
38+
expect(json, isNotNull);
39+
expect(json!['data'], 'onComplete');
40+
expect(json['id'], 3);
41+
});
42+
43+
test('rejects spoofed tags', () {
44+
expect(
45+
parseLottieCallbackMessage(
46+
rawData: '{"data": "onComplete", "id": 3, "tag": "other"}',
47+
tag: 'lottie_123',
48+
),
49+
isNull,
50+
);
51+
});
52+
53+
test('rejects non-json payloads', () {
54+
expect(
55+
parseLottieCallbackMessage(rawData: 'not-json', tag: 'lottie_123'),
56+
isNull,
57+
);
58+
});
59+
});
60+
61+
group('lottieParentOriginLiteral', () {
62+
test('JSON-encodes origin for safe embedding in iframe JavaScript', () {
63+
expect(
64+
lottieParentOriginLiteral('https://app.example.com'),
65+
'"https://app.example.com"',
66+
);
67+
});
68+
});
69+
}

0 commit comments

Comments
 (0)