Start Debugging

Cómo impedir que un WebView de Flutter navegue a URLs externas con NavigationDelegate

Mantén un WebView de Flutter dentro de tu propio dominio con webview_flutter 4.14.1: analiza la URL, compara Uri.host en lugar de usar startsWith, delega mailto: y tel: a url_launcher, y conoce lo que Android e iOS realmente envían a onNavigationRequest.

Respuesta corta: con webview_flutter 4.14.1 en Flutter 3.44, dale al WebViewController un NavigationDelegate cuyo onNavigationRequest analice request.url con Uri.tryParse, devuelva NavigationDecision.navigate solo cuando uri.scheme == 'https' y uri.host esté en tu lista de permitidos, y devuelva NavigationDecision.prevent para todo lo demás, abriendo opcionalmente el enlace bloqueado en el navegador del sistema con url_launcher. No uses url.startsWith('https://example.com'): deja pasar https://example.com.evil.net y https://example.com@evil.net. Y conoce los límites: en Android el callback nunca ve las navegaciones de subframes ni los envíos de formularios por POST.

El resto de este artículo construye esa política paso a paso, muestra la tabla de pruebas que demuestra que la comprobación ingenua es incorrecta y recorre las diferencias entre plataformas que deciden qué puede y qué no puede bloquear tu callback. Todo lo que sigue se compiló y probó con webview_flutter 4.14.1, webview_flutter_android 4.14.1, webview_flutter_wkwebview 3.26.1 y url_launcher 6.3.2 sobre Flutter 3.44.8 / Dart 3.12.2.

Por qué un WebView se escapa de tu sitio

Un centro de ayuda embebido, una página de pago o una pantalla de términos de servicio normalmente están pensados para mostrar un solo sitio. La página no lo sabe. Contiene enlaces en el pie a Twitter, una insignia de “powered by”, un botón de OAuth, una dirección de soporte mailto: y quizá contenido generado por usuarios con enlaces arbitrarios. Toca cualquiera de ellos y el WebView lo carga sin problema dentro de tu app, sin barra de direcciones, sin botón de atrás a menos que hayas construido uno y con el nombre de tu app en la parte superior de la pantalla. Eso es un problema de UX (los usuarios quedan varados en un sitio de terceros) y un problema de confianza (una página de phishing renderizada dentro de tu app hereda la credibilidad de tu app).

webview_flutter expone un solo hook para esto: NavigationDelegate.onNavigationRequest. Su firma en 4.14.1 es:

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

NavigationRequest lleva exactamente dos campos, url (un String) e isMainFrame (un bool), y NavigationDecision tiene dos valores, navigate y prevent. Todo lo demás depende de ti.

El ejemplo del README es el bug

El README oficial del paquete muestra este fragmento:

// 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 demostración de una lista de bloqueo está bien. Conviértelo en una lista de permitidos, que es lo que hace la mayoría, y obtienes if (request.url.startsWith('https://example.com')) navigate else prevent. Los prefijos de cadena no son la forma en que funcionan las URLs. Pasé 16 URLs tanto por la comprobación ingenua de prefijo como por la clase de política que se construye más abajo:

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

Dos filas son las peligrosas. https://example.com.evil.net/login es un host que pertenece a quien haya registrado evil.net. https://example.com@evil.net/login pone example.com en la parte de userinfo de la URL, así que el navegador se conecta a evil.net. Ambas pasan la comprobación de prefijo y se renderizan dentro de tu app. Las demás discrepancias son falsos negativos: un host en mayúsculas o un subdominio se expulsan del WebView sin motivo.

La solución es dejar que Uri haga el análisis. Uri.parse('https://EXAMPLE.com@evil.net:8443/x').host devuelve evil.net: en minúsculas, sin userinfo ni puerto. Compara eso, nunca la cadena cruda.

Construir la política de lista de permitidos

Mantén la decisión en una clase de Dart simple, sin imports de Flutter ni de plugins. Eso la hace comprobable con pruebas unitarias mediante flutter test, lo cual importa porque no puedes ejecutar un WebView real en una prueba de widgets.

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

Algunas decisiones aquí son deliberadas:

La tabla de arriba es la salida de este archivo de pruebas, que se ejecuta en alrededor de un segundo con 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));
  }
}

Conectar la política al NavigationDelegate

Los pasos, en orden:

  1. Agrega los paquetes: flutter pub add webview_flutter url_launcher. webview_flutter 4.14.1 requiere Flutter 3.38 o posterior, Android SDK 24+ e iOS 13+.
  2. Crea el WebViewController una sola vez, en initState, no en build.
  3. Llama a setNavigationDelegate con un onNavigationRequest que mapee cada LinkAction a un NavigationDecision.
  4. Para openExternally, dispara launchUrl con LaunchMode.externalApplication y devuelve prevent de inmediato.
  5. Llama a loadRequest al final, después de que el delegate esté 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),
    );
  }
}

flutter analyze no reporta problemas en este archivo. Vale la pena destacar dos detalles.

El callback retorna de forma síncrona. onNavigationRequest acepta un Future<NavigationDecision>, pero en iOS el plugin hace await de tu callback dentro de decidePolicyForNavigationAction de WebKit, así que cada milisegundo que pasas ahí es un milisegundo en que la página queda congelada. Esperar a launchUrl (que espera a que el sistema operativo cambie de app) es exactamente lo que no debes hacer ahí. Decide de forma síncrona, retorna y lanza en segundo plano con unawaited.

La comprobación de mounted después de await launchUrl está ahí porque el usuario puede haber cerrado la página para cuando el sistema operativo responda. Si ese patrón es nuevo para ti, lo cubrí en detalle en usar BuildContext de forma segura después de un await.

Lo que Android e iOS realmente envían a onNavigationRequest

Esta es la parte que la documentación pasa por alto con “some platforms may also trigger this callback from calls to loadRequest”. Leí las implementaciones de plataforma en webview_flutter_android 4.14.1 y webview_flutter_wkwebview 3.26.1, y las dos se comportan de forma muy distinta.

Android: el lado nativo cancela primero, Dart vuelve a emitir

En Android el callback lo dispara WebViewClient.shouldOverrideUrlLoading. Cuando configuras onNavigationRequest, el plugin llama a setSynchronousReturnValueForShouldOverrideUrlLoading(true). A partir de ahí, el WebViewClientProxyApi nativo devuelve request.isForMainFrame() && true para cada navegación: cada navegación de frame principal se cancela de inmediato, antes de que siquiera se le haya preguntado a Dart. Luego Dart ejecuta tu callback y, si devuelve navigate, el plugin llama a loadUrl con la misma URL y los encabezados de la solicitud original.

Consecuencias:

iOS y macOS: WebKit espera tu respuesta

En WebKit el callback lo dispara WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:). El plugin espera tu callback y mapea navigate a .allow y prevent a .cancel. Nada se vuelve a emitir, así que los cuerpos de POST y los encabezados se conservan intactos.

Consecuencias:

Si necesitas vigilar los iframes de forma consistente en ambas plataformas, el navigation delegate es la herramienta equivocada. Envía un encabezado Content-Security-Policy con frame-src desde tu propio servidor, que ambos WebViews respetan.

Trampas que dejan pasar tráfico por fuera de la lista de permitidos

El navigation delegate controla las navegaciones de página. No ve:

Tres más que muerden en la práctica:

Si tu WebView muestra tu propia compilación de Flutter web en lugar de un sitio normal, el enrutamiento dentro de la app vive en tu router, no en el WebView, y rutas anidadas y deep links con go_router es el artículo más relevante. El escalado de fuentes dentro de esa compilación embebida de Flutter web es otra trampa distinta que documenté en Flutter Text renderizándose fuera de pantalla en un WebView de Android.

Las decisiones asíncronas se comportan distinto según la plataforma

Como Android cancela primero y vuelve a emitir después, un callback async en Android nunca bloquea la página: la página anterior permanece en pantalla, completamente interactiva, hasta que tu Future se completa y el plugin llama a loadUrl. Si el usuario toca un segundo enlace mientras tanto, ambas decisiones se ejecutan y gana el loadUrl que se ejecute al último. En iOS el mismo callback async mantiene abierto el decision handler de WebKit, así que la página espera. Si tu política realmente necesita E/S (por ejemplo, obtener una lista de permitidos remota), cárgala una vez antes de que se abra la página y mantén onNavigationRequest síncrono, como en el ejemplo de arriba. Eso da un comportamiento idéntico en ambas plataformas.

Si necesitas un control que la API multiplataforma no expone, como interceptar solicitudes de subrecursos con shouldInterceptRequest, no hay ningún hook de Dart para eso en 4.14.1. Eso implica código nativo, y aplica el enfoque de agregar código específico de plataforma en Flutter sin plugins. Prueba primero la lista de permitidos; es suficiente para la gran mayoría de las páginas embebidas.

Por último, recuerda que cualquier cosa que incluyas en el binario de la app, incluida la lista de permitidos, la puede leer cualquiera que desempaquete el APK o el IPA. Eso está bien para una lista de nombres de host, pero es un buen recordatorio de lo que un atacante puede extraer de una app de Flutter: la lista de permitidos protege a tus usuarios de desviarse, no es un secreto.

Fuentes

Comments

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

< Volver