wandres.dev
INTEROPERAR · compartir entre apps

Content providers: publicar datos para otras aplicaciones

Un proveedor de contenidos es la única forma que Android ofrece de publicar una base de datos como interfaz pública entre procesos, y precisamente por eso es el componente que más veces se escribe sin necesitarse. Esta lección explica su mecánica real, del emparejador de rutas al viaje por Binder y la peculiaridad de que su creación ocurre antes que la de la aplicación, y separa con nitidez los casos en que publicar uno es la respuesta correcta de aquellos en que solo añade una superficie de ataque. La segunda mitad está dedicada al riesgo de exportar de más: el valor por defecto del atributo según la versión, los permisos de lectura y escritura con nivel de firma, las concesiones acotadas por ruta, la inyección de SQL a través de la selección y la proyección, el recorrido de directorios al abrir ficheros, y la disciplina de verificar quién llama en lugar de creerse lo que dice.

⏱ 22 min

De los cuatro componentes clásicos de Android, el proveedor de contenidos es el único que no tiene interfaz, no responde a un gesto del usuario y puede ejecutarse sin que nadie haya abierto la aplicación que lo contiene. Es, literalmente, una base de datos con una dirección pública: cualquier proceso del dispositivo que conozca su autoridad y tenga los permisos necesarios puede consultarla, insertar filas, modificarlas o borrarlas, y hacerlo mientras tu aplicación lleva semanas sin abrirse. Esa potencia explica su prestigio histórico y también su declive: durante años se enseñó como la capa de datos canónica de cualquier aplicación, cuando en realidad es una API entre procesos, y escribir una API entre procesos para hablar contigo mismo es una de las peores decisiones arquitectónicas disponibles. La pregunta correcta nunca ha sido cómo se escribe un proveedor, sino si hace falta, y la respuesta honesta es que casi nunca.

🎯 Al terminar esta lección sabrás
  • Describir la mecánica real de un proveedor: autoridad, emparejador de rutas, viaje por Binder y momento de creación.
  • Decidir con criterio cuándo publicar un proveedor y qué alternativas cubren mejor los casos habituales.
  • Configurar la exposición con permisos de lectura y escritura, nivel de firma y concesiones acotadas por ruta.
  • Neutralizar inyección de SQL, proyecciones abiertas y recorrido de directorios, verificando la identidad de quien llama.

Qué es realmente un proveedor

Del lado del cliente todo pasa por el resolutor de contenidos, que recibe una referencia, localiza la autoridad en el registro del sistema, encuentra el proceso que la publica y le envía la operación por Binder. Si ese proceso no está vivo, el sistema lo arranca. Del lado del proveedor llega una llamada en un hilo del grupo de Binder, nunca en el hilo principal, y con la referencia completa que hay que interpretar.

Esa interpretación se hace con un emparejador de rutas que traduce patrones en códigos. Es el equivalente a un enrutador de una API web, y como en cualquier enrutador, todo lo que no esté contemplado explícitamente debe rechazarse en lugar de caer en una rama genérica.

private const val AUTORIDAD = "com.ejemplo.notas.provider"
private const val NOTAS = 1
private const val NOTA_ID = 2

private val rutas = UriMatcher(UriMatcher.NO_MATCH).apply {
    addURI(AUTORIDAD, "notas", NOTAS)
    addURI(AUTORIDAD, "notas/#", NOTA_ID)
}

override fun query(
    uri: Uri, projection: Array<String>?, selection: String?,
    selectionArgs: Array<String>?, sortOrder: String?,
): Cursor = when (rutas.match(uri)) {
    NOTAS -> dao.consultaSegura(projection, selection, selectionArgs, sortOrder)
    NOTA_ID -> dao.porId(ContentUris.parseId(uri))
    else -> throw IllegalArgumentException("Ruta no soportada")
}

Hay una peculiaridad de ciclo de vida que conviene tener presente porque tiene consecuencias sorprendentes: los proveedores declarados en el manifiesto se instancian y reciben su llamada de creación antes de que la clase de aplicación reciba la suya. Durante años ese hueco fue el truco preferido de las bibliotecas para inicializarse solas sin pedir nada al desarrollador, hasta que la proliferación de proveedores fantasma en el arranque se volvió un problema de rendimiento medible y aparecieron mecanismos de inicialización unificados. Si tu proveedor hace trabajo pesado al crearse, ese trabajo está en el camino crítico de arranque de tu aplicación.

Cuándo tiene sentido publicar uno

La lista de casos legítimos es corta y conviene tenerla escrita. Publicar datos para otras aplicaciones que no controlas, cuando existe un ecosistema real que los consumirá. Integrarse con mecanismos de la plataforma que solo saben hablar este idioma, como las sugerencias de búsqueda del sistema, ciertos adaptadores de sincronización o los widgets de pantalla de inicio. Compartir ficheros, que es un caso especializado con su propia implementación estándar. Y exponer datos a otras aplicaciones de la misma familia firmadas con la misma clave, donde el proveedor con permisos de firma es una solución limpia.

Fuera de esa lista, la respuesta suele ser otra. Para la capa de datos interna, la biblioteca de persistencia ya ofrece consultas tipadas, migraciones y observación reactiva sin serializar nada ni cruzar procesos. Para entregar un fichero puntual, la referencia de contenido con concesión temporal resuelve el caso completo. Para una integración compleja con una aplicación concreta, un servicio enlazado con una interfaz definida da tipos, métodos y control de acceso sin obligarte a modelar todo como filas y columnas.

El coste de equivocarse no es solo arquitectónico. Cada proveedor exportado es una superficie permanente que hay que mantener compatible durante años, documentar, versionar y defender, y que aparecerá en cualquier auditoría de seguridad como el primer sitio donde mirar.

flowchart TD
A[Otra app llama al resolutor] --> B[El sistema localiza la autoridad]
B --> C{El proceso esta vivo}
C -->|no| D[El sistema arranca el proceso]
C -->|si| E[Llamada por Binder]
D --> E
E --> F{Esta exportado}
F -->|no| G[Se rechaza salvo concesion sobre una referencia]
F -->|si| H[Comprobacion de permisos de lectura o escritura]
H --> I[Emparejador de rutas]
I --> J{Ruta reconocida}
J -->|no| K[Excepcion de argumento ilegal]
J -->|si| L[Consulta parametrizada con proyeccion controlada]

Exportar de más: la superficie que no querías

El atributo de exportación es el interruptor principal y su valor por defecto depende de la versión declarada: en aplicaciones muy antiguas era verdadero, y desde la API 17 es falso cuando el nivel mínimo lo permite. Confiar en ese valor implícito es un error, porque el manifiesto se lee sin contexto y quien lo audite no debería tener que calcular nada. Se declara siempre, de forma explícita, en todos los proveedores.

Cuando la exportación es necesaria, la protección se construye en capas. Un permiso propio con nivel de firma restringe el acceso a aplicaciones firmadas con tu misma clave, que es exactamente lo que se quiere en una familia de aplicaciones. Permisos separados de lectura y de escritura permiten que un consumidor pueda leer sin poder modificar. Y las concesiones acotadas por ruta permiten dejar el componente cerrado en general y abrir excepciones sobre elementos concretos mediante los mecanismos de concesión temporal.

<permission
    android:name="com.ejemplo.notas.LEER"
    android:protectionLevel="signature" />

<provider
    android:name=".NotasProvider"
    android:authorities="com.ejemplo.notas.provider"
    android:exported="true"
    android:readPermission="com.ejemplo.notas.LEER"
    android:writePermission="com.ejemplo.notas.ESCRIBIR"
    android:grantUriPermissions="false">
    <grant-uri-permission android:pathPrefix="/notas/publicas" />
</provider>

Las tres vulnerabilidades clásicas siguen apareciendo con puntualidad en cada auditoría. La primera es la inyección de SQL: la selección y sus argumentos llegan de fuera, y concatenarlos en la consulta entrega el control de la base de datos a quien llame. Se usan siempre parámetros, y en los proveedores serios se ignora directamente la selección del cliente en favor de una construida internamente a partir de la ruta. La segunda es la proyección abierta: aceptar la lista de columnas que pida el cliente permite leer campos que no pensabas publicar, y la defensa es un mapa de proyección que traduce nombres públicos a columnas reales y descarta lo demás. La tercera es el recorrido de directorios al abrir ficheros, cuando la ruta se construye a partir del segmento final de la referencia recibida; la defensa es canonicalizar y comprobar que el resultado sigue estando dentro del directorio permitido.

Las tres defensas caben en muy poco código, y su ausencia también, que es justo lo que las hace fáciles de olvidar en una revisión rápida.

private val proyeccionPublica = mapOf(
    "id" to "notas._id",
    "titulo" to "notas.titulo",
    // el campo interno de sincronizacion no figura: no existe para el exterior
)

override fun query(
    uri: Uri, projection: Array<String>?, selection: String?,
    selectionArgs: Array<String>?, sortOrder: String?,
): Cursor {
    require(rutas.match(uri) != UriMatcher.NO_MATCH) { "Ruta no soportada" }

    val columnas = (projection ?: proyeccionPublica.keys.toTypedArray())
        .mapNotNull { proyeccionPublica[it] }        // lo no mapeado se descarta
    require(columnas.isNotEmpty()) { "Proyeccion vacia" }

    // la seleccion del cliente se ignora: se construye desde la ruta
    return db.query(
        SupportSQLiteQueryBuilder.builder("notas")
            .columns(columnas.toTypedArray())
            .selection("visible = ?", arrayOf(1))
            .create()
    )
}

Queda un principio transversal: nunca creerse lo que el cliente dice de sí mismo. El nombre de paquete que llega en un extra es un dato manipulable. La identidad real se obtiene del propio Binder, y la comprobación de permisos se hace con las llamadas que consultan al sistema por el proceso que está al otro lado.

🔗

Es una API pública

Un proveedor exportado es un contrato que hay que mantener durante años, no una capa interna.

🔐

Firma antes que nada

Para una familia de aplicaciones propias, el permiso con nivel de firma es la protección correcta.

💉

Selección y proyección

Parámetros siempre y mapa de proyección siempre: son las dos fugas más comunes del componente.

🪪

Pregunta al Binder

La identidad de quien llama se consulta al sistema, jamás se lee de un extra del intent.

Exportar es publicar un esquema y renunciar a cambiarlo

Lo que hace peligroso a este componente no es la posibilidad de un fallo puntual de validación, que se corrige con una revisión atenta, sino la naturaleza de lo que se está creando. Al exportar un proveedor no publicas una funcionalidad: publicas un esquema de datos, con sus nombres de tabla convertidos en rutas, sus columnas convertidas en contrato y su semántica convertida en promesa. A partir de ese momento existe, en algún lugar del mundo, código escrito por gente que no conoces que depende de que la columna que llamaste con un nombre provisional siga llamándose así, de que el identificador que devuelves siga siendo numérico y de que borrar una fila siga significando lo mismo. Ese código no se actualiza cuando tú refactorizas, no aparece en tus pruebas y no te avisa; simplemente empieza a fallar en dispositivos de usuarios que no entienden por qué dos aplicaciones que funcionaban han dejado de entenderse. La asimetría es brutal: el coste de publicar es una tarde, el coste de mantener es indefinido, y el coste de retirar es un ecosistema roto. Por eso la decisión de exportar debe tomarse con la misma seriedad con la que se publica una API web con clientes externos, e implica todo lo que implica esa comparación: una clase de contrato con las constantes públicas separada de la implementación, un versionado explícito de las rutas, una capa de traducción entre lo que se publica y cómo se almacena realmente por dentro, y pruebas escritas desde fuera, con otro paquete y otra firma, que ejerciten la superficie tal y como la ve un tercero. Y hay una consecuencia final que se aprecia poco: como el proveedor puede despertar tu proceso sin que el usuario haya abierto nada, cada consulta ajena es también un consumo de batería y de memoria que tu aplicación paga sin verlo. Publicar datos no es solo abrir una puerta; es aceptar que otros decidan cuándo se enciende tu casa.

⚔️ Ataca tu propio proveedor desde otro paquete
  1. Escribe un proveedor mínimo sobre una base de datos con dos tablas, una publicable y otra que no debería salir jamás.
  2. Expórtalo sin permisos y léelo desde una segunda aplicación de prueba firmada con otra clave; comprueba que no hay nada que lo impida.
  3. Concatena la selección recibida en la consulta y extrae la tabla privada desde la aplicación atacante; después arréglalo con parámetros.
  4. Acepta la proyección del cliente, lee una columna que no habías publicado y defiéndete con un mapa de proyección.
  5. Sustituye la exportación abierta por un permiso de nivel de firma, verifica que la aplicación atacante deja de tener acceso y que la de tu misma clave lo conserva.