diff --git a/pkg/image_proxy/lib/image_proxy_service.dart b/pkg/image_proxy/lib/image_proxy_service.dart index 5ae8c114fc..d765efae78 100644 --- a/pkg/image_proxy/lib/image_proxy_service.dart +++ b/pkg/image_proxy/lib/image_proxy_service.dart @@ -23,6 +23,16 @@ Duration timeoutDelay = Duration(seconds: isTesting ? 1 : 8); /// The keys we currently allow the url to be signed with. Map allowedKeys = {}; +// Inspired by https://github.com/atmos/camo/blob/master/server.coffee#L39. +Map securityHeaders = { + 'X-Frame-Options': 'deny', + 'X-XSS-Protection': '1; mode=block', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': + "default-src 'none'; img-src data:; style-src 'unsafe-inline'", + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', +}; + /// Ensure that [allowedKeys] contains keys for today and the two surrounding /// days. Future updateAllowedKeys() async { @@ -104,13 +114,17 @@ final maxImageSize = 1024 * 1024 * 10; // At most 10 MB. Future handler(shelf.Request request) async { try { if (request.method != 'GET') { - return shelf.Response.notFound('Unsupported method'); + return shelf.Response.notFound( + 'Unsupported method', + headers: securityHeaders, + ); } final segments = request.url.pathSegments; if (segments.length != 3) { return shelf.Response.badRequest( body: 'malformed request, ${segments.length} should be of the form //', + headers: securityHeaders, ); } final Uint8List signature; @@ -119,22 +133,30 @@ Future handler(shelf.Request request) async { } on FormatException catch (_) { return shelf.Response.badRequest( body: 'malformed request, could not decode mac signature', + headers: securityHeaders, ); } final date = int.tryParse(segments[1]); if (date == null) { - return shelf.Response.badRequest(body: 'malformed request, missing date'); + return shelf.Response.badRequest( + body: 'malformed request, missing date', + headers: securityHeaders, + ); } final secret = allowedKeys[date]; if (secret == null) { return shelf.Response.badRequest( body: 'malformed request, proxy url expired', + headers: securityHeaders, ); } final imageUrl = segments[2]; if (imageUrl.length > 1024) { - return shelf.Response.badRequest(body: 'proxied url too long'); + return shelf.Response.badRequest( + body: 'proxied url too long', + headers: securityHeaders, + ); } final imageUrlBytes = utf8.encode(imageUrl); @@ -143,16 +165,23 @@ Future handler(shelf.Request request) async { try { parsedImageUrl = Uri.parse(imageUrl); } on FormatException catch (e) { - return shelf.Response.badRequest(body: 'Malformed proxied url $e'); + return shelf.Response.badRequest( + body: 'Malformed proxied url $e', + headers: securityHeaders, + ); } if (!(parsedImageUrl.isScheme('http') || parsedImageUrl.isScheme('https'))) { return shelf.Response.badRequest( body: 'Can only proxy http and https urls', + headers: securityHeaders, ); } if (!parsedImageUrl.isAbsolute) { - return shelf.Response.badRequest(body: 'Can only proxy absolute urls'); + return shelf.Response.badRequest( + body: 'Can only proxy absolute urls', + headers: securityHeaders, + ); } int statusCode; @@ -233,14 +262,24 @@ Future handler(shelf.Request request) async { e is ServerSideException, ); } on TooLargeException { - return shelf.Response.badRequest(body: 'Image too large'); + return shelf.Response.badRequest( + body: 'Image too large', + headers: securityHeaders, + ); } on RedirectException catch (e) { - return shelf.Response.badRequest(body: e.message); + return shelf.Response.badRequest( + body: e.message, + headers: securityHeaders, + ); } on RequestTimeoutException catch (e) { - return shelf.Response.badRequest(body: e.message); + return shelf.Response.badRequest( + body: e.message, + headers: securityHeaders, + ); } on ServerSideException catch (e) { return shelf.Response.badRequest( body: 'Failed to retrieve image. Status code ${e.statusCode}', + headers: securityHeaders, ); } @@ -251,10 +290,11 @@ Future handler(shelf.Request request) async { 'Cache-control': 'max-age=180, public', 'content-type': ?contentType, 'content-encoding': ?contentEncoding, + ...securityHeaders, }, ); } else { - return shelf.Response.unauthorized('Bad hmac'); + return shelf.Response.unauthorized('Bad hmac', headers: securityHeaders); } } catch (e, st) { stderr.writeln('Uncaught error: $e $st'); diff --git a/pkg/image_proxy/test/image_proxy_test.dart b/pkg/image_proxy/test/image_proxy_test.dart index 11334fe9f2..83f38b643c 100644 --- a/pkg/image_proxy/test/image_proxy_test.dart +++ b/pkg/image_proxy/test/image_proxy_test.dart @@ -35,6 +35,12 @@ Stream> infiniteStream() async* { } } +void validateSecurityHeaders(HttpClientResponse response) { + for (final header in securityHeaders.entries) { + expect(response.headers[header.key], [header.value]); + } +} + Future startImageServer() async { var i = 0; final server = await shelf_io.serve( @@ -175,10 +181,10 @@ Future main() async { imageProxyPort: imageProxyPort, imageServerPort: imageServerPort, ); + validateSecurityHeaders(response); expect(response.statusCode, 200); expect(response.headers['content-type']!.single, 'image/jpeg'); expect(response.headers['cache-control']!.single, 'max-age=180, public'); - final hash = await sha256.bind(response).single; final expected = sha256.convert(File(jpgImagePath).readAsBytesSync()); expect(hash, expected); @@ -191,6 +197,7 @@ Future main() async { imageServerPort: imageServerPort, pathToImage: 'path/to/image.png', ); + validateSecurityHeaders(response); expect(response.statusCode, 200); expect(response.headers['content-type']!.single, 'image/png'); final hash = await sha256.bind(response).single; @@ -205,6 +212,7 @@ Future main() async { imageServerPort: imageServerPort, pathToImage: 'path/to/image.svg', ); + validateSecurityHeaders(response); expect(response.statusCode, 200); expect(response.headers['content-type']!.single, 'image/svg+xml'); final hash = await sha256.bind(response).single; @@ -220,6 +228,7 @@ Future main() async { // Gives no content-length pathToImage: 'okstreaming', ); + validateSecurityHeaders(response); expect(response.statusCode, 200); expect(response.headers['content-type']!.single, 'image/jpeg'); final jpgFile = File(jpgImagePath).readAsBytesSync(); @@ -239,6 +248,7 @@ Future main() async { imageServerPort: imageServerPort, day: tomorrow.add(Duration(days: 1)), ); + validateSecurityHeaders(response); expect(response.statusCode, 400); expect( @@ -258,6 +268,7 @@ Future main() async { day: today, disturbSignature: true, ); + validateSecurityHeaders(response); expect(response.statusCode, 401); expect(await Utf8Codec().decodeStream(response), 'Bad hmac'); @@ -275,6 +286,7 @@ Future main() async { disturbSignature: true, pathToImage: 'next/' * 1000 + 'image.jpg', ); + validateSecurityHeaders(response); expect(response.statusCode, 400); expect(await Utf8Codec().decodeStream(response), 'proxied url too long'); @@ -291,6 +303,7 @@ Future main() async { day: today, pathToImage: 'redirect', ); + validateSecurityHeaders(response); expect(response.statusCode, 200); final hash = await sha256.bind(response).single; @@ -309,6 +322,7 @@ Future main() async { day: today, pathToImage: 'redirectForever', ); + validateSecurityHeaders(response); expect(await Utf8Codec().decodeStream(response), 'Too many redirects.'); expect(response.statusCode, 400); @@ -325,6 +339,7 @@ Future main() async { day: today, pathToImage: 'serverError', ); + validateSecurityHeaders(response); expect( await Utf8Codec().decodeStream(response), @@ -344,6 +359,7 @@ Future main() async { day: today, pathToImage: 'doesntexist', ); + validateSecurityHeaders(response); expect(await Utf8Codec().decodeStream(response), 'Not found'); expect(response.statusCode, 404); @@ -360,6 +376,7 @@ Future main() async { day: today, pathToImage: 'worksSecondTime', ); + validateSecurityHeaders(response); expect(response.statusCode, 200); final hash = await sha256.bind(response).single; @@ -378,6 +395,7 @@ Future main() async { day: today, pathToImage: 'canBeCachedLong', ); + validateSecurityHeaders(response); expect(response.statusCode, 200); // The proxy doesn't cache as long time as the original. @@ -398,6 +416,7 @@ Future main() async { day: today, pathToImage: 'timeout', ); + validateSecurityHeaders(response); expect(response.statusCode, 400); // The proxy doesn't cache as long time as the original. @@ -410,6 +429,7 @@ Future main() async { day: today, pathToImage: 'timeoutstreaming', ); + validateSecurityHeaders(response); expect(response.statusCode, 400); // The proxy doesn't cache as long time as the original. @@ -427,6 +447,7 @@ Future main() async { day: today, pathToImage: 'toobig', ); + validateSecurityHeaders(response); expect(response.statusCode, 400); // The proxy doesn't cache as long time as the original. @@ -439,6 +460,7 @@ Future main() async { day: today, pathToImage: 'toobigstreaming', ); + validateSecurityHeaders(response); expect(response.statusCode, 400); // The proxy doesn't cache as long time as the original.