Elevate Your Flutter Web Hosting with Dart_Frog
Everyone who worked with Flutter Web in a production environment knows this Problem. After a ten-minute deployment, you open the web app and you see a white screen. Then you start to investigate and your Browser tells you the following:
This can be very frustrating especially if you start whitelisting scripts to your Content Security Policy and those scripts are requesting resources from somewhere else.
So what's the solution for this?
Content Security Policy errors can only be solved by the web server which is hosting the Web App. There are probably multiple solutions to tackle this problem, but today we are going to take a deep dive into dart_frog
and how to utilize it to host our Flutter Web App with some advanced capabilities.
Key Topics
Note: Content Security Policy will be called CSP in this Blog Post
Serve a Flutter Web App with
dart_frog
Set all recommended security headers with
shelf_helmet
Create a Content Security Policy that works with CSP2 and 3
Create Hashes for
inline-scripts
to whitelist them in our Content Security Policy
Setting everything up
We'll start by creating a brand new dart_frog project
dart_frog create hash_demo
We can then open our pubpsec.yaml
and replace the contents with
name: hash_demo
description: An example of how to use the CSP Hasher package in combination with shelf_helmet.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
csp_hasher: ^1.0.0
dart_frog: ^1.0.0
path: ^1.8.3
shelf_helmet: ^2.1.1
dev_dependencies:
mocktail: ^0.3.0
test: ^1.19.2
very_good_analysis: ^5.0.0
Now we have to create our Flutter project with:flutter create counter --platform web
Build and copy the Flutter App
To build our Flutter App we run in counter
:
flutter build web --web-renderer canvaskit --release --csp
After the build, we can copy the output from counter/build/web
to public/
To simplify this you can run from hash_demo
:
cp -r counter/build/web/ public/
Serve Flutter App at /
To serve the index.html
file directly at the /
location we modify the routes/index.dart
file with:
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'package:path/path.dart' as path;
Response onRequest(RequestContext context) {
final file = File(
path.join(Directory.current.path, 'public', 'index.html'),
);
final indexHtml = file.readAsStringSync();
return Response(body: indexHtml, headers: {'Content-Type': 'text/html'});
}
To ensure that everything works as expected we will write a test for it.
Let's create test/routes/index_test.dart
:
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import '../../routes/index.dart' as route;
class _MockRequestContext extends Mock implements RequestContext {}
final htmlString = File(
'${Directory.current.path}/public/index.html',
).readAsStringSync();
void main() {
group('GET /', () {
test('responds with a 200, an html and the content_type header text/html',
() async {
final context = _MockRequestContext();
final response = route.onRequest(context);
expect(response.statusCode, equals(200));
expect(
response.headers,
equals({
'Content-Type': 'text/html',
'content-length': '1830',
}),
);
expect(response.body(), completion(htmlString));
});
});
}
Perfect! We can now serve our Flutter Web App at localhost:8080/
To test it in a browser you can run:
dart_frog dev
Creating our middleware
We can now create our middleware where most of the magic is happening.
To do so run:
dart_frog new middleware /
We can now replace the content in the newly created middleware with this:
import 'package:dart_frog/dart_frog.dart';
import 'package:shelf_helmet/shelf_helmet.dart';
Handler middleware(Handler handler) {
return handler.use(requestLogger()).use(
fromShelfMiddleware(
helmet(
options: const HelmetOptions(
cspOptions: ContentSecurityPolicyOptions.useDefaults(
directives: {
'script-src': [
"'strict-dynamic'",
"'wasm-unsafe-eval'",
"'self'",
'blob:',
'https://unpkg.com/',
'https://www.gstatic.com/flutter-canvaskit/',
],
'script-src-elem': [
"'self'",
'blob:',
'https://unpkg.com/',
'https://www.gstatic.com/flutter-canvaskit/',
],
'connect-src': [
"'self'",
'https://unpkg.com/',
'https://www.gstatic.com/flutter-canvaskit/',
'https://fonts.gstatic.com/s/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf ',
],
'style-src': [
"'self'",
'https:',
],
'require-trusted-types-for': ["'script'"],
},
),
),
),
),
);
}
We use shelf_helmet
to set nearly all security headers. Since we are using a Flutter Web App we have to define some Urls in our CSP. This CSP is working with CSP3 and all previous versions.
We ensure that all recommended headers are set by creating a test for our middleware. So let's create test/routes/_middleware_test.dart
:
import 'package:dart_frog/dart_frog.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import '../../routes/_middleware.dart';
class _MockRequestContext extends Mock implements RequestContext {}
void main() {
group('Middleware', () {
test('add all required headers', () async {
final handler = middleware((context) => Response());
final request = Request.get(Uri.parse('http://localhost/'));
final context = _MockRequestContext();
when(() => context.request).thenReturn(request);
final finishedHandler = await handler(context);
const cspRules =
'''script-src 'strict-dynamic' 'wasm-unsafe-eval' 'self' blob: https://unpkg.com/ https://www.gstatic.com/flutter-canvaskit/;script-src-elem 'self' blob: https://unpkg.com/ https://www.gstatic.com/flutter-canvaskit/;connect-src 'self' https://unpkg.com/ https://www.gstatic.com/flutter-canvaskit/ https://fonts.gstatic.com/s/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf ;style-src 'self' https:;require-trusted-types-for 'script';default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests''';
_expectedHeaders(finishedHandler.headers, cspRules);
});
});
}
void _expectedHeaders(Map<String, String> headers, String cspRules) {
expect(headers['content-length'], '0');
expect(headers['x-xss-protection'], '0');
expect(headers['x-permitted-cross-domain-policies'], 'none');
expect(headers['x-frame-options'], 'SAMEORIGIN');
expect(headers['x-download-options'], 'noopen');
expect(headers['x-dns-prefetch-control'], 'off');
expect(headers['x-content-type-options'], 'nosniff');
expect(
headers['strict-transport-security'],
'max-age=15552000; includeSubDomains',
);
expect(headers['referrer-policy'], 'no-referrer');
expect(headers['origin-agent-cluster'], '?1');
expect(headers['cross-origin-resource-policy'], 'same-origin');
expect(headers['cross-origin-opener-policy'], 'same-origin');
expect(headers['Content-Security-Policy'], cspRules);
}
In our _expectedHeaders
we can see the work of shelf_helmet
.
Create hashes for inline-scripts
with the csp_hasher
package
Since Flutter is using inline-scripts
like this:
<script>
// The value below is injected by flutter build, do not touch.
var serviceWorkerVersion = "1515245720";
</script>
which is causing our Content Security Policy to throw errors and cause our web app to not load we have to generate Hashes for those functions.
Let's start by hashing our inline-scripts
in a custom entrypoint at our project root
// custom entrypoint for the app
import 'dart:io';
import 'package:csp_hasher/csp_hasher.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:path/path.dart' as path;
List<CspHash> cspScriptHashes = [];
List<CspHash> cspStyleHashes = [];
Future<HttpServer> run(Handler handler, InternetAddress ip, int port) {
generateCspHashes();
return serve(handler, ip, port, poweredByHeader: null);
}
/// Generates CSP hashes for the scripts and styles in the index.html file
void generateCspHashes() {
final file = File(
path.join(Directory.current.path, 'public', '-.html'),
);
if (!file.existsSync()) {
throw Exception('Index Not found\nPlease run build_web.sh first');
}
cspScriptHashes = hashScripts(htmlFile: file);
cspStyleHashes = hashScripts(
htmlFile: file,
hashMode: HashMode.style,
);
}
This is creating hashes for all our inline-scripts
at every hot-reload.
We can now inject the hashes into our Content Security Policy in the _middleware.dart
file so that it looks like this:
import 'package:dart_frog/dart_frog.dart';
import 'package:shelf_helmet/shelf_helmet.dart';
import '../main.dart';
Handler middleware(Handler handler) {
return handler.use(requestLogger()).use(
fromShelfMiddleware(
helmet(
options: HelmetOptions(
cspOptions: ContentSecurityPolicyOptions.useDefaults(
directives: {
'script-src': [
"'strict-dynamic'",
"'wasm-unsafe-eval'",
cspScriptHashes.join(' ').replaceAll('"', ''),
"'self'",
'blob:',
'https://unpkg.com/',
'https://www.gstatic.com/flutter-canvaskit/',
],
'script-src-elem': [
cspScriptHashes.join(' ').replaceAll('"', ''),
"'self'",
'blob:',
'https://unpkg.com/',
'https://www.gstatic.com/flutter-canvaskit/',
],
'connect-src': [
"'self'",
'https://unpkg.com/',
'https://www.gstatic.com/flutter-canvaskit/',
'https://fonts.gstatic.com/s/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf ',
],
'style-src': [
"'self'",
'https:',
cspStyleHashes.join(' ').replaceAll('"', ''),
],
'require-trusted-types-for': ["'script'"],
},
),
),
),
),
);
}
And our updated test is looking like this:
import 'package:csp_hasher/csp_hasher.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import '../../main.dart';
import '../../routes/_middleware.dart';
class _MockRequestContext extends Mock implements RequestContext {}
void main() {
group('Middleware', () {
setUp(() {
cspScriptHashes.addAll([
CspHash(
lineNumber: 1,
hashType: sha256,
hash: 'abcdef',
hashMode: HashMode.script,
),
]);
cspStyleHashes.addAll([
CspHash(
lineNumber: 1,
hashType: sha256,
hash: 'ghijkl',
hashMode: HashMode.style,
),
]);
});
test('add all required headers', () async {
final handler = middleware((context) => Response());
final request = Request.get(Uri.parse('http://localhost/'));
final context = _MockRequestContext();
when(() => context.request).thenReturn(request);
final finishedHandler = await handler(context);
const cspRules =
'''script-src 'unsafe-inline' 'strict-dynamic' 'wasm-unsafe-eval' 'sha256-abcdef' 'self' blob: https://unpkg.com/ https://www.gstatic.com/flutter-canvaskit/;script-src-elem 'sha256-abcdef' 'self' blob: https://unpkg.com/ https://www.gstatic.com/flutter-canvaskit/;connect-src 'self' https://unpkg.com/ https://www.gstatic.com/flutter-canvaskit/ https://fonts.gstatic.com/s/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf ;style-src 'self' https: 'sha256-ghijkl';require-trusted-types-for 'script';default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src-attr 'none';upgrade-insecure-requests''';
_expectedHeaders(finishedHandler.headers, cspRules);
});
});
}
void _expectedHeaders(Map<String, String> headers, String cspRules) {
expect(headers['content-length'], '0');
expect(headers['x-xss-protection'], '0');
expect(headers['x-permitted-cross-domain-policies'], 'none');
expect(headers['x-frame-options'], 'SAMEORIGIN');
expect(headers['x-download-options'], 'noopen');
expect(headers['x-dns-prefetch-control'], 'off');
expect(headers['x-content-type-options'], 'nosniff');
expect(
headers['strict-transport-security'],
'max-age=15552000; includeSubDomains',
);
expect(headers['referrer-policy'], 'no-referrer');
expect(headers['origin-agent-cluster'], '?1');
expect(headers['cross-origin-resource-policy'], 'same-origin');
expect(headers['cross-origin-opener-policy'], 'same-origin');
expect(headers['Content-Security-Policy'], cspRules);
}
Conclusion
By utilizing dart_frog
in combination with shelf_helmet
and csp_hasher
it is quite easy to serve a Flutter Web App with the recommended Security Headers and a very strong Content-Security-Policy
. Nevertheless, we can avoid all the hashing by removing all inline-scripts
from our index.html
Subscribe to my newsletter
Read articles from Justin Baumann directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Justin Baumann
Justin Baumann
Hey, I'm jxstxn or Justin. I'm a Flutter Developer who loves Flutter and Dart. In my free time, I'm writing Dart Packages like Middlewares for Shelf or Plugins for Sidekick.