Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions pkg/image_proxy/lib/image_proxy_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ Duration timeoutDelay = Duration(seconds: isTesting ? 1 : 8);
/// The keys we currently allow the url to be signed with.
Map<int, Uint8List> allowedKeys = {};

// Inspired by https://github.com/atmos/camo/blob/master/server.coffee#L39.
Map<String, String> 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<void> updateAllowedKeys() async {
Expand Down Expand Up @@ -104,13 +114,17 @@ final maxImageSize = 1024 * 1024 * 10; // At most 10 MB.
Future<shelf.Response> 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 <base64(hmac(url,daily_secret))>/<date>/<urlencode(url)>',
headers: securityHeaders,
);
}
final Uint8List signature;
Expand All @@ -119,22 +133,30 @@ Future<shelf.Response> 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);

Expand All @@ -143,16 +165,23 @@ Future<shelf.Response> 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;
Expand Down Expand Up @@ -233,14 +262,24 @@ Future<shelf.Response> 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,
);
}

Expand All @@ -251,10 +290,11 @@ Future<shelf.Response> 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');
Expand Down
24 changes: 23 additions & 1 deletion pkg/image_proxy/test/image_proxy_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ Stream<List<int>> infiniteStream() async* {
}
}

void validateSecurityHeaders(HttpClientResponse response) {
for (final header in securityHeaders.entries) {
expect(response.headers[header.key], [header.value]);
}
}

Future<int> startImageServer() async {
var i = 0;
final server = await shelf_io.serve(
Expand Down Expand Up @@ -175,10 +181,10 @@ Future<void> 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);
Expand All @@ -191,6 +197,7 @@ Future<void> 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;
Expand All @@ -205,6 +212,7 @@ Future<void> 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;
Expand All @@ -220,6 +228,7 @@ Future<void> 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();
Expand All @@ -239,6 +248,7 @@ Future<void> main() async {
imageServerPort: imageServerPort,
day: tomorrow.add(Duration(days: 1)),
);
validateSecurityHeaders(response);
expect(response.statusCode, 400);

expect(
Expand All @@ -258,6 +268,7 @@ Future<void> main() async {
day: today,
disturbSignature: true,
);
validateSecurityHeaders(response);
expect(response.statusCode, 401);

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

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

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

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

expect(
await Utf8Codec().decodeStream(response),
Expand All @@ -344,6 +359,7 @@ Future<void> main() async {
day: today,
pathToImage: 'doesntexist',
);
validateSecurityHeaders(response);

expect(await Utf8Codec().decodeStream(response), 'Not found');
expect(response.statusCode, 404);
Expand All @@ -360,6 +376,7 @@ Future<void> main() async {
day: today,
pathToImage: 'worksSecondTime',
);
validateSecurityHeaders(response);

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

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

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

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

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

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