Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions modules/ensemble/lib/widget/lottie/lottie_post_message.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic>? 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<String, dynamic>.from(json);
} catch (_) {
return null;
}
}

/// JSON-encoded origin literal safe to embed in generated iframe JavaScript.
@visibleForTesting
String lottieParentOriginLiteral(String pageOrigin) => jsonEncode(pageOrigin);
48 changes: 30 additions & 18 deletions modules/ensemble/lib/widget/lottie/web/lottiestate.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -50,14 +50,18 @@ class LottieState extends EWidgetState<EnsembleLottie>
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
Expand Down Expand Up @@ -115,6 +119,7 @@ class LottieState extends EWidgetState<EnsembleLottie>
// 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 = '''
<html>
<body>
Expand All @@ -127,6 +132,7 @@ class LottieState extends EWidgetState<EnsembleLottie>
<script type="text/javascript">
let direction = 1; // Variable to define the direction ie to run animation forward or backward
let player_$divId = document.getElementById("$divId");
const parentOrigin = $parentOriginLiteral;
// A counter variable which increments upon each event and thus making each event unique and allowing to segregate from old events
let counter = 0;
player_$divId.load("$source");
Expand All @@ -135,6 +141,9 @@ class LottieState extends EWidgetState<EnsembleLottie>

// Function to handle all the messages that are received from dart to js
function handleMessage(e) {
if (e.origin !== parentOrigin) {
return;
}
var data = e.data;
if (data == "forward_$divId") {
direction = 1;
Expand All @@ -149,7 +158,7 @@ class LottieState extends EWidgetState<EnsembleLottie>
}
if (data == "stop_$divId") {
player_$divId.pause();
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', "*");
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
counter++;
}
if (data == "reset_$divId") {
Expand All @@ -158,20 +167,20 @@ class LottieState extends EWidgetState<EnsembleLottie>
}
// Event Listener for specific actions for animation like onComplete, onStart, onLoad and so on
player_$divId.addEventListener("play", () => {
if (direction == 1) window.parent.postMessage('{"data": "onForward", "id": ' + counter + ', "tag": "$divId"}', "*");
else window.parent.postMessage('{"data": "onReverse", "id": ' + counter + ', "tag": "$divId"}', "*");
if (direction == 1) window.parent.postMessage('{"data": "onForward", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
else window.parent.postMessage('{"data": "onReverse", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
counter++;
});
player_$divId.addEventListener("complete", () => {
window.parent.postMessage('{"data": "onComplete", "id": ' + counter + ', "tag": "$divId"}', "*");
window.parent.postMessage('{"data": "onComplete", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
counter++;
});
player_$divId.addEventListener("pause", () => {
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', "*");
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
counter++;
});
player_$divId.addEventListener("stop", () => {
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', "*");
window.parent.postMessage('{"data": "onStop", "id": ' + counter + ', "tag": "$divId"}', parentOrigin);
counter++;
});
</script>
Expand All @@ -188,13 +197,17 @@ class LottieState extends EWidgetState<EnsembleLottie>
// 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) {
Expand Down Expand Up @@ -228,7 +241,6 @@ class LottieState extends EWidgetState<EnsembleLottie>
event: EnsembleEvent(widget),
);
}
}
}
},
);
Expand Down
69 changes: 69 additions & 0 deletions modules/ensemble/test/lottie_post_message_test.dart
Original file line number Diff line number Diff line change
@@ -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"',
);
});
});
}
Loading