Pular para o conteúdo

Boas práticas de segurança para FireCMS self-hosted

O FireCMS é uma aplicação React exclusivamente frontend. Ele não possui nenhum componente de servidor integrado que aplique segurança. Isso significa que toda a segurança deve ser implementada e aplicada no seu backend. As permissões do lado do cliente do FireCMS (o callback permissions nas coleções) controlam a UI/UX — elas ocultam botões e desabilitam formulários — mas podem ser contornadas por qualquer usuário com acesso às ferramentas de desenvolvedor do navegador.

Este guia cobre tudo o que você precisa saber para proteger seu deployment self-hosted do FireCMS ao usar MongoDB, autenticação personalizada e armazenamento de arquivos personalizado — sem Firebase.


Um deployment self-hosted seguro do FireCMS tem a seguinte arquitetura:

┌─────────────────┐ HTTPS ┌──────────────────┐
│ FireCMS React │ ──────────────────▶ │ Seu servidor API │
│ (Navegador) │ ◀────────────────── │ (Express/Nest) │
└─────────────────┘ └──────┬───────────┘
┌──────────────────────┤
│ │
┌──────▼──────┐ ┌───────▼──────┐
│ MongoDB │ │Armazenamento │
│ (Banco de │ │ de arquivos │
│ dados) │ │ (S3/Minio) │
└─────────────┘ └──────────────┘

Pontos-chave:

  • O navegador nunca se comunica diretamente com o MongoDB ou seu backend de armazenamento.
  • Seu servidor API é o ponto de entrada único que autentica cada requisição, autoriza a ação, valida as entradas, e então interage com o banco de dados e o armazenamento.

A interface AuthController gerencia o estado de autenticação do usuário no navegador. Ao usar um backend personalizado, sua implementação deve ser um hook React que se comunica com sua própria 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;
};

Sua implementação de getAuthToken é a pedra angular de todo o modelo de segurança — cada requisição que seu DataSourceDelegate e StorageSource fazem irá chamá-la para anexar credenciais.

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);
// Ao montar, verificar se existe uma sessão
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 {
// Sem sessão válida
}
}, []);
const getAuthToken = useCallback(async (): Promise<string> => {
if (!accessToken) throw new Error("Não 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,
};
}

Pontos-chave de segurança:

AspectoRecomendação
Armazenamento de tokensArmazene os JWTs em cookies httpOnly, Secure, SameSite=Strict quando possível. Se precisar usar tokens em memória, nunca os armazene em localStorage ou sessionStorage.
Expiração de tokensUse tokens de acesso de curta duração (5–15 minutos) com um fluxo de refresh token.
Renovação de tokensImplemente a renovação transparente de tokens em getAuthToken antes da expiração.
Invalidação de sessãosignOut deve chamar seu servidor para invalidar o refresh token / sessão. Um logout apenas do lado do cliente não é seguro.
Carregamento inicialUse initialLoading para verificar silenciosamente se existe uma sessão ao montar a app.

O callback Authenticator permite controlar quais usuários autenticados podem acessar o FireCMS. Use-o para carregar o papel do usuário do seu banco de dados e anexá-lo ao controller de autenticação.

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("Erro de autenticação:", error);
return false;
}
};

O DataSourceDelegate é a interface que o FireCMS usa para ler e escrever dados. Quando apoiado pelo MongoDB, sua implementação deve fazer proxy de cada chamada através do seu servidor API autenticado.

Esta é a regra mais crítica. Não use o driver do MongoDB, o SDK Realm, nem qualquer conexão direta ao banco de dados no navegador.

// ❌ PERIGOSO — acesso direto ao MongoDB a partir do navegador
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb+srv://user:password@cluster...");
// ✅ CORRETO — proxy através da sua 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("Não autorizado");
}
if (!res.ok) {
throw new Error(`Erro da 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,
};
}

Seu servidor API (ex. Express, Fastify, NestJS) deve aplicar o seguinte em cada requisição:

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 ausente" });
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: "Não autenticado" });
if (user.role !== "admin" && user.role !== requiredRole) {
return res.status(403).json({ error: "Permissões insuficientes" });
}
next();
};
}
app.delete("/api/data/:path/:id", authenticate, authorize("admin"), async (req, res) => {
// Apenas administradores podem excluir
});

Validação de entrada e prevenção de injeção NoSQL

Seção intitulada “Validação de entrada e prevenção de injeção NoSQL”

O MongoDB é vulnerável a injeção NoSQL quando a entrada do usuário é passada diretamente para operadores de consulta.

// ❌ VULNERÁVEL — entrada do usuário vai diretamente para a consulta
app.get("/api/data/:collection", async (req, res) => {
const filter = JSON.parse(req.query.filter);
const docs = await db.collection(req.params.collection).find(filter).toArray();
res.json(docs);
});
// ✅ SEGURO — sanitizar e usar whitelist
import mongo from "mongo-sanitize";
app.get("/api/data/:collection", authenticate, async (req, res) => {
// 1. Whitelist de coleções permitidas
const allowedCollections = ["products", "orders", "categories"];
if (!allowedCollections.includes(req.params.collection)) {
return res.status(400).json({ error: "Coleção inválida" });
}
// 2. Sanitizar o filtro para remover operadores do MongoDB
let filter = {};
if (req.query.filter) {
filter = mongo.sanitize(JSON.parse(req.query.filter));
}
// 3. Aplicar limites
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);
});

Validações-chave:

VerificaçãoPor quê
Whitelist de coleçõesPrevenir acesso a coleções do sistema (admin, local) ou internas
Sanitizar operadores de filtroBloquear $where, $gt, $regex e outros operadores injetáveis
Limitar tamanho dos resultadosPrevenir negação de serviço via consultas sem limite
Validar campos orderByPermitir ordenação apenas em campos indexados/conhecidos
Validar formato do entityIdGarantir que IDs correspondam ao formato esperado (ex. UUID ou ObjectId)
Validar values ao salvarExecutar validação de esquema (ex. Zod, Joi) no servidor antes da escrita

A interface StorageSource gerencia o upload e download de arquivos. Ao usar armazenamento personalizado (S3, MinIO, GCS, ou um sistema de arquivos local), o princípio-chave é: nunca exponha credenciais de armazenamento ao 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>;
}

Em vez de passar credenciais S3/GCS ao navegador, faça seu servidor gerar URLs pré-assinadas de curta duração:

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}`;
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();
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();
}
};
}

Seu endpoint de API de armazenamento deve aplicar:

AspectoRecomendação
Validação de tipo de arquivoWhitelist de tipos MIME permitidos (ex. image/jpeg, application/pdf). Rejeitar executáveis.
Limites de tamanho de arquivoAplicar tamanhos máximos (ex. 10 MB para imagens, 50 MB para documentos).
Prevenção de path traversalSanitizar o parâmetro path. Rejeitar .., caminhos absolutos ou bytes nulos.
Expiração de URLs pré-assinadasManter URLs de upload/download de curta duração (5–15 minutos).
Verificação de vírusPara conteúdo enviado por usuários, considere integrar ClamAV ou um serviço de verificação em nuvem.
Escopo de acessoCada URL pré-assinada deve conceder acesso a exatamente um arquivo.

Exemplo de validação de caminho no lado do servidor

Seção intitulada “Exemplo de validação de caminho no lado do 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: "Caminho inválido" });
}
if (!validateFileType(contentType)) {
return res.status(400).json({ error: "Tipo de arquivo não permitido" });
}
if (size > 10 * 1024 * 1024) {
return res.status(400).json({ error: "Arquivo muito grande" });
}
// Gerar e retornar URL pré-assinada...
});

4. Permissões e controle de acesso baseado em papéis

Seção intitulada “4. Permissões e controle de acesso baseado em papéis”

O FireCMS tem um sistema de Permissions integrado e um callback PermissionsBuilder que você pode usar em cada coleção. Esses são controles em nível de UI — eles determinam quais botões são exibidos e quais formulários são editáveis.

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: {
// ...
}
});

Sua API deve aplicar as mesmas regras exatas:

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: "Permissões 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);

Se um usuário pode modificar seu próprio papel no banco de dados (ex. definir role: "admin" no seu próprio documento de usuário), seu sistema de permissões está comprometido. Sempre restrinja as escritas às coleções de usuário/papéis apenas a operações de administrador.


  • Sempre use HTTPS em produção. Use TLS 1.2+ com suítes de cifra robustas.
  • Defina os cabeçalhos Strict-Transport-Security (HSTS).
  • Redirecione todo o tráfego HTTP para HTTPS.

Configure CORS no seu servidor API para permitir apenas seu domínio FireCMS:

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

Proteja sua API contra ataques de força bruta e abuso:

import rateLimit from "express-rate-limit";
app.use("/api/", rateLimit({
windowMs: 15 * 60 * 1000,
max: 500
}));
app.use("/api/auth/", rateLimit({
windowMs: 15 * 60 * 1000,
max: 20
}));

Defina cabeçalhos CSP para prevenir ataques XSS:

Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' https://seu-armazenamento.exemplo.com;
connect-src 'self' https://sua-api.exemplo.com;
❌ Não faça✅ Faça
Codificar chaves de API no código frontendUsar variáveis de ambiente no servidor
Fazer commit de arquivos .env no GitUsar um gerenciador de segredos (Vault, AWS Secrets Manager, Doppler)
Compartilhar strings de conexão do MongoDB com o clienteManter todas as conexões ao banco de dados apenas no lado do servidor
Usar o mesmo segredo JWT em todos os ambientesUsar segredos únicos por ambiente
  • Habilite a autenticação no seu cluster MongoDB. Nunca execute sem autenticação.
  • Use um usuário de banco de dados dedicado para sua API com as permissões mínimas necessárias.
  • Habilite TLS para conexões entre sua API e o MongoDB.
  • Controle de acesso de rede: restrinja quais IPs podem se conectar ao seu cluster MongoDB.
  • Habilite o log de auditoria se seu plano do MongoDB suportar.
  • Execute npm audit regularmente e resolva as vulnerabilidades.
  • Fixe as versões principais das dependências no package.json.
  • Use npm audit fix ou ferramentas como Snyk ou Socket para monitoramento contínuo.

ÁreaRequisitoStatus
AuthTokens JWT/sessão são assinados e validados pelo servidor
AuthTokens são de curta duração com fluxo de renovação
AuthsignOut invalida a sessão no lado do servidor
AuthTokens armazenados em cookies httpOnly (preferido)
DadosTodo o CRUD passa por API autenticada
DadosMongoDB nunca exposto ao navegador
DadosO servidor valida e sanitiza todas as entradas
DadosPrevenção de injeção NoSQL implementada
DadosAcesso às coleções está em whitelist
DadosLimites de tamanho de resultados aplicados
ArmazenamentoURLs pré-assinadas usadas para uploads/downloads
ArmazenamentoTipo e tamanho de arquivo validados no lado do servidor
ArmazenamentoPath traversal prevenido
PermissõesPermissões do cliente replicam as regras do servidor
PermissõesAtribuição de papéis restrita a administradores
GeralHTTPS aplicado
GeralCORS restrito ao domínio do CMS
GeralRate limiting em todos os endpoints da API
GeralCabeçalhos CSP configurados
GeralNenhum segredo no código frontend
GeralAuth e TLS do MongoDB habilitados
GeralDependências auditadas regularmente