Skip to content

Commit c5dabf8

Browse files
authored
Image proxy Add security headers to all responses (#9026)
1 parent 97c3983 commit c5dabf8

File tree

2 files changed

+72
-10
lines changed

2 files changed

+72
-10
lines changed

pkg/image_proxy/lib/image_proxy_service.dart

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ Duration timeoutDelay = Duration(seconds: isTesting ? 1 : 8);
2323
/// The keys we currently allow the url to be signed with.
2424
Map<int, Uint8List> allowedKeys = {};
2525

26+
// Inspired by https://github.com/atmos/camo/blob/master/server.coffee#L39.
27+
Map<String, String> securityHeaders = {
28+
'X-Frame-Options': 'deny',
29+
'X-XSS-Protection': '1; mode=block',
30+
'X-Content-Type-Options': 'nosniff',
31+
'Content-Security-Policy':
32+
"default-src 'none'; img-src data:; style-src 'unsafe-inline'",
33+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
34+
};
35+
2636
/// Ensure that [allowedKeys] contains keys for today and the two surrounding
2737
/// days.
2838
Future<void> updateAllowedKeys() async {
@@ -104,13 +114,17 @@ final maxImageSize = 1024 * 1024 * 10; // At most 10 MB.
104114
Future<shelf.Response> handler(shelf.Request request) async {
105115
try {
106116
if (request.method != 'GET') {
107-
return shelf.Response.notFound('Unsupported method');
117+
return shelf.Response.notFound(
118+
'Unsupported method',
119+
headers: securityHeaders,
120+
);
108121
}
109122
final segments = request.url.pathSegments;
110123
if (segments.length != 3) {
111124
return shelf.Response.badRequest(
112125
body:
113126
'malformed request, ${segments.length} should be of the form <base64(hmac(url,daily_secret))>/<date>/<urlencode(url)>',
127+
headers: securityHeaders,
114128
);
115129
}
116130
final Uint8List signature;
@@ -119,22 +133,30 @@ Future<shelf.Response> handler(shelf.Request request) async {
119133
} on FormatException catch (_) {
120134
return shelf.Response.badRequest(
121135
body: 'malformed request, could not decode mac signature',
136+
headers: securityHeaders,
122137
);
123138
}
124139
final date = int.tryParse(segments[1]);
125140
if (date == null) {
126-
return shelf.Response.badRequest(body: 'malformed request, missing date');
141+
return shelf.Response.badRequest(
142+
body: 'malformed request, missing date',
143+
headers: securityHeaders,
144+
);
127145
}
128146
final secret = allowedKeys[date];
129147
if (secret == null) {
130148
return shelf.Response.badRequest(
131149
body: 'malformed request, proxy url expired',
150+
headers: securityHeaders,
132151
);
133152
}
134153

135154
final imageUrl = segments[2];
136155
if (imageUrl.length > 1024) {
137-
return shelf.Response.badRequest(body: 'proxied url too long');
156+
return shelf.Response.badRequest(
157+
body: 'proxied url too long',
158+
headers: securityHeaders,
159+
);
138160
}
139161
final imageUrlBytes = utf8.encode(imageUrl);
140162

@@ -143,16 +165,23 @@ Future<shelf.Response> handler(shelf.Request request) async {
143165
try {
144166
parsedImageUrl = Uri.parse(imageUrl);
145167
} on FormatException catch (e) {
146-
return shelf.Response.badRequest(body: 'Malformed proxied url $e');
168+
return shelf.Response.badRequest(
169+
body: 'Malformed proxied url $e',
170+
headers: securityHeaders,
171+
);
147172
}
148173
if (!(parsedImageUrl.isScheme('http') ||
149174
parsedImageUrl.isScheme('https'))) {
150175
return shelf.Response.badRequest(
151176
body: 'Can only proxy http and https urls',
177+
headers: securityHeaders,
152178
);
153179
}
154180
if (!parsedImageUrl.isAbsolute) {
155-
return shelf.Response.badRequest(body: 'Can only proxy absolute urls');
181+
return shelf.Response.badRequest(
182+
body: 'Can only proxy absolute urls',
183+
headers: securityHeaders,
184+
);
156185
}
157186

158187
int statusCode;
@@ -233,14 +262,24 @@ Future<shelf.Response> handler(shelf.Request request) async {
233262
e is ServerSideException,
234263
);
235264
} on TooLargeException {
236-
return shelf.Response.badRequest(body: 'Image too large');
265+
return shelf.Response.badRequest(
266+
body: 'Image too large',
267+
headers: securityHeaders,
268+
);
237269
} on RedirectException catch (e) {
238-
return shelf.Response.badRequest(body: e.message);
270+
return shelf.Response.badRequest(
271+
body: e.message,
272+
headers: securityHeaders,
273+
);
239274
} on RequestTimeoutException catch (e) {
240-
return shelf.Response.badRequest(body: e.message);
275+
return shelf.Response.badRequest(
276+
body: e.message,
277+
headers: securityHeaders,
278+
);
241279
} on ServerSideException catch (e) {
242280
return shelf.Response.badRequest(
243281
body: 'Failed to retrieve image. Status code ${e.statusCode}',
282+
headers: securityHeaders,
244283
);
245284
}
246285

@@ -251,10 +290,11 @@ Future<shelf.Response> handler(shelf.Request request) async {
251290
'Cache-control': 'max-age=180, public',
252291
'content-type': ?contentType,
253292
'content-encoding': ?contentEncoding,
293+
...securityHeaders,
254294
},
255295
);
256296
} else {
257-
return shelf.Response.unauthorized('Bad hmac');
297+
return shelf.Response.unauthorized('Bad hmac', headers: securityHeaders);
258298
}
259299
} catch (e, st) {
260300
stderr.writeln('Uncaught error: $e $st');

pkg/image_proxy/test/image_proxy_test.dart

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ Stream<List<int>> infiniteStream() async* {
3535
}
3636
}
3737

38+
void validateSecurityHeaders(HttpClientResponse response) {
39+
for (final header in securityHeaders.entries) {
40+
expect(response.headers[header.key], [header.value]);
41+
}
42+
}
43+
3844
Future<int> startImageServer() async {
3945
var i = 0;
4046
final server = await shelf_io.serve(
@@ -175,10 +181,10 @@ Future<void> main() async {
175181
imageProxyPort: imageProxyPort,
176182
imageServerPort: imageServerPort,
177183
);
184+
validateSecurityHeaders(response);
178185
expect(response.statusCode, 200);
179186
expect(response.headers['content-type']!.single, 'image/jpeg');
180187
expect(response.headers['cache-control']!.single, 'max-age=180, public');
181-
182188
final hash = await sha256.bind(response).single;
183189
final expected = sha256.convert(File(jpgImagePath).readAsBytesSync());
184190
expect(hash, expected);
@@ -191,6 +197,7 @@ Future<void> main() async {
191197
imageServerPort: imageServerPort,
192198
pathToImage: 'path/to/image.png',
193199
);
200+
validateSecurityHeaders(response);
194201
expect(response.statusCode, 200);
195202
expect(response.headers['content-type']!.single, 'image/png');
196203
final hash = await sha256.bind(response).single;
@@ -205,6 +212,7 @@ Future<void> main() async {
205212
imageServerPort: imageServerPort,
206213
pathToImage: 'path/to/image.svg',
207214
);
215+
validateSecurityHeaders(response);
208216
expect(response.statusCode, 200);
209217
expect(response.headers['content-type']!.single, 'image/svg+xml');
210218
final hash = await sha256.bind(response).single;
@@ -220,6 +228,7 @@ Future<void> main() async {
220228
// Gives no content-length
221229
pathToImage: 'okstreaming',
222230
);
231+
validateSecurityHeaders(response);
223232
expect(response.statusCode, 200);
224233
expect(response.headers['content-type']!.single, 'image/jpeg');
225234
final jpgFile = File(jpgImagePath).readAsBytesSync();
@@ -239,6 +248,7 @@ Future<void> main() async {
239248
imageServerPort: imageServerPort,
240249
day: tomorrow.add(Duration(days: 1)),
241250
);
251+
validateSecurityHeaders(response);
242252
expect(response.statusCode, 400);
243253

244254
expect(
@@ -258,6 +268,7 @@ Future<void> main() async {
258268
day: today,
259269
disturbSignature: true,
260270
);
271+
validateSecurityHeaders(response);
261272
expect(response.statusCode, 401);
262273

263274
expect(await Utf8Codec().decodeStream(response), 'Bad hmac');
@@ -275,6 +286,7 @@ Future<void> main() async {
275286
disturbSignature: true,
276287
pathToImage: 'next/' * 1000 + 'image.jpg',
277288
);
289+
validateSecurityHeaders(response);
278290
expect(response.statusCode, 400);
279291

280292
expect(await Utf8Codec().decodeStream(response), 'proxied url too long');
@@ -291,6 +303,7 @@ Future<void> main() async {
291303
day: today,
292304
pathToImage: 'redirect',
293305
);
306+
validateSecurityHeaders(response);
294307

295308
expect(response.statusCode, 200);
296309
final hash = await sha256.bind(response).single;
@@ -309,6 +322,7 @@ Future<void> main() async {
309322
day: today,
310323
pathToImage: 'redirectForever',
311324
);
325+
validateSecurityHeaders(response);
312326

313327
expect(await Utf8Codec().decodeStream(response), 'Too many redirects.');
314328
expect(response.statusCode, 400);
@@ -325,6 +339,7 @@ Future<void> main() async {
325339
day: today,
326340
pathToImage: 'serverError',
327341
);
342+
validateSecurityHeaders(response);
328343

329344
expect(
330345
await Utf8Codec().decodeStream(response),
@@ -344,6 +359,7 @@ Future<void> main() async {
344359
day: today,
345360
pathToImage: 'doesntexist',
346361
);
362+
validateSecurityHeaders(response);
347363

348364
expect(await Utf8Codec().decodeStream(response), 'Not found');
349365
expect(response.statusCode, 404);
@@ -360,6 +376,7 @@ Future<void> main() async {
360376
day: today,
361377
pathToImage: 'worksSecondTime',
362378
);
379+
validateSecurityHeaders(response);
363380

364381
expect(response.statusCode, 200);
365382
final hash = await sha256.bind(response).single;
@@ -378,6 +395,7 @@ Future<void> main() async {
378395
day: today,
379396
pathToImage: 'canBeCachedLong',
380397
);
398+
validateSecurityHeaders(response);
381399

382400
expect(response.statusCode, 200);
383401
// The proxy doesn't cache as long time as the original.
@@ -398,6 +416,7 @@ Future<void> main() async {
398416
day: today,
399417
pathToImage: 'timeout',
400418
);
419+
validateSecurityHeaders(response);
401420

402421
expect(response.statusCode, 400);
403422
// The proxy doesn't cache as long time as the original.
@@ -410,6 +429,7 @@ Future<void> main() async {
410429
day: today,
411430
pathToImage: 'timeoutstreaming',
412431
);
432+
validateSecurityHeaders(response);
413433

414434
expect(response.statusCode, 400);
415435
// The proxy doesn't cache as long time as the original.
@@ -427,6 +447,7 @@ Future<void> main() async {
427447
day: today,
428448
pathToImage: 'toobig',
429449
);
450+
validateSecurityHeaders(response);
430451

431452
expect(response.statusCode, 400);
432453
// The proxy doesn't cache as long time as the original.
@@ -439,6 +460,7 @@ Future<void> main() async {
439460
day: today,
440461
pathToImage: 'toobigstreaming',
441462
);
463+
validateSecurityHeaders(response);
442464

443465
expect(response.statusCode, 400);
444466
// The proxy doesn't cache as long time as the original.

0 commit comments

Comments
 (0)