Start Debugging

Как запретить Flutter WebView переходить на внешние URL с помощью NavigationDelegate

Удерживайте Flutter WebView на своём домене с webview_flutter 4.14.1: разбирайте URL, сравнивайте Uri.host вместо startsWith, передавайте mailto: и tel: в url_launcher и учитывайте, что Android и iOS на самом деле отправляют в onNavigationRequest.

Короткий ответ: с webview_flutter 4.14.1 на Flutter 3.44 передайте WebViewController объект NavigationDelegate, у которого onNavigationRequest разбирает request.url через Uri.tryParse, возвращает NavigationDecision.navigate только когда uri.scheme == 'https' и uri.host входит в ваш список разрешённых хостов, а для всего остального возвращает NavigationDecision.prevent, по желанию открывая заблокированную ссылку в системном браузере через url_launcher. Не используйте url.startsWith('https://example.com'): такая проверка пропускает https://example.com.evil.net и https://example.com@evil.net. И помните об ограничениях: на Android колбэк никогда не видит навигацию во вложенных фреймах и отправку форм методом POST.

Дальше в статье эта политика строится шаг за шагом, приводится тестовая таблица, доказывающая, что наивная проверка ошибочна, и разбираются различия платформ, которые определяют, что ваш колбэк может заблокировать, а что нет. Весь код ниже скомпилирован и проверен с webview_flutter 4.14.1, webview_flutter_android 4.14.1, webview_flutter_wkwebview 3.26.1 и url_launcher 6.3.2 на Flutter 3.44.8 / Dart 3.12.2.

Почему WebView уходит с вашего сайта

Встроенный справочный центр, страница оформления заказа или экран с условиями использования обычно должны показывать один сайт. Сама страница об этом не знает. В ней есть ссылки в подвале на Twitter, значок “powered by”, кнопка OAuth, адрес поддержки mailto: и, возможно, пользовательский контент с произвольными ссылками. Нажмите любую из них, и WebView спокойно загрузит её внутри вашего приложения: без адресной строки, без кнопки “назад”, если вы её не сделали, и с названием вашего приложения вверху экрана. Это проблема UX (пользователи застревают на стороннем сайте) и проблема доверия (фишинговая страница, показанная внутри вашего приложения, наследует его репутацию).

webview_flutter предоставляет для этого один хук: NavigationDelegate.onNavigationRequest. Его сигнатура в 4.14.1 такая:

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

NavigationRequest содержит ровно два поля, url (String) и isMainFrame (bool), а у NavigationDecision два значения, navigate и prevent. Всё остальное зависит от вас.

Пример из 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

Опасны две строки. https://example.com.evil.net/login это хост, принадлежащий тому, кто зарегистрировал evil.net. https://example.com@evil.net/login помещает example.com в часть URL с данными пользователя (userinfo), поэтому браузер подключается к evil.net. Оба URL проходят проверку префикса и отображаются внутри вашего приложения. Остальные расхождения это ложноотрицательные результаты: хост в верхнем регистре или поддомен без всякой причины выбрасываются из WebView.

Решение в том, чтобы доверить разбор Uri. Uri.parse('https://EXAMPLE.com@evil.net:8443/x').host возвращает evil.net: в нижнем регистре, без userinfo и порта. Сравнивайте именно это значение, а не исходную строку.

Построение политики со списком разрешённых хостов

Держите логику решения в обычном классе Dart без импортов Flutter или плагинов. Тогда его можно покрыть модульными тестами через 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 примерно за секунду:

// 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_launcher. webview_flutter 4.14.1 требует Flutter 3.38 или новее, Android SDK 24+ и iOS 13+.
  2. Создайте WebViewController один раз, в initState, а не в build.
  3. Вызовите setNavigationDelegate с onNavigationRequest, который сопоставляет каждый LinkAction с NavigationDecision.
  4. Для openExternally запустите launchUrl с LaunchMode.externalApplication и сразу верните 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 не находит в этом файле проблем. Стоит отметить две детали.

Колбэк возвращает результат синхронно. onNavigationRequest принимает Future<NavigationDecision>, но на iOS плагин выполняет await вашего колбэка внутри decidePolicyForNavigationAction из WebKit, поэтому каждая миллисекунда, проведённая там, это миллисекунда, в течение которой страница заморожена. Ожидать launchUrl (который ждёт, пока ОС переключит приложения) там как раз не следует. Примите решение синхронно, верните его и запустите открытие в фоне через unawaited.

Проверка mounted после await launchUrl нужна потому, что к моменту ответа ОС пользователь мог уже закрыть страницу. Если этот паттерн для вас новый, я подробно разобрал его в статье о безопасном использовании BuildContext после await.

Что 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, плагин вызывает loadUrl с тем же URL и исходными заголовками запроса.

Последствия:

iOS и macOS: WebKit ждёт вашего ответа

В WebKit колбэк вызывается из WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:). Плагин ожидает ваш колбэк и сопоставляет navigate с .allow, а prevent с .cancel. Ничего не отправляется повторно, поэтому тела POST-запросов и заголовки сохраняются.

Последствия:

Если нужен единообразный контроль iframe на обеих платформах, делегат навигации для этого не подходит. Отправляйте со своего сервера заголовок Content-Security-Policy с frame-src: его соблюдают оба WebView.

Подводные камни, через которые трафик проходит мимо списка разрешённых

Делегат навигации управляет навигацией страниц. Он не видит:

Ещё три, которые кусаются на практике:

Если ваш WebView показывает вашу собственную веб-сборку Flutter, а не обычный сайт, маршрутизация внутри приложения живёт в вашем роутере, а не в WebView, и полезнее будет статья о вложенных маршрутах и глубоких ссылках с go_router. Масштабирование шрифтов внутри такой встроенной веб-сборки Flutter это отдельная ловушка, которую я описал в статье о Flutter Text, отрисовывающемся за пределами экрана в Android WebView.

Асинхронные решения ведут себя по-разному на разных платформах

Поскольку Android сначала отменяет навигацию, а потом повторяет её, async-колбэк на Android никогда не блокирует страницу: старая страница остаётся на экране и полностью интерактивна, пока ваш Future не завершится и плагин не вызовет loadUrl. Если за это время пользователь нажмёт вторую ссылку, выполнятся оба решения, и победит тот loadUrl, который выполнится последним. На iOS тот же async-колбэк держит обработчик решения WebKit открытым, поэтому страница ждёт. Если вашей политике действительно нужен ввод-вывод (например, загрузка удалённого списка разрешённых), загрузите его один раз до открытия страницы и оставьте onNavigationRequest синхронным, как в примере выше. Так поведение на обеих платформах будет одинаковым.

Если нужен контроль, который кроссплатформенный API не предоставляет, например перехват запросов подресурсов через shouldInterceptRequest, то в 4.14.1 для этого нет хука в Dart. Это означает нативный код, и тут пригодится подход из статьи о добавлении платформенно-зависимого кода во Flutter без плагинов. Сначала попробуйте список разрешённых; для подавляющего большинства встроенных страниц его достаточно.

Наконец, помните, что всё, что вы поставляете в бинарнике приложения, включая список разрешённых хостов, может прочитать любой, кто распакует APK или IPA. Для списка имён хостов это нормально, но это хорошее напоминание о том, что злоумышленник может извлечь из Flutter-приложения: список разрешённых защищает пользователей от ухода с сайта, но секретом он не является.

Источники

Comments

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

< Назад