Saltearse al contenido

Mejores prácticas de seguridad para FireCMS self-hosted

FireCMS es una aplicación React exclusivamente de frontend. No tiene ningún componente de servidor integrado que aplique seguridad. Esto significa que toda la seguridad debe implementarse y aplicarse en tu backend. Los permisos del lado del cliente de FireCMS (el callback permissions en las colecciones) controlan la UI/UX — ocultan botones y deshabilitan formularios — pero pueden ser eludidos por cualquier usuario con acceso a las herramientas de desarrollador del navegador.

Esta guía cubre todo lo que necesitas saber para asegurar tu despliegue self-hosted de FireCMS cuando usas MongoDB, autenticación personalizada y almacenamiento de archivos personalizado — sin Firebase.


Un despliegue self-hosted seguro de FireCMS tiene la siguiente arquitectura:

┌─────────────────┐ HTTPS ┌──────────────────┐
│ FireCMS React │ ──────────────────▶ │ Tu servidor API │
│ (Navegador) │ ◀────────────────── │ (Express/Nest) │
└─────────────────┘ └──────┬───────────┘
┌──────────────────────┤
│ │
┌──────▼──────┐ ┌───────▼──────┐
│ MongoDB │ │ Almacenamiento│
│ (Base de │ │ de archivos │
│ datos) │ │ (S3/Minio) │
└─────────────┘ └──────────────┘

Puntos clave:

  • El navegador nunca se comunica directamente con MongoDB o tu backend de almacenamiento.
  • Tu servidor API es el punto de entrada único que autentica cada solicitud, autoriza la acción, valida las entradas, y luego interactúa con la base de datos y el almacenamiento.

La interfaz AuthController gestiona el estado de autenticación del usuario en el navegador. Cuando usas un backend personalizado, tu implementación debe ser un hook de React que se comunique con tu propia API.

type AuthController<USER extends User = any> = {
user: USER | null;
initialLoading?: boolean;
authLoading: boolean;
signOut: () => Promise<void>;
authError?: any;
getAuthToken: () => Promise<string>;
loginSkipped: boolean;
extra: any;
setExtra: (extra: any) => void;
};

Tu implementación de getAuthToken es la piedra angular de todo el modelo de seguridad — cada solicitud que tu DataSourceDelegate y StorageSource realicen la llamará para adjuntar credenciales.

import { useState, useCallback, useEffect } from "react";
import type { AuthController, User } from "@firecms/core";
interface CustomUser extends User {
uid: string;
displayName: string | null;
email: string | null;
photoURL: string | null;
providerId: string;
isAnonymous: boolean;
}
export function useCustomAuthController(): AuthController<CustomUser> {
const [user, setUser] = useState<CustomUser | null>(null);
const [authLoading, setAuthLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [extra, setExtra] = useState<any>(null);
// Al montar, verificar si existe una sesión
useEffect(() => {
checkSession().finally(() => setInitialLoading(false));
}, []);
const checkSession = useCallback(async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (res.ok) {
const data = await res.json();
setUser(data.user);
setAccessToken(data.accessToken);
}
} catch {
// Sin sesión válida
}
}, []);
const getAuthToken = useCallback(async (): Promise<string> => {
if (!accessToken) throw new Error("No autenticado");
return accessToken;
}, [accessToken]);
const signOut = useCallback(async () => {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "include"
});
setUser(null);
setAccessToken(null);
}, []);
return {
user,
authLoading,
initialLoading,
signOut,
getAuthToken,
loginSkipped: false,
extra,
setExtra,
};
}

Puntos clave de seguridad:

AspectoRecomendación
Almacenamiento de tokensAlmacena los JWT en cookies httpOnly, Secure, SameSite=Strict cuando sea posible. Si debes usar tokens en memoria, nunca los guardes en localStorage o sessionStorage.
Expiración de tokensUsa tokens de acceso de corta duración (5–15 minutos) con un flujo de refresh token.
Renovación de tokensImplementa la renovación transparente de tokens en getAuthToken antes de que expiren.
Invalidación de sesiónsignOut debe llamar a tu servidor para invalidar el refresh token / sesión. Un cierre de sesión solo del lado del cliente no es seguro.
Carga inicialUsa initialLoading para verificar silenciosamente si existe una sesión al montar la app.

El callback Authenticator te permite controlar qué usuarios autenticados pueden acceder a FireCMS. Úsalo para cargar el rol del usuario desde tu base de datos y adjuntarlo al controller de autenticación.

import type { Authenticator } from "@firecms/core";
const myAuthenticator: Authenticator<CustomUser> = async ({
user,
authController,
dataSourceDelegate
}) => {
if (!user?.email) return false;
try {
const users = await dataSourceDelegate.fetchCollection({
path: "cms_users",
filter: { email: ["==", user.email] }
});
if (users.length === 0) return false;
const profile = users[0].values;
authController.setExtra({ role: profile.role });
return true;
} catch (error) {
console.error("Error de autenticación:", error);
return false;
}
};

El DataSourceDelegate es la interfaz que FireCMS usa para leer y escribir datos. Cuando está respaldado por MongoDB, tu implementación debe proxear cada llamada a través de tu servidor API autenticado.

Esta es la regla más crítica. No uses el driver de MongoDB, el SDK de Realm, ni ninguna conexión directa a la base de datos en el navegador.

// ❌ PELIGROSO — acceso directo a MongoDB desde el navegador
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb+srv://user:password@cluster...");
// ✅ CORRECTO — proxear a través de tu API autenticada
const response = await fetch("/api/data/products", {
headers: { Authorization: `Bearer ${await authController.getAuthToken()}` }
});
import type {
DataSourceDelegate,
Entity,
FetchCollectionDelegateProps,
FetchEntityProps,
SaveEntityDelegateProps,
DeleteEntityProps
} from "@firecms/core";
export function useSecureMongoDelegate(
getAuthToken: () => Promise<string>
): DataSourceDelegate {
async function authenticatedFetch(url: string, options: RequestInit = {}) {
const token = await getAuthToken();
const res = await fetch(url, {
...options,
headers: {
...options.headers,
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
if (res.status === 401 || res.status === 403) {
throw new Error("No autorizado");
}
if (!res.ok) {
throw new Error(`Error de API: ${res.status}`);
}
return res.json();
}
return {
key: "secure-mongo",
initialised: true,
async fetchCollection<M extends Record<string, any>>({
path, filter, limit, startAfter, orderBy, order, searchString
}: FetchCollectionDelegateProps<M>): Promise<Entity<M>[]> {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (orderBy) params.set("orderBy", orderBy);
if (order) params.set("order", order);
if (searchString) params.set("q", searchString);
if (filter) params.set("filter", JSON.stringify(filter));
if (startAfter) params.set("startAfter", JSON.stringify(startAfter));
return authenticatedFetch(`/api/data/${path}?${params}`);
},
async fetchEntity<M extends Record<string, any>>({
path, entityId
}: FetchEntityProps<M>): Promise<Entity<M> | undefined> {
return authenticatedFetch(`/api/data/${path}/${entityId}`);
},
async saveEntity<M extends Record<string, any>>({
path, entityId, values, status
}: SaveEntityDelegateProps<M>): Promise<Entity<M>> {
const method = status === "new" ? "POST" : "PUT";
const url = entityId
? `/api/data/${path}/${entityId}`
: `/api/data/${path}`;
return authenticatedFetch(url, {
method,
body: JSON.stringify({ values })
});
},
async deleteEntity<M extends Record<string, any>>({
entity
}: DeleteEntityProps<M>): Promise<void> {
await authenticatedFetch(`/api/data/${entity.path}/${entity.id}`, {
method: "DELETE"
});
},
async checkUniqueField(
path: string, name: string, value: any, entityId?: string
): Promise<boolean> {
const result = await authenticatedFetch(
`/api/data/${path}/check-unique`,
{
method: "POST",
body: JSON.stringify({ field: name, value, entityId })
}
);
return result.unique;
},
generateEntityId(): string {
return crypto.randomUUID();
},
delegateToCMSModel: (data: any) => data,
cmsToDelegateModel: (data: any) => data,
};
}

Lista de verificación de seguridad del servidor

Sección titulada «Lista de verificación de seguridad del servidor»

Tu servidor API (ej. Express, Fastify, NestJS) debe aplicar lo siguiente en cada solicitud:

// Ejemplo de middleware Express
import jwt from "jsonwebtoken";
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Token faltante" });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: "Token inválido" });
}
}
function authorize(requiredRole: string) {
return (req, res, next) => {
const user = req.user;
if (!user) return res.status(401).json({ error: "No autenticado" });
if (user.role !== "admin" && user.role !== requiredRole) {
return res.status(403).json({ error: "Permisos insuficientes" });
}
next();
};
}
app.delete("/api/data/:path/:id", authenticate, authorize("admin"), async (req, res) => {
// Solo los administradores pueden eliminar
});

Validación de entrada y prevención de inyección NoSQL

Sección titulada «Validación de entrada y prevención de inyección NoSQL»

MongoDB es vulnerable a la inyección NoSQL cuando la entrada del usuario se pasa directamente a operadores de consulta.

// ❌ VULNERABLE — la entrada del usuario va directamente a la consulta
app.get("/api/data/:collection", async (req, res) => {
const filter = JSON.parse(req.query.filter); // el atacante puede inyectar {$gt: ""}
const docs = await db.collection(req.params.collection).find(filter).toArray();
res.json(docs);
});
// ✅ SEGURO — sanitizar y usar lista blanca
import mongo from "mongo-sanitize";
app.get("/api/data/:collection", authenticate, async (req, res) => {
// 1. Lista blanca de colecciones permitidas
const allowedCollections = ["products", "orders", "categories"];
if (!allowedCollections.includes(req.params.collection)) {
return res.status(400).json({ error: "Colección inválida" });
}
// 2. Sanitizar el filtro para eliminar operadores de MongoDB
let filter = {};
if (req.query.filter) {
filter = mongo.sanitize(JSON.parse(req.query.filter));
}
// 3. Aplicar límites
const limit = Math.min(parseInt(req.query.limit) || 25, 100);
const docs = await db
.collection(req.params.collection)
.find(filter)
.limit(limit)
.toArray();
res.json(docs);
});

Validaciones clave:

VerificaciónPor qué
Lista blanca de coleccionesPrevenir acceso a colecciones del sistema (admin, local) o colecciones internas
Sanitizar operadores de filtroBloquear $where, $gt, $regex y otros operadores que pueden ser inyectados
Limitar tamaño de resultadosPrevenir denegación de servicio mediante consultas sin límite
Validar campos de orderBySolo permitir ordenamiento en campos indexados/conocidos
Validar formato de entityIdAsegurar que los IDs coincidan con el formato esperado (ej. UUID o patrón ObjectId)
Validar values al guardarEjecutar validación de esquema (ej. Zod, Joi) en el servidor antes de escribir

La interfaz StorageSource maneja la subida y descarga de archivos. Cuando usas almacenamiento personalizado (S3, MinIO, GCS, o un sistema de archivos local), el principio clave es: nunca expongas credenciales de almacenamiento al navegador.

interface StorageSource {
uploadFile: (props: UploadFileProps) => Promise<UploadFileResult>;
getDownloadURL: (pathOrUrl: string, bucket?: string) => Promise<DownloadConfig>;
getFile: (path: string, bucket?: string) => Promise<File | null>;
deleteFile: (path: string, bucket?: string) => Promise<void>;
list: (path: string, options?: { ... }) => Promise<StorageListResult>;
}

En lugar de pasar credenciales de S3/GCS al navegador, haz que tu servidor genere URLs pre-firmadas de corta duración:

import type { StorageSource, UploadFileProps, UploadFileResult, DownloadConfig } from "@firecms/core";
export function useSecureStorageSource(
getAuthToken: () => Promise<string>
): StorageSource {
async function authenticatedFetch(url: string, options: RequestInit = {}) {
const token = await getAuthToken();
return fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` }
});
}
return {
async uploadFile({ file, fileName, path }: UploadFileProps): Promise<UploadFileResult> {
const usedFileName = fileName ?? file.name;
const destinationPath = `${path}/${usedFileName}`;
// 1. Obtener una URL pre-firmada de subida desde tu servidor
const res = await authenticatedFetch("/api/storage/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: destinationPath,
contentType: file.type,
size: file.size
})
});
const { uploadUrl, storageUrl } = await res.json();
// 2. Subir directamente al almacenamiento usando la URL pre-firmada
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type }
});
return {
path: destinationPath,
bucket: "your-bucket",
storageUrl
};
},
async getDownloadURL(pathOrUrl: string): Promise<DownloadConfig> {
const res = await authenticatedFetch(
`/api/storage/download-url?path=${encodeURIComponent(pathOrUrl)}`
);
if (!res.ok) return { url: null, fileNotFound: true };
const data = await res.json();
return { url: data.url, metadata: data.metadata };
},
async getFile(path: string): Promise<File | null> {
const { url } = await this.getDownloadURL(path);
if (!url) return null;
const res = await fetch(url);
const blob = await res.blob();
return new File([blob], path.split("/").pop() || "file");
},
async deleteFile(path: string): Promise<void> {
await authenticatedFetch(`/api/storage/files?path=${encodeURIComponent(path)}`, {
method: "DELETE"
});
},
async list(path: string, options?: { maxResults?: number; pageToken?: string }) {
const params = new URLSearchParams({ path });
if (options?.maxResults) params.set("maxResults", String(options.maxResults));
if (options?.pageToken) params.set("pageToken", options.pageToken);
const res = await authenticatedFetch(`/api/storage/list?${params}`);
return res.json();
}
};
}

Seguridad del almacenamiento del lado del servidor

Sección titulada «Seguridad del almacenamiento del lado del servidor»

Tu endpoint de API de almacenamiento debe aplicar:

AspectoRecomendación
Validación de tipo de archivoLista blanca de tipos MIME permitidos (ej. image/jpeg, application/pdf). Rechazar ejecutables.
Límites de tamaño de archivoAplicar tamaños máximos de archivo (ej. 10 MB para imágenes, 50 MB para documentos).
Prevención de path traversalSanitizar el parámetro path. Rechazar .., rutas absolutas o bytes nulos. Nunca permitas que los clientes dicten dónde se almacenan los archivos sin validación.
Expiración de URLs pre-firmadasMantener URLs de subida/descarga de corta duración (5–15 minutos).
Escaneo de virusPara contenido subido por usuarios, considera integrar ClamAV o un servicio de escaneo en la nube.
Alcance de accesoCada URL pre-firmada debe otorgar acceso a exactamente un archivo. Usa la identidad del usuario para limitar a qué rutas puede acceder.

Ejemplo de validación de ruta del lado del servidor

Sección titulada «Ejemplo de validación de ruta del lado del servidor»
function validateStoragePath(path: string): boolean {
if (path.includes("..") || path.startsWith("/")) return false;
if (path.includes("\0")) return false;
const allowedPrefixes = ["uploads/", "images/", "documents/"];
return allowedPrefixes.some(prefix => path.startsWith(prefix));
}
function validateFileType(contentType: string): boolean {
const allowedTypes = [
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf",
"video/mp4"
];
return allowedTypes.includes(contentType);
}
app.post("/api/storage/upload-url", authenticate, (req, res) => {
const { path, contentType, size } = req.body;
if (!validateStoragePath(path)) {
return res.status(400).json({ error: "Ruta inválida" });
}
if (!validateFileType(contentType)) {
return res.status(400).json({ error: "Tipo de archivo no permitido" });
}
if (size > 10 * 1024 * 1024) { // 10 MB
return res.status(400).json({ error: "Archivo demasiado grande" });
}
// Generar y devolver URL pre-firmada...
});

4. Permisos y control de acceso basado en roles

Sección titulada «4. Permisos y control de acceso basado en roles»

FireCMS tiene un sistema de Permissions integrado y un callback PermissionsBuilder que puedes usar en cada colección. Estos son controles a nivel de UI — determinan qué botones se muestran y qué formularios son editables.

import { buildCollection } from "@firecms/core";
export const productsCollection = buildCollection({
name: "Products",
path: "products",
permissions: ({ authController }) => {
const role = authController.extra?.role;
return {
read: true,
create: role === "admin" || role === "editor",
edit: role === "admin" || role === "editor",
delete: role === "admin"
};
},
properties: {
// ...
}
});

Tu API debe aplicar las mismas reglas exactas:

// permissions.ts — lógica compartida (o replicada del lado del servidor)
type Role = "admin" | "editor" | "viewer";
const collectionPermissions: Record<string, Record<Role, {
read: boolean;
create: boolean;
edit: boolean;
delete: boolean;
}>> = {
products: {
admin: { read: true, create: true, edit: true, delete: true },
editor: { read: true, create: true, edit: true, delete: false },
viewer: { read: true, create: false, edit: false, delete: false },
},
};
function checkPermission(
collection: string,
action: "read" | "create" | "edit" | "delete",
role: Role
): boolean {
return collectionPermissions[collection]?.[role]?.[action] ?? false;
}
function requirePermission(action: "read" | "create" | "edit" | "delete") {
return (req, res, next) => {
const collection = req.params.path;
const role = req.user.role;
if (!checkPermission(collection, action, role)) {
return res.status(403).json({ error: "Permisos insuficientes" });
}
next();
};
}
app.get("/api/data/:path", authenticate, requirePermission("read"), handler);
app.post("/api/data/:path", authenticate, requirePermission("create"), handler);
app.put("/api/data/:path/:id", authenticate, requirePermission("edit"), handler);
app.delete("/api/data/:path/:id", authenticate, requirePermission("delete"), handler);

Si un usuario puede modificar su propio rol en la base de datos (ej. establecer role: "admin" en su propio documento de usuario), tu sistema de permisos está comprometido. Siempre restringe las escrituras a colecciones de usuario/roles a operaciones exclusivas de administrador.


5. Mejores prácticas generales de seguridad

Sección titulada «5. Mejores prácticas generales de seguridad»
  • Siempre usa HTTPS en producción. Usa TLS 1.2+ con suites de cifrado robustas.
  • Establece cabeceras Strict-Transport-Security (HSTS).
  • Redirige todo el tráfico HTTP a HTTPS.

Configura CORS en tu servidor API para permitir solo tu dominio de FireCMS:

import cors from "cors";
app.use(cors({
origin: "https://tu-panel-admin.ejemplo.com",
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"]
}));

Protege tu API de ataques de fuerza bruta y abuso:

import rateLimit from "express-rate-limit";
// Límite general de la API
app.use("/api/", rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 500
}));
// Límite más estricto para endpoints de autenticación
app.use("/api/auth/", rateLimit({
windowMs: 15 * 60 * 1000,
max: 20
}));

Establece cabeceras CSP para prevenir ataques XSS:

Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' https://tu-almacenamiento.ejemplo.com;
connect-src 'self' https://tu-api.ejemplo.com;
❌ No hagas✅ Haz
Codificar claves API en el código frontendUsar variables de entorno en el servidor
Hacer commit de archivos .env en GitUsar un gestor de secretos (Vault, AWS Secrets Manager, Doppler)
Compartir cadenas de conexión de MongoDB con el clienteMantener todas las conexiones a base de datos solo del lado del servidor
Usar el mismo secreto JWT en todos los entornosUsar secretos únicos por entorno
  • Habilita la autenticación en tu clúster MongoDB. Nunca ejecutes sin autenticación.
  • Usa un usuario de base de datos dedicado para tu API con los permisos mínimos requeridos.
  • Habilita TLS para conexiones entre tu API y MongoDB.
  • Control de acceso por red: restringe qué IPs pueden conectarse a tu clúster MongoDB.
  • Habilita el registro de auditoría si tu plan de MongoDB lo soporta.
  • Ejecuta npm audit regularmente y aborda las vulnerabilidades.
  • Fija las versiones principales de dependencias en package.json.
  • Usa npm audit fix o herramientas como Snyk o Socket para monitoreo continuo.

ÁreaRequisitoEstado
AuthLos tokens JWT/sesión son firmados y validados por el servidor
AuthLos tokens son de corta duración con flujo de renovación
AuthsignOut invalida la sesión del lado del servidor
AuthLos tokens se almacenan en cookies httpOnly (preferido)
DatosTodo el CRUD pasa por API autenticada
DatosMongoDB nunca expuesto al navegador
DatosEl servidor valida y sanitiza todas las entradas
DatosPrevención de inyección NoSQL implementada
DatosAcceso a colecciones está en lista blanca
DatosLímites de tamaño de resultados aplicados
AlmacenamientoURLs pre-firmadas usadas para subidas/descargas
AlmacenamientoTipo y tamaño de archivo validados del lado del servidor
AlmacenamientoPath traversal prevenido
PermisosPermisos del cliente replican las reglas del servidor
PermisosAsignación de roles restringida a administradores
GeneralHTTPS aplicado
GeneralCORS restringido al dominio del CMS
GeneralRate limiting en todos los endpoints de la API
GeneralCabeceras CSP configuradas
GeneralSin secretos en el código frontend
GeneralAutenticación y TLS de MongoDB habilitados
GeneralDependencias auditadas regularmente