Firebase Security Rules: los 10 errores que encontramos en el 80% de las startups

Firestore, Realtime Database y Storage: los 10 patrones inseguros que encontramos repetidos en escaneos de startups. Con código reproducible bueno vs malo y reglas copy-paste.

K
Kevin Reyes
11 min de lectura

Firebase es el backend más usado por startups en fase seed y serie A en España. No por casualidad: arranque en horas, auth gratis, realtime out-of-the-box, free tier generoso. El problema es que Security Rules — el único muro que separa tu base de datos de Internet — es donde casi nadie pone el esfuerzo que merece.

En los escaneos que hace Vulnerabbit a aplicaciones Firebase (tanto Firestore como Realtime Database como Storage), vemos los mismos 10 patrones una y otra vez. No son bugs de nicho: son errores que aparecen en la mayoría de las startups que analizamos. Y todos son prevenibles si sabes qué buscar.

Este post es código. Cada sección tiene el patrón inseguro que vemos, por qué es peligroso, y la regla correcta copy-paste lista.

Antes de empezar: el modelo mental

Firebase Security Rules no son middleware. No son un ORM. Son reglas declarativas evaluadas en el servidor de Firebase antes de cada lectura o escritura. No confían en nada que venga del cliente excepto el token de autenticación firmado por Firebase Auth.

Dos verdades incómodas:

  1. Todo lo que no esté explícitamente permitido, está denegado. Por defecto, una base Firestore recién creada rechaza todo. Buena noticia.
  2. Todo lo que esté permitido, está permitido para cualquiera que cumpla la regla. Si escribes allow read: if request.auth != null, cualquier usuario autenticado de tu proyecto puede leer todo. Mala noticia si no lo esperabas.

Con esto claro, vamos a los errores.

Error #1 — allow read, write: if true; en producción

El patrón:

// Firestore rules — NUNCA HAGAS ESTO
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

Por qué aparece: el console de Firebase sugiere reglas abiertas "para desarrollo" con expiración automática en 30 días. Muchos equipos lo mantienen o lo renuevan sin revisar.

Qué pasa: cualquier persona del planeta puede leer y escribir toda tu base de datos. Sin auth. Sin límite. Se puede enumerar en segundos con firebase-tools o un curl básico a la REST API de Firestore.

La regla correcta:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Por defecto, denegar todo
    match /{document=**} {
      allow read, write: if false;
    }

    // Y luego, reglas específicas por colección (ver siguientes errores)
    match /users/{userId} {
      allow read: if request.auth != null && request.auth.uid == userId;
      allow write: if request.auth != null && request.auth.uid == userId;
    }
  }
}

Cómo detectarlo en tu proyecto: revisa firestore.rules y busca if true. Si lo encuentras en algo que no sea un test local, tienes un problema crítico.

Error #2 — allow read: if request.auth != null; global

El patrón:

match /{document=**} {
  allow read: if request.auth != null;
}

Por qué aparece: la progresión natural tras darse cuenta del error #1. "Ya, pongo que requiera login". Problema: en tu app, cualquier usuario registrado puede leer datos de cualquier otro usuario.

Qué pasa: un atacante crea una cuenta legítima, obtiene un token de Firebase Auth, y lee la base completa. En apps sociales o marketplaces donde el registro es abierto, esto es equivalente a no tener seguridad.

La regla correcta: scope por documento.

match /users/{userId} {
  allow read: if request.auth != null && request.auth.uid == userId;
}

match /posts/{postId} {
  allow read: if request.auth != null &&
              resource.data.authorId == request.auth.uid;
}

// Posts públicos explícitos
match /publicPosts/{postId} {
  allow read: if true;  // o: if request.auth != null si quieres al menos login
}

Error #3 — Validación de esquema ausente en escrituras

El patrón:

match /orders/{orderId} {
  allow create: if request.auth != null;
}

Por qué aparece: el ingeniero confía en que el frontend valide los campos. Spoiler: cualquier cliente puede llamar directamente a la API de Firestore con Postman y saltarse la validación frontend por completo.

Qué pasa: un atacante crea orders con total: -100 (descuento) o con un campo refunded: true que tu backend nunca chequea. Bienvenido al fraude de IDOR sin IDOR.

La regla correcta:

match /orders/{orderId} {
  allow create: if request.auth != null
                && request.resource.data.keys().hasAll(['userId', 'total', 'items', 'createdAt'])
                && request.resource.data.keys().hasOnly(['userId', 'total', 'items', 'createdAt'])
                && request.resource.data.userId == request.auth.uid
                && request.resource.data.total is number
                && request.resource.data.total > 0
                && request.resource.data.total < 100000
                && request.resource.data.items is list
                && request.resource.data.items.size() > 0
                && request.resource.data.items.size() <= 50
                && request.resource.data.createdAt == request.time;
}

Patrón clave: hasAll() + hasOnly() fuerza campos exactos. request.time evita timestamp manipulation.

Error #4 — No proteger campos contra mutación

El patrón:

match /users/{userId} {
  allow update: if request.auth != null && request.auth.uid == userId;
}

Por qué aparece: regla razonable — el usuario puede actualizar su propio perfil. Olvida que campos como role, plan, credits, isAdmin NO deberían poder modificarse desde cliente.

Qué pasa: un usuario autenticado hace users/miUid con body { role: "admin", credits: 999999 }. Enhorabuena, has creado un admin.

La regla correcta:

match /users/{userId} {
  allow read: if request.auth != null && request.auth.uid == userId;

  // Crear sólo en onboarding, sin privilegios
  allow create: if request.auth != null
                && request.auth.uid == userId
                && request.resource.data.role == 'user'
                && !('credits' in request.resource.data || 'isAdmin' in request.resource.data);

  // Actualizar: no se pueden tocar campos privilegiados
  allow update: if request.auth != null
                && request.auth.uid == userId
                && request.resource.data.role == resource.data.role
                && request.resource.data.plan == resource.data.plan
                && (!('credits' in request.resource.data) ||
                    request.resource.data.credits == resource.data.credits)
                && (!('isAdmin' in request.resource.data) ||
                    request.resource.data.isAdmin == resource.data.isAdmin);
}

Helper pattern — si repites esto mucho:

function immutableFields(fields) {
  return fields.hasOnly(fields);  // shorthand
}

function onlyChangedFields(before, after, mutable) {
  return after.diff(before).affectedKeys().hasOnly(mutable);
}

// Uso:
allow update: if request.auth.uid == userId
              && onlyChangedFields(resource.data, request.resource.data,
                                    ['displayName', 'avatarUrl', 'bio']);

Error #5 — Exponer colecciones con {document=**} sin saberlo

El patrón:

match /{document=**} {
  allow read: if request.auth.token.admin == true;
}

Por qué aparece: se piensa como una regla de "admin puede ver todo". Problema: {document=**} es recursivo y captura también subcolecciones que quizás tienen reglas específicas que querías que fueran distintas.

Qué pasa: una regla recursiva en un path superior sobrescribe y permite acceso aunque haya una regla restrictiva específica más abajo. Es uno de los errores conceptuales más comunes en Firestore.

La regla correcta: especifica el path, no uses {document=**} a menos que sepas exactamente qué barres.

// Bien: admin sobre colecciones específicas
match /users/{userId} {
  allow read: if request.auth.token.admin == true;
}
match /orders/{orderId} {
  allow read: if request.auth.token.admin == true;
}

// Si de verdad quieres global, documéntalo:
// NOTA: Esta regla aplica a TODA la base de datos incluyendo subcolecciones futuras.
match /{document=**} {
  allow read: if request.auth.token.admin == true;
}

Error #6 — Reglas que hacen lecturas sin cuenta (facturación)

El patrón:

match /posts/{postId} {
  allow read: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.isPremium == true;
}

Por qué aparece: parece elegante: un usuario sólo puede leer posts si es premium. En la práctica, cada lectura hace una lectura adicional a users/{uid}. Tu factura de Firestore se duplica sin que te enteres.

Qué pasa: la factura vuela. Además, si la colección recibe muchas lecturas concurrentes, empiezas a pagar más de lo esperado por el mismo usuario repitiendo la sub-lectura.

La regla correcta: guarda los flags de permisos en custom claims del token de Firebase Auth, no en Firestore:

// En tu Cloud Function al cambiar plan:
// admin.auth().setCustomUserClaims(uid, { isPremium: true })

// En la regla — cero lecturas extra:
match /posts/{postId} {
  allow read: if request.auth.token.isPremium == true;
}

Cuándo SÍ usar get() en reglas: cuando el permiso depende de datos dinámicos de negocio (ej: "puedo editar este doc si mi uid está en doc.editors"). En ese caso, la lectura adicional es inevitable — minimízala usando el patrón resource.data cuando puedas.

Error #7 — exists() / get() sin manejar el caso ausente

El patrón:

match /messages/{msgId} {
  allow read: if get(/databases/$(database)/documents/chats/$(resource.data.chatId))
                .data.members.hasAny([request.auth.uid]);
}

Por qué aparece: asumimos que el chat siempre existe. Si no existe (borrado, race condition), la regla tira una excepción y cierra el acceso silenciosamente, lo que puede parecer un bug de auth al usuario.

Qué pasa: peor en updates. Una regla que falla por null puede dejar un documento en estado inconsistente.

La regla correcta: usa exists() antes de get() o usa .get(default):

match /messages/{msgId} {
  allow read: if exists(/databases/$(database)/documents/chats/$(resource.data.chatId))
              && get(/databases/$(database)/documents/chats/$(resource.data.chatId))
                   .data.members.hasAny([request.auth.uid]);
}

Error #8 — Storage Rules copiadas del template "auth required"

El patrón (firebase.storage rules):

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

Por qué aparece: el template por defecto de Storage en el console. "Cualquier usuario autenticado puede leer y escribir en mi bucket." ¿Qué podría salir mal?

Qué pasa: (a) cualquier usuario puede subir archivos arbitrarios sin límite de tamaño → bills + posible hosting de contenido ilegal con tu dominio; (b) cualquier usuario puede leer los archivos privados de los demás.

La regla correcta:

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // Avatares: lectura pública, escritura propia con límites
    match /avatars/{userId}/{fileName} {
      allow read: if true;
      allow write: if request.auth != null
                   && request.auth.uid == userId
                   && request.resource.size < 5 * 1024 * 1024  // 5 MB max
                   && request.resource.contentType.matches('image/.*');
    }

    // Archivos privados: sólo propietario
    match /users/{userId}/private/{fileName} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }

    // Todo lo demás: denegado explícito
    match /{allPaths=**} {
      allow read, write: if false;
    }
  }
}

Error #9 — Realtime Database con reglas JSON obsoletas

El patrón (rules.json):

{
  "rules": {
    ".read": true,
    ".write": true
  }
}

Por qué aparece: Realtime Database es el "primo viejo" de Firestore. Muchos proyectos legacy lo usan, y el console también ofrece reglas abiertas por 30 días.

Qué pasa: igual que Firestore con if true, toda la base es pública.

La regla correcta:

{
  "rules": {
    "users": {
      "$uid": {
        ".read": "auth != null && auth.uid == $uid",
        ".write": "auth != null && auth.uid == $uid",
        ".validate": "newData.hasChildren(['displayName', 'createdAt'])"
      }
    },
    "publicData": {
      ".read": true,
      ".write": "auth != null && auth.token.admin == true"
    },
    "$other": {
      ".read": false,
      ".write": false
    }
  }
}

Nota importante: Realtime Database tiene herencia de permisos diferente a Firestore. Una regla ".read": true en un nodo da acceso a todo el subárbol. Siempre pon ".read": false explícito en raíz.

Error #10 — No testear las reglas

El patrón: no hay test. Las reglas se despliegan y se ruega.

Por qué aparece: testear reglas Firebase requiere el Firestore emulator + un runner. Se percibe como esfuerzo alto.

Qué pasa: cada refactor introduce un silent regression. Nadie se entera hasta que un auditor, un bug bounty, o un incidente lo revela.

La regla correcta: test suite con @firebase/rules-unit-testing:

// tests/firestore.rules.test.js
const {
  initializeTestEnvironment,
  assertFails,
  assertSucceeds
} = require('@firebase/rules-unit-testing');

const PROJECT_ID = 'my-test-project';

describe('users collection rules', () => {
  let testEnv;

  beforeAll(async () => {
    testEnv = await initializeTestEnvironment({
      projectId: PROJECT_ID,
      firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') }
    });
  });

  afterAll(async () => {
    await testEnv.cleanup();
  });

  test('authenticated user can read own profile', async () => {
    const alice = testEnv.authenticatedContext('alice').firestore();
    await assertSucceeds(alice.doc('users/alice').get());
  });

  test('authenticated user CANNOT read another user profile', async () => {
    const alice = testEnv.authenticatedContext('alice').firestore();
    await assertFails(alice.doc('users/bob').get());
  });

  test('user cannot escalate to admin via update', async () => {
    const alice = testEnv.authenticatedContext('alice').firestore();
    await assertFails(
      alice.doc('users/alice').update({ role: 'admin' })
    );
  });
});

Ejecutar en CI:

firebase emulators:exec --only firestore "npm test"

Si tienes 20 minutos a la semana para dedicar a seguridad Firebase, invertirlos aquí. Cada test previene un incidente futuro.

Checklist de revisión rápida

Ahora mismo, abre tu firestore.rules (o storage.rules o database.rules.json) y busca:

☐ ¿Hay algún "if true" en producción?
☐ ¿Hay reglas "auth != null" sin filtrar por uid o resource?
☐ ¿Las reglas de update validan que los campos privilegiados no cambien?
☐ ¿Los create validan request.resource.data contra hasAll + hasOnly?
☐ ¿Los role/plan/isAdmin están en custom claims, no en Firestore?
☐ ¿Storage tiene content-type y size limits en los uploads?
☐ ¿Hay tests automatizados de las reglas en CI?
☐ ¿Las reglas con {document=**} están documentadas o eliminadas?

Si marcas menos de 6, tu configuración actual está en el 80% con fallos comunes. No es personal — es lo que encontramos en casi todos los escaneos.


Cómo encaja Vulnerabbit aquí

Lo que acabas de leer es el tipo de hallazgo que nuestro escáner detecta de forma automática en cualquier proyecto Firebase que le apuntes. Si quieres ver qué encuentra en tu stack sin configurar nada, lanza un escaneo gratuito — sin registro, sin tarjeta, en minutos.

Si estás en medio de una implantación mayor (ISO 27001, ENS, migración cloud) y te interesa acompañamiento además de herramienta, escríbenos: seleccionamos un número reducido de engagements al año.


¿Qué leer después?

Comprueba si tu dominio es vulnerable

Nuestro escáner gratuito analiza SSL, cabeceras de seguridad, DNS y configuración de email en menos de 5 minutos. Sin instalar nada.

Lanzar Auditoría Gratuita
Kevin Reyes

Escrito por

Kevin Reyes

Fundador & CEO de Vulnerabbit

Ingeniero DevSecOps con más de 7 años de experiencia en cloud security, CI/CD y automatización de infraestructura. Fundó Vulnerabbit con la misión de hacer la ciberseguridad sencilla, proactiva y accesible para startups y pymes.

LinkedIn
Escanea tu dominio gratis