Skip to content

Commit 7113f81

Browse files
committed
timeouts, test for large files
1 parent 1ca50fc commit 7113f81

File tree

2 files changed

+152
-12
lines changed

2 files changed

+152
-12
lines changed

pkg/image_proxy/lib/image_proxy_service.dart

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,20 @@ import 'dart:async';
66
import 'dart:convert';
77
import 'dart:io';
88
import 'dart:typed_data';
9+
910
import 'package:crypto/crypto.dart';
11+
import 'package:googleapis/cloudkms/v1.dart' as kms;
12+
import 'package:googleapis_auth/auth_io.dart' as auth;
1013
import 'package:googleapis_auth/googleapis_auth.dart';
1114
import 'package:http/http.dart' as http;
12-
1315
import 'package:retry/retry.dart';
1416
import 'package:shelf/shelf.dart' as shelf;
1517
import 'package:shelf/shelf_io.dart';
16-
import 'package:googleapis/cloudkms/v1.dart' as kms;
17-
import 'package:googleapis_auth/auth_io.dart' as auth;
18-
19-
class Config {
20-
final Uri secretsUrl;
21-
final int maxFileSize;
22-
Config({required this.secretsUrl, required this.maxFileSize});
23-
}
2418

2519
bool isTesting = Platform.environment['IMAGE_PROXY_TESTING'] == 'true';
2620

21+
Duration timeoutDelay = Duration(seconds: isTesting ? 1 : 8);
22+
2723
/// The keys we currently allow the url to be signed with.
2824
Map<int, Uint8List> allowedKeys = {};
2925

@@ -98,7 +94,10 @@ bool _constantTimeEquals(Uint8List a, Uint8List b) {
9894

9995
// The client used for requesting the images.
10096
// Using raw dart:io client such that we can disable autoUncompress.
101-
final HttpClient client = HttpClient()..autoUncompress = false;
97+
final HttpClient client = HttpClient()
98+
..autoUncompress = false
99+
..connectionTimeout = Duration(seconds: 10)
100+
..idleTimeout = Duration(seconds: 15);
102101

103102
final maxImageSize = 1024 * 1024 * 10; // At most 10 MB.
104103

@@ -162,12 +161,21 @@ Future<shelf.Response> handler(shelf.Request request) async {
162161
String? contentEncoding;
163162
try {
164163
(statusCode, bytes, contentType, contentEncoding) = await retry(
165-
maxDelay: isTesting ? Duration(seconds: 1) : Duration(seconds: 8),
164+
maxDelay: timeoutDelay,
166165
maxAttempts: isTesting ? 2 : 8,
167166
() async {
168167
final request = await client.getUrl(parsedImageUrl);
168+
Timer? timeoutTimer;
169+
void scheduleRequestTimeout() {
170+
timeoutTimer?.cancel();
171+
timeoutTimer = Timer(timeoutDelay, () {
172+
request.abort(RequestTimeoutException('No response'));
173+
});
174+
}
175+
169176
request.headers.add('user-agent', 'pub-proxy');
170177
request.followRedirects = false;
178+
scheduleRequestTimeout();
171179
var response = await request.close();
172180
var redirectCount = 0;
173181
while (response.isRedirect) {
@@ -184,9 +192,11 @@ Future<shelf.Response> handler(shelf.Request request) async {
184192
}
185193
final uri = parsedImageUrl.resolve(location);
186194
final request = await client.getUrl(uri);
195+
187196
request.headers.add('user-agent', 'pub-proxy');
188197
// Set the body or headers as desired.
189198
request.followRedirects = false;
199+
scheduleRequestTimeout();
190200
response = await request.close();
191201
}
192202
switch (response.statusCode) {
@@ -204,6 +214,11 @@ Future<shelf.Response> handler(shelf.Request request) async {
204214
await readAllBytes(
205215
response,
206216
contentLength == -1 ? maxImageSize : contentLength,
217+
).timeout(
218+
timeoutDelay,
219+
onTimeout: () {
220+
throw RequestTimeoutException('No response');
221+
},
207222
),
208223
response.headers.value('content-type'),
209224
response.headers.value('content-encoding'),
@@ -218,6 +233,8 @@ Future<shelf.Response> handler(shelf.Request request) async {
218233
return shelf.Response.badRequest(body: 'Image too large');
219234
} on RedirectException catch (e) {
220235
return shelf.Response.badRequest(body: e.message);
236+
} on RequestTimeoutException catch (e) {
237+
return shelf.Response.badRequest(body: e.message);
221238
} on ServerSideException catch (e) {
222239
return shelf.Response.badRequest(
223240
body: 'Failed to retrieve image. Status code ${e.statusCode}',
@@ -267,6 +284,11 @@ class RedirectException implements Exception {
267284
RedirectException(this.message);
268285
}
269286

287+
class RequestTimeoutException implements Exception {
288+
String message;
289+
RequestTimeoutException(this.message);
290+
}
291+
270292
Uint8List hmacSign(Uint8List key, Uint8List imageUrlBytes) {
271293
return Hmac(sha256, key).convert(imageUrlBytes).bytes as Uint8List;
272294
}

pkg/image_proxy/test/image_proxy_test.dart

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:async';
56
import 'dart:convert';
67
import 'dart:io';
78

@@ -28,10 +29,16 @@ Future<int> startImageProxy() async {
2829
return port!;
2930
}
3031

32+
Stream<List<int>> infiniteStream() async* {
33+
while (true) {
34+
yield List.generate(1000, (i) => 0);
35+
}
36+
}
37+
3138
Future<int> startImageServer() async {
3239
var i = 0;
3340
final server = await shelf_io.serve(
34-
(shelf.Request request) {
41+
(shelf.Request request) async {
3542
switch (request.url.path) {
3643
case 'path/to/image.jpg':
3744
return shelf.Response.ok(
@@ -73,6 +80,42 @@ Future<int> startImageServer() async {
7380
File(jpgImagePath).readAsBytesSync(),
7481
headers: {'content-type': 'image/jpeg'},
7582
);
83+
case 'timeout':
84+
await Future.delayed(Duration(hours: 1));
85+
return shelf.Response.notFound('Not found');
86+
case 'timeoutstreaming':
87+
late final StreamController lateStreamController;
88+
lateStreamController = StreamController(
89+
onListen: () async {
90+
// Return a single byte, and then stall.
91+
lateStreamController.add([1]);
92+
await Future.delayed(Duration(hours: 1));
93+
await lateStreamController.close();
94+
},
95+
);
96+
return shelf.Response.ok(
97+
lateStreamController.stream,
98+
headers: {'content-type': 'image/jpeg'},
99+
);
100+
case 'okstreaming':
101+
return shelf.Response.ok(
102+
// Has no content-length
103+
File(jpgImagePath).openRead(),
104+
headers: {'content-type': 'image/jpeg'},
105+
);
106+
case 'toobig':
107+
return shelf.Response.ok(
108+
infiniteStream(),
109+
headers: {
110+
'content-type': 'image/jpeg',
111+
'content-length': '100000000',
112+
},
113+
);
114+
case 'toobigstreaming':
115+
return shelf.Response.ok(
116+
infiniteStream(),
117+
headers: {'content-type': 'image/jpeg'},
118+
);
76119
default:
77120
return shelf.Response.notFound('Not found');
78121
}
@@ -168,6 +211,23 @@ Future<void> main() async {
168211
final expected = sha256.convert(File(svgImagePath).readAsBytesSync());
169212
expect(hash, expected);
170213
}
214+
215+
{
216+
final response = await getImage(
217+
day: today,
218+
imageProxyPort: imageProxyPort,
219+
imageServerPort: imageServerPort,
220+
// Gives no content-length
221+
pathToImage: 'okstreaming',
222+
);
223+
expect(response.statusCode, 200);
224+
expect(response.headers['content-type']!.single, 'image/jpeg');
225+
final jpgFile = File(jpgImagePath).readAsBytesSync();
226+
expect(response.contentLength, jpgFile.length);
227+
final hash = await sha256.bind(response).single;
228+
final expected = sha256.convert(jpgFile);
229+
expect(hash, expected);
230+
}
171231
});
172232

173233
test('Fails on days outside recent range', () async {
@@ -327,4 +387,62 @@ Future<void> main() async {
327387
expect(hash, expected);
328388
}
329389
});
390+
391+
test('times out', () async {
392+
final imageProxyPort = await startImageProxy();
393+
final imageServerPort = await startImageServer();
394+
{
395+
final response = await getImage(
396+
imageProxyPort: imageProxyPort,
397+
imageServerPort: imageServerPort,
398+
day: today,
399+
pathToImage: 'timeout',
400+
);
401+
402+
expect(response.statusCode, 400);
403+
// The proxy doesn't cache as long time as the original.
404+
expect(await Utf8Codec().decodeStream(response), 'No response');
405+
}
406+
{
407+
final response = await getImage(
408+
imageProxyPort: imageProxyPort,
409+
imageServerPort: imageServerPort,
410+
day: today,
411+
pathToImage: 'timeoutstreaming',
412+
);
413+
414+
expect(response.statusCode, 400);
415+
// The proxy doesn't cache as long time as the original.
416+
expect(await Utf8Codec().decodeStream(response), 'No response');
417+
}
418+
});
419+
420+
test('protects against too big files', () async {
421+
final imageProxyPort = await startImageProxy();
422+
final imageServerPort = await startImageServer();
423+
{
424+
final response = await getImage(
425+
imageProxyPort: imageProxyPort,
426+
imageServerPort: imageServerPort,
427+
day: today,
428+
pathToImage: 'toobig',
429+
);
430+
431+
expect(response.statusCode, 400);
432+
// The proxy doesn't cache as long time as the original.
433+
expect(await Utf8Codec().decodeStream(response), 'Image too large');
434+
}
435+
{
436+
final response = await getImage(
437+
imageProxyPort: imageProxyPort,
438+
imageServerPort: imageServerPort,
439+
day: today,
440+
pathToImage: 'toobigstreaming',
441+
);
442+
443+
expect(response.statusCode, 400);
444+
// The proxy doesn't cache as long time as the original.
445+
expect(await Utf8Codec().decodeStream(response), 'Image too large');
446+
}
447+
});
330448
}

0 commit comments

Comments
 (0)