Zum Inhalt springen

Sicherheits-Best-Practices für selbstgehostetes FireCMS

FireCMS ist eine reine Frontend-React-Anwendung. Es gibt keine integrierte Serverkomponente, die Sicherheit durchsetzt. Das bedeutet, dass alle Sicherheitsmaßnahmen in Ihrem Backend implementiert und durchgesetzt werden müssen. Die clientseitigen Berechtigungen von FireCMS (der permissions-Callback bei Sammlungen) steuern die UI/UX — sie blenden Schaltflächen aus und deaktivieren Formulare — können aber von jedem Benutzer mit Zugriff auf die Browser-Entwicklertools umgangen werden.

Dieser Leitfaden deckt alles ab, was Sie wissen müssen, um Ihr selbstgehostetes FireCMS-Deployment abzusichern, wenn Sie MongoDB, eigene Authentifizierung und eigenen Dateispeicher verwenden — ohne Firebase.


Ein sicheres selbstgehostetes FireCMS-Deployment hat die folgende Architektur:

┌─────────────────┐ HTTPS ┌──────────────────┐
│ FireCMS React │ ──────────────────▶ │ Ihr API-Server │
│ (Browser) │ ◀────────────────── │ (Express/Nest) │
└─────────────────┘ └──────┬───────────┘
┌──────────────────────┤
│ │
┌──────▼──────┐ ┌───────▼──────┐
│ MongoDB │ │ Dateispeicher│
│ (Datenbank)│ │ (S3/Minio) │
└─────────────┘ └──────────────┘

Kernpunkte:

  • Der Browser kommuniziert niemals direkt mit MongoDB oder Ihrem Speicher-Backend.
  • Ihr API-Server ist der einzige Zugangspunkt, der jede Anfrage authentifiziert, die Aktion autorisiert, Eingaben validiert und dann mit der Datenbank und dem Speicher interagiert.

Das AuthController-Interface verwaltet den Authentifizierungszustand des Benutzers im Browser. Bei Verwendung eines eigenen Backends sollte Ihre Implementierung ein React-Hook sein, der mit Ihrer eigenen API kommuniziert.

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;
};

Ihre getAuthToken-Implementierung ist das Fundament des gesamten Sicherheitsmodells — jede Anfrage, die Ihr DataSourceDelegate und StorageSource stellen, wird sie aufrufen, um Anmeldeinformationen anzuhängen.

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);
// Beim Laden vorhandene Sitzung prüfen
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 {
// Keine gültige Sitzung
}
}, []);
const getAuthToken = useCallback(async (): Promise<string> => {
if (!accessToken) throw new Error("Nicht authentifiziert");
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,
};
}

Wichtige Sicherheitspunkte:

BereichEmpfehlung
Token-SpeicherungSpeichern Sie JWTs nach Möglichkeit in httpOnly-, Secure-, SameSite=Strict-Cookies. Wenn Sie In-Memory-Tokens verwenden müssen, speichern Sie diese niemals in localStorage oder sessionStorage.
Token-AblaufVerwenden Sie kurzlebige Access-Tokens (5–15 Minuten) mit einem Refresh-Token-Flow.
Token-ErneuerungImplementieren Sie transparente Token-Erneuerung in getAuthToken vor Ablauf.
SitzungsinvalidierungsignOut muss Ihren Server aufrufen, um den Refresh-Token / die Sitzung zu invalidieren. Ein nur clientseitiger Logout ist nicht sicher.
Initialer LadevorgangVerwenden Sie initialLoading, um beim App-Start stillschweigend nach einer vorhandenen Sitzung zu prüfen.

Der Authenticator-Callback ermöglicht es Ihnen zu steuern, welche authentifizierten Benutzer auf FireCMS zugreifen können. Verwenden Sie ihn, um die Rolle des Benutzers aus Ihrer Datenbank zu laden und an den Auth-Controller anzuhängen.

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("Authentifizierungsfehler:", error);
return false;
}
};

Der DataSourceDelegate ist das Interface, das FireCMS zum Lesen und Schreiben von Daten verwendet. Bei MongoDB-Unterstützung sollte Ihre Implementierung jeden Aufruf über Ihren authentifizierten API-Server proxen.

Dies ist die wichtigste Regel. Verwenden Sie keinen MongoDB-Treiber, Realm-SDK oder eine direkte Datenbankverbindung im Browser.

// ❌ GEFÄHRLICH — direkter MongoDB-Zugriff vom Browser
import { MongoClient } from "mongodb";
const client = new MongoClient("mongodb+srv://user:password@cluster...");
// ✅ KORREKT — über Ihre authentifizierte API proxen
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("Nicht autorisiert");
}
if (!res.ok) {
throw new Error(`API-Fehler: ${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,
};
}

Ihr API-Server (z.B. Express, Fastify, NestJS) muss bei jeder Anfrage Folgendes durchsetzen:

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 fehlt" });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: "Ungültiger Token" });
}
}
function authorize(requiredRole: string) {
return (req, res, next) => {
const user = req.user;
if (!user) return res.status(401).json({ error: "Nicht authentifiziert" });
if (user.role !== "admin" && user.role !== requiredRole) {
return res.status(403).json({ error: "Unzureichende Berechtigungen" });
}
next();
};
}
app.delete("/api/data/:path/:id", authenticate, authorize("admin"), async (req, res) => {
// Nur Administratoren können löschen
});

Eingabevalidierung und NoSQL-Injection-Prävention

Abschnitt betitelt „Eingabevalidierung und NoSQL-Injection-Prävention“

MongoDB ist anfällig für NoSQL-Injection, wenn Benutzereingaben direkt an Abfrageoperatoren übergeben werden.

// ❌ ANFÄLLIG — Benutzereingabe geht direkt in die Abfrage
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);
});
// ✅ SICHER — bereinigen und Whitelist verwenden
import mongo from "mongo-sanitize";
app.get("/api/data/:collection", authenticate, async (req, res) => {
// 1. Erlaubte Sammlungen whitelisten
const allowedCollections = ["products", "orders", "categories"];
if (!allowedCollections.includes(req.params.collection)) {
return res.status(400).json({ error: "Ungültige Sammlung" });
}
// 2. Filter bereinigen, um MongoDB-Operatoren zu entfernen
let filter = {};
if (req.query.filter) {
filter = mongo.sanitize(JSON.parse(req.query.filter));
}
// 3. Limits durchsetzen
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);
});

Wichtige Validierungen:

PrüfungWarum
Sammlungen whitelistenZugriff auf Systemsammlungen (admin, local) oder interne Sammlungen verhindern
Filteroperatoren bereinigen$where, $gt, $regex und andere injizierbare Operatoren blockieren
Ergebnisgröße begrenzenDenial-of-Service durch unbegrenzte Abfragen verhindern
orderBy-Felder validierenNur Sortierung nach indizierten/bekannten Feldern erlauben
entityId-Format validierenSicherstellen, dass IDs dem erwarteten Format entsprechen (z.B. UUID oder ObjectId)
values beim Speichern validierenSchema-Validierung (z.B. Zod, Joi) auf dem Server vor dem Schreiben ausführen

Das StorageSource-Interface verwaltet Datei-Uploads und -Downloads. Bei Verwendung von benutzerdefiniertem Speicher (S3, MinIO, GCS oder lokales Dateisystem) lautet das Schlüsselprinzip: Setzen Sie niemals Speicher-Anmeldeinformationen dem Browser aus.

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>;
}

Anstatt S3/GCS-Anmeldeinformationen an den Browser zu übergeben, lassen Sie Ihren Server kurzlebige vorsignierte URLs generieren:

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

Ihr Speicher-API-Endpunkt muss durchsetzen:

BereichEmpfehlung
Dateityp-ValidierungWhitelist erlaubter MIME-Typen (z.B. image/jpeg, application/pdf). Ausführbare Dateien ablehnen.
Dateigrößen-LimitsMaximale Dateigrößen durchsetzen (z.B. 10 MB für Bilder, 50 MB für Dokumente).
Path-Traversal-PräventionDen path-Parameter bereinigen. .., absolute Pfade oder Null-Bytes ablehnen.
Ablauf vorsignierter URLsUpload/Download-URLs kurzlebig halten (5–15 Minuten).
VirenscanningFür benutzerhochgeladene Inhalte ClamAV oder einen cloudbasierten Scandienst in Betracht ziehen.
ZugriffsbereichJede vorsignierte URL sollte Zugriff auf genau eine Datei gewähren.
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: "Ungültiger Pfad" });
}
if (!validateFileType(contentType)) {
return res.status(400).json({ error: "Dateityp nicht erlaubt" });
}
if (size > 10 * 1024 * 1024) {
return res.status(400).json({ error: "Datei zu groß" });
}
// Vorsignierte URL generieren und zurückgeben...
});

4. Berechtigungen und rollenbasierte Zugriffskontrolle

Abschnitt betitelt „4. Berechtigungen und rollenbasierte Zugriffskontrolle“

FireCMS hat ein integriertes Permissions-System und einen PermissionsBuilder-Callback, den Sie bei jeder Sammlung verwenden können. Dies sind Steuerungen auf UI-Ebene — sie bestimmen, welche Schaltflächen angezeigt werden und welche Formulare bearbeitbar sind.

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

Ihre API muss die exakt gleichen Regeln durchsetzen:

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: "Unzureichende Berechtigungen" });
}
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);

Wenn ein Benutzer seine eigene Rolle in der Datenbank ändern kann (z.B. role: "admin" auf seinem eigenen Benutzerdokument setzen), ist Ihr Berechtigungssystem kompromittiert. Beschränken Sie Schreibvorgänge auf Benutzer-/Rollensammlungen immer auf Administratoroperationen.


  • Verwenden Sie immer HTTPS in der Produktion. Verwenden Sie TLS 1.2+ mit starken Cipher-Suites.
  • Setzen Sie Strict-Transport-Security-Header (HSTS).
  • Leiten Sie allen HTTP-Verkehr auf HTTPS um.

Konfigurieren Sie CORS auf Ihrem API-Server, um nur Ihre FireCMS-Domäne zuzulassen:

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

Schützen Sie Ihre API vor Brute-Force-Angriffen und Missbrauch:

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

Setzen Sie CSP-Header, um XSS-Angriffe zu verhindern:

Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' https://ihr-speicher.beispiel.com;
connect-src 'self' https://ihre-api.beispiel.com;
❌ Nicht tun✅ Tun
API-Schlüssel im Frontend-Code hardcodenUmgebungsvariablen auf dem Server verwenden
.env-Dateien in Git committenEinen Secrets Manager verwenden (Vault, AWS Secrets Manager, Doppler)
MongoDB-Verbindungsstrings mit dem Client teilenAlle Datenbankverbindungen nur serverseitig halten
Das gleiche JWT-Secret in allen Umgebungen verwendenEinzigartige Secrets pro Umgebung verwenden
  • Aktivieren Sie die Authentifizierung auf Ihrem MongoDB-Cluster. Betreiben Sie ihn niemals ohne Authentifizierung.
  • Verwenden Sie einen dedizierten Datenbankbenutzer für Ihre API mit den minimal erforderlichen Berechtigungen.
  • Aktivieren Sie TLS für Verbindungen zwischen Ihrer API und MongoDB.
  • Netzwerkzugriffskontrolle: Beschränken Sie, welche IPs sich mit Ihrem MongoDB-Cluster verbinden können.
  • Aktivieren Sie Audit-Logging, wenn Ihr MongoDB-Plan dies unterstützt.
  • Führen Sie regelmäßig npm audit aus und beheben Sie Schwachstellen.
  • Pinnen Sie Hauptversionen von Abhängigkeiten in package.json.
  • Verwenden Sie npm audit fix oder Tools wie Snyk oder Socket für kontinuierliche Überwachung.

BereichAnforderungStatus
AuthJWT/Sitzungstoken werden serverseitig signiert und validiert
AuthToken sind kurzlebig mit Erneuerungsflow
AuthsignOut invalidiert die serverseitige Sitzung
AuthToken in httpOnly-Cookies gespeichert (bevorzugt)
DatenAlle CRUD-Operationen gehen über authentifizierte API
DatenMongoDB niemals dem Browser ausgesetzt
DatenServer validiert und bereinigt alle Eingaben
DatenNoSQL-Injection-Prävention vorhanden
DatenSammlungszugriff ist gewhitelistet
DatenErgebnisgrößen-Limits durchgesetzt
SpeicherVorsignierte URLs für Uploads/Downloads verwendet
SpeicherDateityp und -größe serverseitig validiert
SpeicherPath-Traversal verhindert
BerechtigungenClientseitige Berechtigungen spiegeln serverseitige Regeln
BerechtigungenRollenzuweisung auf Administratoren beschränkt
AllgemeinHTTPS durchgesetzt
AllgemeinCORS auf CMS-Domäne beschränkt
AllgemeinRate Limiting auf allen API-Endpunkten
AllgemeinCSP-Header konfiguriert
AllgemeinKeine Geheimnisse im Frontend-Code
AllgemeinMongoDB-Auth und TLS aktiviert
AllgemeinAbhängigkeiten regelmäßig geprüft