Elevate Your Flutter Web Hosting with Dart_Frog

Elevate Your Flutter Web Hosting with Dart_Frog

(and some more packages)

Link to the GitHub Repo

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.

You can find a deeper explanation of CSP here

And the documentation of shelf_helmet

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,
  );
}

You can find a link to the csp_hasher package here

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

Link to the GitHub Repo