¿Qué puede salir mal con los deep links / el manejo de intents, y cómo los asegurás?
Un deep link es un punto de entrada que cualquier app del dispositivo puede intentar disparar — tratá los datos que trae con la misma sospecha que una entrada de red no confiable, no como algo que sólo produjo tu propia UI.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué puede salir mal con los deep links / el manejo de intents, y cómo los asegurás?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dos mecanismos de enlace, una gran diferencia de confianza
Debajo de la frase "deep link" en realidad hay dos tecnologías distintas, y la historia de seguridad es completamente diferente para cada una. Un esquema de URI personalizado (myapp://reset?token=...) es sólo una cadena que una app registra ante el sistema operativo — nada impide que una segunda app, sin relación alguna, registre exactamente el mismo esquema. En Android, si más de una app instalada reclama myapp://, el usuario ve una hoja de desambiguación (el mejor caso) o, en configuraciones viejas o mal hechas, gana en silencio la app que el sistema elija. Un App Link verificado (Android, https:// + android:autoVerify="true" + un archivo assetlinks.json alojado en tu dominio) o un Universal Link (iOS, https:// + el entitlement associated-domains + un archivo apple-app-site-association) está atado criptográficamente a una prueba de que controlás el dominio. Una vez que la verificación tiene éxito, el sistema operativo enruta esa URL https:// exclusivamente a tu app — sin selector, y ninguna otra app puede reclamarla sin controlar también tu DNS/hosting.
#Dónde se rompe realmente
Más allá del secuestro directo de esquema, otras cuatro clases de bug aparecen constantemente en revisión:
- Inyección de parámetros en un
WebView. Un deep link que pasa un parámetrourlcrudo directo awebView.loadUrl(...)convierte tu app en un open redirect, o peor, permite que un atacante haga aterrizar una URLjavascript:o una página que corre en un origen en el que tu app confía implícitamente. - Activities/pantallas exportadas pensadas para alcanzarse "sólo después de loguearse". Si la
Activityde una pantalla sensible esexported="true"(o queda exportada implícitamente por tener un intent filter) sin chequeo de permiso ni de estado, cualquier app del dispositivo puede lanzarla directamente con unIntentarmado a medida, saltándose la guardia de navegación que tu UI normalmente impone. PendingIntents mutables entregados al sistema o a otras apps (notificaciones, widgets, shortcuts de la app). Antes de que Android 12 hiciera obligatorioFLAG_IMMUTABLE, unPendingIntentmutable podía ser completado por un componente malicioso con extras controlados por el atacante — la clase de bug popularizada como secuestro de tareas al estilo StrandHogg.- Interceptación del redirect de OAuth. Si la redirect URI de tu flujo OAuth es un esquema personalizado, otra app que registre el mismo esquema puede interceptar el código de autorización antes de que llegue a vos.
<!-- Manifest de Android: un App Link verificado, no un esquema personalizado a secas --><activity android:name=".DeepLinkActivity" android:exported="true"><intent-filter android:autoVerify="true"><action android:name="android.intent.action.VIEW" /><category android:name="android.intent.category.DEFAULT" /><category android:name="android.intent.category.BROWSABLE" /><data android:scheme="https" android:host="app.example.com" android:pathPrefix="/reset" /></intent-filter></activity>
// https://app.example.com/.well-known/assetlinks.json[{"relation": ["delegate_permission/common.handle_all_urls"],"target": {"namespace": "android_app","package_name": "com.example.app","sha256_cert_fingerprints": ["AA:BB:CC:...:FF"]}}]
#Enrutar deep links como entrada no confiable, no como navegación interna
La solución no es sólo "usar links verificados" — también hay que tratar cada parámetro que llega por uno como no confiable, exactamente igual que el cuerpo de un request. Eso significa una lista blanca explícita de destinos, nunca un router dinámico o basado en reflexión que convierta directamente una cadena de path en una pantalla o en una carga de URL.
// ANTIPATRÓN — nunca cargues un parámetro crudo de deep link en un WebViewval target = intent.data?.getQueryParameter("url")webView.loadUrl(target) // open redirect, o peor, ejecución arbitraria de JS// Correcto: una tabla de ruteo explícita con validación por destinofun handleDeepLink(intent: Intent) {val uri = intent.data ?: returnwhen (uri.path) {"/reset" -> {val token = uri.getQueryParameter("token") ?: returnif (!isValidSingleUseToken(token)) return // chequeado del lado del servidor, TTL cortonavigateTo(Screen.ResetPassword(token))}"/product" -> {val id = uri.getQueryParameter("id")?.toIntOrNull() ?: returnnavigateTo(Screen.ProductDetail(id))}else -> navigateTo(Screen.Home) // nunca caer a una carga cruda en un WebView}}
#Cerrar la superficie de ataque específica de Android
Poné android:exported="false" en cada Activity/Service/Receiver que no necesite genuinamente ser lanzado por otra app, y agregá un chequeo de permiso a nivel de firma en los que sí lo necesiten pero no deberían ser públicos. Cada PendingIntent que construyas debería llevar PendingIntent.FLAG_IMMUTABLE a menos que tengas una razón específica y documentada para dejar que el componente receptor complete los extras. Para acciones que sólo deberían dispararse desde tu propio dominio web o una app socia de confianza, chequeá Activity.getCallingPackage()/getCallingActivity() antes de actuar sobre el intent, en lugar de confiar sólo en los datos del intent.
val pendingIntent = PendingIntent.getActivity(context, requestCode, resultIntent,PendingIntent.FLAG_IMMUTABLE // obligatorio desde Android 12, igual vale la pena ser explícito)
#Los Universal Links de iOS merecen la misma disciplina
func application(_ application: UIApplication,continue userActivity: NSUserActivity,restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,let url = userActivity.webpageURL,let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {return false}// La misma disciplina de router con lista blanca que del lado de Android — nada de despacho dinámico sobre paths crudos.return router.handle(components)}
Los esquemas myapp:// personalizados igual se siguen entregando a application(_:open:options:) en iOS, con el mismo riesgo de secuestro que en Android cuando otra app registra el mismo esquema (iOS resuelve el empate por orden de instalación, algo de lo que no querés depender) — así que cualquier cosa sensible debería pasar en cambio por el camino de Universal Link verificado con apple-app-site-association.
#Todo lo que un enlace "autentica" tiene que comportarse como si fuera público
Una URL viaja por gateways de SMS, servidores de correo, los scrapers de vista previa de apps de chat, el historial del navegador, headers de referrer y capturas de pantalla — asumí que se va a filtrar. Un link de reset de contraseña o de login mágico debería llevar un token de un solo uso y TTL corto que no signifique nada sin un chequeo del lado del servidor, nunca un token de sesión real. Para flujos de OAuth lanzados desde mobile, usá PKCE (un par verifier/challenge por intento) para que, aunque un código de autorización se intercepte por una colisión de esquema, sea inútil sin el verifier que sólo tu app generó y guardó en memoria.