Start Debugging

Como impedir que uma WebView do Flutter navegue para URLs externas com NavigationDelegate

Mantenha uma WebView do Flutter no seu próprio domínio com webview_flutter 4.14.1: faça o parse da URL, compare Uri.host em vez de usar startsWith, entregue mailto: e tel: ao url_launcher e saiba o que Android e iOS realmente enviam para onNavigationRequest.

Resposta curta: com webview_flutter 4.14.1 no Flutter 3.44, dê ao WebViewController um NavigationDelegate cujo onNavigationRequest faz o parse de request.url com Uri.tryParse, retorna NavigationDecision.navigate somente quando uri.scheme == 'https' e uri.host está na sua allowlist, e retorna NavigationDecision.prevent para todo o resto, opcionalmente abrindo o link bloqueado no navegador do sistema com url_launcher. Não use url.startsWith('https://example.com'): isso deixa passar https://example.com.evil.net e https://example.com@evil.net. E conheça os limites: no Android o callback nunca vê navegações de subframes nem envios de formulário via POST.

O restante deste post constrói essa política passo a passo, mostra a tabela de testes que prova que a verificação ingênua está errada e percorre as diferenças entre plataformas que decidem o que o seu callback consegue ou não bloquear. Tudo abaixo foi compilado e testado com webview_flutter 4.14.1, webview_flutter_android 4.14.1, webview_flutter_wkwebview 3.26.1 e url_launcher 6.3.2 no Flutter 3.44.8 / Dart 3.12.2.

Por que uma WebView sai do seu site

Uma central de ajuda embutida, uma página de checkout ou uma tela de termos de serviço normalmente devem mostrar um único site. A página não sabe disso. Ela contém links no rodapé para o Twitter, um selo “powered by”, um botão de OAuth, um endereço de suporte mailto: e talvez conteúdo gerado por usuários com links arbitrários. Toque em qualquer um deles e a WebView carrega tudo alegremente dentro do seu app, sem barra de endereço, sem botão de voltar a menos que você tenha criado um, e com o nome do seu app no topo da tela. Isso é um problema de UX (os usuários ficam presos em um site de terceiros) e um problema de confiança (uma página de phishing renderizada dentro do seu app herda a credibilidade do seu app).

O webview_flutter expõe um único hook para isso: NavigationDelegate.onNavigationRequest. A assinatura na versão 4.14.1 é:

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

NavigationRequest carrega exatamente dois campos, url (uma String) e isMainFrame (um bool), e NavigationDecision tem dois valores, navigate e prevent. Todo o resto fica por sua conta.

O exemplo do README é o bug

O README oficial do pacote mostra este trecho:

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

Como demonstração de denylist, tudo bem. Inverta para uma allowlist, que é o que a maioria das pessoas faz, e você obtém if (request.url.startsWith('https://example.com')) navigate else prevent. Prefixos de string não são como URLs funcionam. Passei 16 URLs tanto pela verificação ingênua por prefixo quanto pela classe de política construída abaixo:

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

Duas linhas são as perigosas. https://example.com.evil.net/login é um host que pertence a quem registrou evil.net. https://example.com@evil.net/login coloca example.com na parte de userinfo da URL, então o navegador se conecta a evil.net. Ambas passam na verificação por prefixo e são renderizadas dentro do seu app. As outras divergências são falsos negativos: um host em maiúsculas ou um subdomínio é expulso da WebView sem motivo.

A correção é deixar o Uri fazer o parse. Uri.parse('https://EXAMPLE.com@evil.net:8443/x').host retorna evil.net: em minúsculas, com userinfo e porta removidos. Compare isso, nunca a string bruta.

Construindo a política de allowlist

Mantenha a decisão em uma classe Dart simples, sem imports do Flutter ou de plugins. Isso a torna testável com flutter test, o que importa porque você não consegue rodar uma WebView real em um teste de widget.

// 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'));
  }
}

Algumas escolhas aqui são deliberadas:

A tabela acima é a saída deste arquivo de teste, que roda em cerca de um segundo com 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));
  }
}

Conectando a política ao NavigationDelegate

Os passos, em ordem:

  1. Adicione os pacotes: flutter pub add webview_flutter url_launcher. O webview_flutter 4.14.1 exige Flutter 3.38 ou superior, Android SDK 24+ e iOS 13+.
  2. Crie o WebViewController uma única vez, em initState, não em build.
  3. Chame setNavigationDelegate com um onNavigationRequest que mapeia cada LinkAction para uma NavigationDecision.
  4. Para openExternally, dispare launchUrl com LaunchMode.externalApplication e retorne prevent imediatamente.
  5. Chame loadRequest por último, depois que o delegate estiver configurado.
// 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),
    );
  }
}

O flutter analyze não reporta nenhum problema neste arquivo. Dois detalhes merecem destaque.

O callback retorna de forma síncrona. onNavigationRequest aceita um Future<NavigationDecision>, mas no iOS o plugin faz await do seu callback dentro do decidePolicyForNavigationAction do WebKit, então cada milissegundo gasto ali é um milissegundo em que a página fica congelada. Aguardar launchUrl (que espera o sistema operacional trocar de app) é exatamente o que não se deve fazer ali. Decida de forma síncrona, retorne e lance em segundo plano com unawaited.

A verificação de mounted depois de await launchUrl está ali porque o usuário pode ter fechado a página quando o sistema operacional responder. Se esse padrão é novo para você, eu o expliquei em detalhes em usando BuildContext com segurança depois de um await.

O que Android e iOS realmente enviam para onNavigationRequest

Esta é a parte que a documentação trata superficialmente com “some platforms may also trigger this callback from calls to loadRequest”. Li as implementações de plataforma em webview_flutter_android 4.14.1 e webview_flutter_wkwebview 3.26.1, e as duas se comportam de maneira bem diferente.

Android: o lado nativo cancela primeiro, o Dart reemite

No Android o callback é acionado por WebViewClient.shouldOverrideUrlLoading. Quando você define onNavigationRequest, o plugin chama setSynchronousReturnValueForShouldOverrideUrlLoading(true). A partir daí, o WebViewClientProxyApi nativo retorna request.isForMainFrame() && true para toda navegação: toda navegação de frame principal é cancelada imediatamente, antes mesmo de o Dart ser consultado. O Dart então executa o seu callback e, se ele retornar navigate, o plugin chama loadUrl com a mesma URL e os headers da requisição original.

Consequências:

iOS e macOS: o WebKit espera a sua resposta

No WebKit o callback é acionado por WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:). O plugin aguarda o seu callback e mapeia navigate para .allow e prevent para .cancel. Nada é reemitido, então corpos de POST e headers chegam intactos.

Consequências:

Se você precisa de uma fiscalização consistente de iframes nas duas plataformas, o navigation delegate é a ferramenta errada. Envie um header Content-Security-Policy com frame-src a partir do seu próprio servidor, que ambas as WebViews respeitam.

Armadilhas que deixam tráfego escapar da allowlist

O navigation delegate controla navegações de página. Ele não enxerga:

Mais três que pegam na prática:

Se a sua WebView mostra o seu próprio build Flutter web em vez de um site comum, o roteamento dentro do app fica no seu router, não na WebView, e rotas aninhadas e deep links com go_router é o texto mais relevante. O escalonamento de fonte dentro desse build Flutter web embutido é uma armadilha à parte que descrevi em Text do Flutter renderizando fora da tela em uma WebView do Android.

Decisões assíncronas se comportam de forma diferente em cada plataforma

Como o Android cancela primeiro e reemite depois, um callback async no Android nunca bloqueia a página: a página antiga continua na tela, totalmente interativa, até o seu Future completar e o plugin chamar loadUrl. Se o usuário tocar em um segundo link nesse meio-tempo, as duas decisões são executadas e vence o loadUrl que rodar por último. No iOS o mesmo callback async mantém aberto o decision handler do WebKit, então a página espera. Se a sua política realmente precisa de I/O (por exemplo, buscar uma allowlist remota), carregue-a uma vez antes de a página abrir e mantenha onNavigationRequest síncrono, como no exemplo acima. Isso dá um comportamento idêntico nas duas plataformas.

Se você precisa de um controle que a API multiplataforma não expõe, como interceptar requisições de sub-recursos com shouldInterceptRequest, não existe hook em Dart para isso na versão 4.14.1. Isso significa código nativo, e a abordagem de adicionar código específico de plataforma no Flutter sem plugins se aplica. Tente a allowlist primeiro; ela basta para a grande maioria das páginas embutidas.

Por fim, lembre-se de que tudo o que você distribui no binário do app, incluindo a allowlist, pode ser lido por qualquer pessoa que descompacte o APK ou o IPA. Para uma lista de hostnames isso não tem problema, mas é um bom lembrete do que um atacante consegue extrair de um app Flutter: a allowlist protege os seus usuários de se perderem por aí, ela não é um segredo.

Fontes

Comments

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

< Voltar