Start Debugging

NavigationDelegate で Flutter の WebView が外部 URL へ遷移するのをブロックする方法

webview_flutter 4.14.1 で Flutter の WebView を自分のドメインに留める方法です。URL をパースし、startsWith ではなく Uri.host を比較し、mailto: と tel: は url_launcher に渡し、Android と iOS が onNavigationRequest に実際に何を送るのかを把握します。

要点: Flutter 3.44 上の webview_flutter 4.14.1 では、WebViewControllerNavigationDelegate を設定し、その onNavigationRequestrequest.urlUri.tryParse でパースします。uri.scheme == 'https' かつ uri.host が許可リストに含まれる場合にだけ NavigationDecision.navigate を返し、それ以外はすべて NavigationDecision.prevent を返します。必要であれば、ブロックしたリンクを url_launcher でシステムのブラウザーで開きます。url.startsWith('https://example.com') は使わないでください。これでは https://example.com.evil.nethttps://example.com@evil.net が通ってしまいます。また限界も知っておく必要があります。Android では、サブフレームの遷移や POST のフォーム送信はこのコールバックに一切届きません。

この記事の残りでは、このポリシーを段階的に組み立て、素朴なチェックが誤っていることを示すテスト表を紹介し、コールバックで何をブロックでき何をブロックできないかを決めるプラットフォームごとの違いを解説します。以下の内容はすべて、Flutter 3.44.8 / Dart 3.12.2 上で webview_flutter 4.14.1、webview_flutter_android 4.14.1、webview_flutter_wkwebview 3.26.1、url_launcher 6.3.2 を使ってコンパイルとテストを行っています。

WebView がサイトの外へさまよい出る理由

埋め込みのヘルプセンター、決済ページ、利用規約の画面は、たいてい 1 つのサイトだけを表示するためのものです。しかしページ側はそれを知りません。フッターには Twitter へのリンクがあり、“powered by” のバッジ、OAuth のボタン、mailto: のサポート用アドレス、さらには任意のリンクを含むユーザー投稿コンテンツがあるかもしれません。そのどれかをタップすると、WebView はそれをアプリ内でそのまま読み込みます。アドレスバーはなく、自分で作らない限り戻るボタンもなく、画面上部にはアプリの名前が表示されたままです。これは UX の問題 (ユーザーがサードパーティのサイトで立ち往生する) であり、信頼の問題 (アプリ内に表示されたフィッシングページがアプリの信用をそのまま引き継ぐ) でもあります。

webview_flutter はこのためのフックを 1 つだけ公開しています。NavigationDelegate.onNavigationRequest です。4.14.1 でのシグネチャは次のとおりです。

// webview_flutter 4.14.1
FutureOr<NavigationDecision> Function(NavigationRequest request)? onNavigationRequest

NavigationRequest が持つフィールドはちょうど 2 つ、url (String) と isMainFrame (bool) で、NavigationDecision の値は navigateprevent の 2 つです。それ以外はすべて自分で実装します。

README の例こそがバグ

公式パッケージの README には次のスニペットが載っています。

// From the webview_flutter 4.14.1 README
onNavigationRequest: (NavigationRequest request) {
  if (request.url.startsWith('https://www.youtube.com/')) {
    return NavigationDecision.prevent;
  }
  return NavigationDecision.navigate;
},

拒否リストのデモとしては問題ありません。しかし多くの人がやるように許可リストへ反転させると、if (request.url.startsWith('https://example.com')) navigate else prevent になります。URL は文字列のプレフィックスで判断できるものではありません。16 個の URL を、素朴なプレフィックスチェックと後で組み立てるポリシークラスの両方に通してみました。

URL                                                  naive     policy
https://example.com/pricing                          internal  allowInWebView
https://help.example.com/articles/42                 external  allowInWebView
https://EXAMPLE.com/Pricing                          external  allowInWebView
https://example.com:8443/admin                       internal  allowInWebView
https://example.com.evil.net/login                   internal  openExternally
https://example.com@evil.net/login                   internal  openExternally
https://notexample.com/                              external  openExternally
https://evil.net/?next=https://example.com           external  openExternally
http://example.com/                                  external  openExternally
mailto:support@example.com                           external  openExternally
tel:+15550100                                        external  openExternally
about:blank                                          external  allowInWebView
about:srcdoc                                         external  block
javascript:alert(1)                                  external  block
intent://scan/#Intent;scheme=zxing;end               external  block
file:///data/data/com.example/shared_prefs/x.xml     external  block

危険なのは 2 行です。https://example.com.evil.net/login は、evil.net を登録した人が所有するホストです。https://example.com@evil.net/loginexample.com を URL の userinfo 部分に置いているので、ブラウザーは evil.net に接続します。どちらもプレフィックスチェックを通過し、アプリ内に表示されます。その他の不一致は偽陰性です。大文字のホストやサブドメインが、理由もなく WebView から追い出されます。

解決策は、パースを Uri に任せることです。Uri.parse('https://EXAMPLE.com@evil.net:8443/x').hostevil.net を返します。小文字化され、userinfo とポートは取り除かれています。生の文字列ではなく、必ずこちらを比較してください。

許可リストのポリシーを組み立てる

判定ロジックは、Flutter やプラグインを import しない素の Dart クラスに置きます。こうすると flutter test で単体テストができます。ウィジェットテストでは本物の WebView を動かせないので、これは重要です。

// Flutter 3.44, Dart 3.12, webview_flutter 4.14.1
enum LinkAction { allowInWebView, openExternally, block }

class LinkPolicy {
  const LinkPolicy({
    required this.allowedHosts,
    this.allowSubdomains = true,
    this.externalSchemes = const {'mailto', 'tel', 'sms'},
  });

  /// Hosts that may load inside the WebView, lower case, no scheme, no port.
  final Set<String> allowedHosts;
  final bool allowSubdomains;

  /// Schemes handed to the OS instead of the WebView.
  final Set<String> externalSchemes;

  LinkAction decide(String url) {
    final uri = Uri.tryParse(url);
    if (uri == null) return LinkAction.block;

    switch (uri.scheme) {
      case 'https':
        return _isAllowedHost(uri.host)
            ? LinkAction.allowInWebView
            : LinkAction.openExternally;
      case 'http':
        // Never load cleartext in-app, even for your own host.
        return LinkAction.openExternally;
      case 'about':
        return url == 'about:blank'
            ? LinkAction.allowInWebView
            : LinkAction.block;
      default:
        return externalSchemes.contains(uri.scheme)
            ? LinkAction.openExternally
            : LinkAction.block; // javascript:, file:, intent:, data:, ...
    }
  }

  bool _isAllowedHost(String host) {
    // Uri.host is already lower case and has userinfo and port stripped.
    if (allowedHosts.contains(host)) return true;
    if (!allowSubdomains) return false;
    return allowedHosts.any((allowed) => host.endsWith('.$allowed'));
  }
}

ここでのいくつかの選択は意図的なものです。

上の表は、次のテストファイルの出力です。flutter test で約 1 秒で実行できます。

// Flutter 3.44, Dart 3.12
import 'package:flutter_test/flutter_test.dart';
import 'package:wvguard/link_policy.dart';

void main() {
  const policy = LinkPolicy(allowedHosts: {'example.com'});

  final cases = <String, LinkAction>{
    'https://help.example.com/articles/42': LinkAction.allowInWebView,
    'https://EXAMPLE.com/Pricing': LinkAction.allowInWebView,
    'https://example.com.evil.net/login': LinkAction.openExternally,
    'https://example.com@evil.net/login': LinkAction.openExternally,
    'https://notexample.com/': LinkAction.openExternally,
    'http://example.com/': LinkAction.openExternally,
    'mailto:support@example.com': LinkAction.openExternally,
    'javascript:alert(1)': LinkAction.block,
    'intent://scan/#Intent;scheme=zxing;end': LinkAction.block,
  };

  for (final entry in cases.entries) {
    test(entry.key, () => expect(policy.decide(entry.key), entry.value));
  }
}

ポリシーを NavigationDelegate に組み込む

手順は次の順番です。

  1. パッケージを追加します: flutter pub add webview_flutter url_launcherwebview_flutter 4.14.1 には Flutter 3.38 以降、Android SDK 24+、iOS 13+ が必要です。
  2. WebViewControllerbuild ではなく initState で一度だけ作成します。
  3. LinkActionNavigationDecision に対応付ける onNavigationRequest を指定して setNavigationDelegate を呼び出します。
  4. openExternally の場合は、LaunchMode.externalApplication を指定して launchUrl を発行し、すぐに prevent を返します。
  5. デリゲートを設定し終えてから、最後に loadRequest を呼び出します。
// Flutter 3.44, Dart 3.12, webview_flutter 4.14.1, url_launcher 6.3.2
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';

import 'link_policy.dart';

class HelpCenterPage extends StatefulWidget {
  const HelpCenterPage({super.key});

  @override
  State<HelpCenterPage> createState() => _HelpCenterPageState();
}

class _HelpCenterPageState extends State<HelpCenterPage> {
  static final Uri _home = Uri.parse('https://help.example.com/');
  static const LinkPolicy _policy = LinkPolicy(allowedHosts: {'example.com'});

  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(onNavigationRequest: _onNavigationRequest),
      )
      ..loadRequest(_home);
  }

  NavigationDecision _onNavigationRequest(NavigationRequest request) {
    // Android never asks about subframes; iOS does. Leave iframes alone here.
    if (!request.isMainFrame) return NavigationDecision.navigate;

    switch (_policy.decide(request.url)) {
      case LinkAction.allowInWebView:
        return NavigationDecision.navigate;
      case LinkAction.openExternally:
        unawaited(_openExternally(Uri.parse(request.url)));
        return NavigationDecision.prevent;
      case LinkAction.block:
        debugPrint('Blocked navigation to ${request.url}');
        return NavigationDecision.prevent;
    }
  }

  Future<void> _openExternally(Uri uri) async {
    final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
    if (!opened && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('No app can open $uri')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Help')),
      body: WebViewWidget(controller: _controller),
    );
  }
}

このファイルに対して flutter analyze は問題を報告しません。ここで取り上げておきたい細部が 2 つあります。

コールバックは同期的に値を返します。onNavigationRequestFuture<NavigationDecision> も受け付けますが、iOS ではプラグインが WebKit の decidePolicyForNavigationAction の中でコールバックを await するため、そこで費やした 1 ミリ秒ごとにページが固まります。launchUrl を待機する (OS がアプリを切り替えるのを待つ) のは、そこでは最もやってはいけないことです。同期的に判定して値を返し、起動は unawaited でバックグラウンドに回してください。

await launchUrl の後の mounted チェックは、OS が応答するまでにユーザーがページを閉じている可能性があるために入れています。このパターンに馴染みがなければ、await の後で BuildContext を安全に使う方法 で詳しく解説しています。

Android と iOS が onNavigationRequest に実際に送るもの

ここはドキュメントが “some platforms may also trigger this callback from calls to loadRequest” という一文で済ませている部分です。webview_flutter_android 4.14.1 と webview_flutter_wkwebview 3.26.1 のプラットフォーム実装を読んだところ、両者の挙動は大きく異なっていました。

Android: ネイティブ側が先にキャンセルし、Dart が再発行する

Android では、コールバックは WebViewClient.shouldOverrideUrlLoading によって駆動されます。onNavigationRequest を設定すると、プラグインは setSynchronousReturnValueForShouldOverrideUrlLoading(true) を呼び出します。それ以降、ネイティブの WebViewClientProxyApi はすべての遷移に対して request.isForMainFrame() && true を返します。つまりメインフレームの遷移はすべて、Dart に問い合わせる前に即座にキャンセルされます。その後 Dart がコールバックを実行し、navigate が返されると、プラグインは同じ URL と元のリクエストヘッダーで loadUrl を呼び出します。

その結果、次のようになります。

iOS と macOS: WebKit は応答を待つ

WebKit では、コールバックは WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:) によって駆動されます。プラグインはコールバックを待機し、navigate.allow に、prevent.cancel に対応付けます。再発行は行われないので、POST のボディとヘッダーはそのまま保たれます。

その結果、次のようになります。

両プラットフォームで一貫して iframe を取り締まる必要があるなら、ナビゲーションデリゲートは適切な道具ではありません。自分のサーバーから frame-src を含む Content-Security-Policy ヘッダーを送ってください。両方の WebView がこれを適用します。

トラフィックが許可リストをすり抜ける落とし穴

ナビゲーションデリゲートが制御するのはページの遷移です。次のものは見えません。

実際にハマりやすいものがさらに 3 つあります。

WebView が通常のサイトではなく自分の Flutter web ビルドを表示しているなら、アプリ内のルーティングは WebView ではなくルーターが担っており、go_router でネストしたルートとディープリンクを設定する方法 のほうが関連の深い記事です。その埋め込み Flutter web ビルド内のフォントスケーリングは別の落とし穴で、Android の WebView で Flutter の Text が画面外に描画される問題 にまとめています。

非同期の判定はプラットフォームごとに挙動が異なる

Android は先にキャンセルして後から再発行するため、Android の async コールバックはページを決してブロックしません。Future が完了してプラグインが loadUrl を呼び出すまで、古いページは完全に操作可能な状態で画面に残ります。その間にユーザーが 2 つ目のリンクをタップすると、両方の判定が実行され、最後に実行された loadUrl が勝ちます。iOS では同じ async コールバックが WebKit の decision handler を開いたままにするので、ページは待たされます。ポリシーが本当に I/O を必要とする場合 (たとえばリモートの許可リストを取得する場合) は、ページを開く前に一度だけ読み込み、上の例のように onNavigationRequest を同期的に保ってください。そうすれば両プラットフォームで同じ挙動になります。

shouldInterceptRequest によるサブリソースリクエストの横取りのように、クロスプラットフォーム API が公開していない制御が必要な場合、4.14.1 にはそのための Dart のフックはありません。ネイティブコードが必要になり、プラグインなしで Flutter にプラットフォーム固有のコードを追加する方法 のアプローチが使えます。まずは許可リストを試してください。埋め込みページの大多数はそれで十分です。

最後に、許可リストを含め、アプリのバイナリに同梱したものはすべて、APK や IPA を展開すれば誰でも読めることを忘れないでください。ホスト名のリストならそれで問題ありませんが、攻撃者が Flutter アプリから何を抽出できるか を思い出すよいきっかけになります。許可リストはユーザーがさまよい出るのを防ぐためのものであり、秘密ではありません。

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る