Skip to content

Commit 1718c91

Browse files
fix(security): encode Address widget Places proxy query parameters
User-controlled autocomplete input was interpolated into a raw URL string, allowing attackers to inject extra query parameters (for example to override country filters). Build proxy URLs with Uri.https and add regression tests. Co-authored-by: Sharjeel Yunus <sharjeelyunus@users.noreply.github.com>
1 parent 4031a3a commit 1718c91

3 files changed

Lines changed: 86 additions & 19 deletions

File tree

modules/ensemble/lib/widget/address.dart

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'package:ensemble_ts_interpreter/invokables/invokable.dart';
1212
import 'package:flutter/material.dart';
1313
import 'package:flutter/scheduler.dart';
1414
import 'package:get_it/get_it.dart';
15+
import 'package:ensemble/widget/address_url_builder.dart';
1516
import 'package:http/http.dart' as http;
1617

1718
class Address extends StatefulWidget
@@ -103,22 +104,13 @@ class AddressState extends EWidgetState<Address> {
103104
latitude: widget._controller.getLastLocation()!.latitude,
104105
longitude: widget._controller.getLastLocation()!.longitude);
105106
}
106-
String locationBiasStr = '';
107-
if (center != null) {
108-
locationBiasStr =
109-
'&locationbias=circle:${widget._controller.proximitySearchRadius ?? 20000}@${center.latitude},${center.longitude}';
110-
}
111-
112-
// filter by country
113-
String countryFilterStr = '';
114-
if (widget._controller._countryFilter?.isNotEmpty ?? false) {
115-
countryFilterStr =
116-
'&components=${widget._controller._countryFilter!.map((e) => 'country:$e').join('|')}';
117-
}
118-
119-
var url =
120-
'https://services-googleplacesautocomplete-2czdl2akpq-uc.a.run.app?input=$query$locationBiasStr$countryFilterStr';
121-
var response = await http.get(Uri.parse(url));
107+
final uri = buildPlacesAutocompleteUri(
108+
input: query,
109+
center: center,
110+
proximityRadiusMeters: widget._controller.proximitySearchRadius,
111+
countryFilter: widget._controller._countryFilter,
112+
);
113+
var response = await http.get(uri);
122114
if (response.statusCode == 200) {
123115
var jsonResponse = json.decode(response.body);
124116
if (jsonResponse['error_message'] != null) {
@@ -137,9 +129,7 @@ class AddressState extends EWidgetState<Address> {
137129
}
138130

139131
Future<Place> _getPlaceDetail(PlaceSummary placeSummary) async {
140-
var url =
141-
'https://services-googleplacesdetail-2czdl2akpq-uc.a.run.app?placeId=${placeSummary.placeId}';
142-
var response = await http.get(Uri.parse(url));
132+
var response = await http.get(buildPlacesDetailUri(placeSummary.placeId));
143133
if (response.statusCode == 200) {
144134
var jsonResponse = json.decode(response.body);
145135
if (jsonResponse['error_message'] == null) {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'package:ensemble/framework/stub/location_manager.dart';
2+
import 'package:meta/meta.dart';
3+
4+
const _placesAutocompleteHost =
5+
'services-googleplacesautocomplete-2czdl2akpq-uc.a.run.app';
6+
const _placesDetailHost =
7+
'services-googleplacesdetail-2czdl2akpq-uc.a.run.app';
8+
9+
/// Builds the Google Places autocomplete proxy URL with encoded query parameters
10+
/// so user-controlled [input] cannot inject additional `&key=value` pairs.
11+
@visibleForTesting
12+
Uri buildPlacesAutocompleteUri({
13+
required String input,
14+
LocationData? center,
15+
int? proximityRadiusMeters,
16+
List<String>? countryFilter,
17+
}) {
18+
final queryParameters = <String, String>{'input': input};
19+
if (center != null) {
20+
final radius = proximityRadiusMeters ?? 20000;
21+
queryParameters['locationbias'] =
22+
'circle:$radius@${center.latitude},${center.longitude}';
23+
}
24+
if (countryFilter != null && countryFilter.isNotEmpty) {
25+
queryParameters['components'] =
26+
countryFilter.map((code) => 'country:$code').join('|');
27+
}
28+
return Uri.https(_placesAutocompleteHost, '/', queryParameters);
29+
}
30+
31+
/// Builds the Google Places detail proxy URL with an encoded [placeId].
32+
@visibleForTesting
33+
Uri buildPlacesDetailUri(String placeId) {
34+
return Uri.https(_placesDetailHost, '/', {'placeId': placeId});
35+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import 'package:ensemble/framework/stub/location_manager.dart';
2+
import 'package:ensemble/widget/address_url_builder.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
5+
void main() {
6+
group('buildPlacesAutocompleteUri', () {
7+
test('percent-encodes user input so extra query params cannot be injected',
8+
() {
9+
const maliciousInput = 'foo&components=country:US';
10+
final uri = buildPlacesAutocompleteUri(
11+
input: maliciousInput,
12+
countryFilter: const ['CA'],
13+
);
14+
15+
expect(uri.queryParameters['input'], maliciousInput);
16+
expect(uri.queryParameters['components'], 'country:CA');
17+
expect(uri.queryParameters.containsKey('country:US'), isFalse);
18+
expect(uri.query, contains('input=foo%26components%3Dcountry%3AUS'));
19+
});
20+
21+
test('includes location bias when center is provided', () {
22+
final uri = buildPlacesAutocompleteUri(
23+
input: 'Paris',
24+
center: LocationData(latitude: 48.8566, longitude: 2.3522),
25+
proximityRadiusMeters: 15000,
26+
);
27+
28+
expect(uri.queryParameters['locationbias'],
29+
'circle:15000@48.8566,2.3522');
30+
});
31+
});
32+
33+
group('buildPlacesDetailUri', () {
34+
test('encodes placeId query value', () {
35+
const placeId = 'id&injected=1';
36+
final uri = buildPlacesDetailUri(placeId);
37+
38+
expect(uri.queryParameters['placeId'], placeId);
39+
expect(uri.query, contains('placeId=id%26injected%3D1'));
40+
});
41+
});
42+
}

0 commit comments

Comments
 (0)