Skip to content

Commit 563ef82

Browse files
🛡️ Sentinel: Fix Email Content Injection in mailto links (#2141)
🛡️ Sentinel: [MEDIUM] Fix Email Content Injection 🚨 Severity: MEDIUM 💡 Vulnerability: Email Content Injection in `mailto` links. 🎯 Impact: An attacker could inject unintended headers (like CC, BCC) or modify the body/subject if the input (e.g., subject) contained special characters like `&` or `=`. 🔧 Fix: Use `encodeQueryParameters` method, which automatically and correctly handles percent-encoding of query parameters. ✅ Verification: Added `lib/url_launcher_extended/test/url_launcher_extended_test.dart` which tries to inject a `cc` parameter via the `subject` field and asserts that it is correctly encoded as part of the subject rather than interpreted as a new parameter. --- *PR created automatically by Jules for task [3007356946123536916](https://jules.google.com/task/3007356946123536916) started by @nilsreichardt* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: nilsreichardt <24459435+nilsreichardt@users.noreply.github.com>
1 parent 4922f0c commit 563ef82

2 files changed

Lines changed: 117 additions & 9 deletions

File tree

lib/url_launcher_extended/lib/src/url_launcher_extended.dart

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ class UrlLauncherExtended {
8888
return url_launcher.canLaunchUrl(url);
8989
}
9090

91+
/// Encodes the given parameters into a query string.
92+
///
93+
/// [Uri.queryParameters] constructor should be avoid due to [a
94+
/// bug](https://github.com/dart-lang/sdk/issues/43838) in Dart. See
95+
/// https://pub.dev/packages/url_launcher#encoding-urls.
96+
String? _encodeQueryParameters(Map<String, String> params) {
97+
return params.entries
98+
.map(
99+
(entry) =>
100+
'${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value)}',
101+
)
102+
.join('&');
103+
}
104+
91105
/// Create email draft to the default email app by converting the parameters
92106
/// into an uri, like
93107
/// "mailto:smith@example.com?subject=Example+Subject+%26+Symbols+are+allowed%21"
@@ -107,16 +121,15 @@ class UrlLauncherExtended {
107121
String? subject,
108122
String? body,
109123
}) async {
110-
String url = 'mailto:$address';
111-
if (subject != null) {
112-
url += '?subject=$subject';
113-
}
114-
if (body != null) {
115-
url += subject == null ? '?' : '&';
116-
url += 'body=$body';
117-
}
124+
final uri = Uri(
125+
scheme: 'mailto',
126+
path: address,
127+
query: _encodeQueryParameters({
128+
if (subject != null) 'subject': subject,
129+
if (body != null) 'body': body,
130+
}),
131+
);
118132

119-
final uri = Uri.parse(url);
120133
final canLaunch = await canLaunchUrl(uri);
121134
if (!canLaunch) {
122135
throw CouldNotLaunchMailException(address, subject: subject, body: body);
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 Sharezone UG (haftungsbeschränkt)
2+
// Licensed under the EUPL-1.2-or-later.
3+
//
4+
// You may obtain a copy of the Licence at:
5+
// https://joinup.ec.europa.eu/software/page/eupl
6+
//
7+
// SPDX-License-Identifier: EUPL-1.2
8+
9+
import 'package:flutter_test/flutter_test.dart';
10+
import 'package:url_launcher_extended/src/url_launcher_extended.dart';
11+
import 'package:url_launcher/url_launcher.dart';
12+
13+
class TestUrlLauncherExtended extends UrlLauncherExtended {
14+
Uri? lastLaunchedUri;
15+
16+
@override
17+
Future<bool> launchUrl(
18+
Uri url, {
19+
LaunchMode mode = LaunchMode.platformDefault,
20+
WebViewConfiguration webViewConfiguration = const WebViewConfiguration(),
21+
String? webOnlyWindowName,
22+
}) async {
23+
lastLaunchedUri = url;
24+
return true;
25+
}
26+
27+
@override
28+
Future<bool> canLaunchUrl(Uri url) async {
29+
return true;
30+
}
31+
}
32+
33+
void main() {
34+
test('Security: prevents parameter injection in mailto links', () async {
35+
final launcher = TestUrlLauncherExtended();
36+
37+
// Malicious subject attempting to inject a CC header
38+
const subject = 'Hello&cc=hacker@example.com';
39+
40+
await launcher.tryLaunchMailOrThrow(
41+
'user@example.com',
42+
subject: subject,
43+
body: 'Body',
44+
);
45+
46+
final uri = launcher.lastLaunchedUri!;
47+
48+
// The injected parameters should NOT be parsed as separate query keys.
49+
expect(
50+
uri.queryParameters.containsKey('cc'),
51+
isFalse,
52+
reason: 'CC parameter was injected',
53+
);
54+
55+
// The subject should contain the full string (properly encoded in the URI string)
56+
expect(uri.queryParameters['subject'], equals(subject));
57+
58+
// Verify string representation contains encoded characters
59+
// & should be %26, = should be %3D
60+
expect(
61+
uri.toString(),
62+
contains('subject=Hello%26cc%3Dhacker%40example.com'),
63+
);
64+
});
65+
66+
test('Normal usage with body & subject works correctly', () async {
67+
final launcher = TestUrlLauncherExtended();
68+
69+
await launcher.tryLaunchMailOrThrow(
70+
'user@example.com',
71+
subject: 'Hello World',
72+
body: 'This is a body',
73+
);
74+
75+
final uri = launcher.lastLaunchedUri!;
76+
77+
expect(uri.scheme, equals('mailto'));
78+
expect(uri.path, equals('user@example.com'));
79+
expect(uri.queryParameters['subject'], equals('Hello World'));
80+
expect(uri.queryParameters['body'], equals('This is a body'));
81+
});
82+
83+
test('Normal usage with only subject works correctly', () async {
84+
final launcher = TestUrlLauncherExtended();
85+
86+
await launcher.tryLaunchMailOrThrow('user@example.com');
87+
88+
final uri = launcher.lastLaunchedUri!;
89+
90+
expect(uri.scheme, equals('mailto'));
91+
expect(uri.path, equals('user@example.com'));
92+
expect(uri.queryParameters['subject'], isNull);
93+
expect(uri.queryParameters['body'], isNull);
94+
});
95+
}

0 commit comments

Comments
 (0)