feat: initialize CalBook project with comprehensive scheduling, admin, and deployment infrastructure
This commit is contained in:
169
lib/security/config-guard.ts
Normal file
169
lib/security/config-guard.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
type ConfigIssue = {
|
||||
key: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const WEAK_DEFAULT_VALUES = new Set([
|
||||
"",
|
||||
"calbook",
|
||||
"calbook123",
|
||||
"admin",
|
||||
"admin123",
|
||||
"passwort",
|
||||
"password",
|
||||
"123456",
|
||||
"bitte-einen-langen-random-wert-setzen",
|
||||
"bitte-einen-random-cron-secret-setzen",
|
||||
"bitte-einen-random-salt-setzen",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"bittesicher123!",
|
||||
"change_me",
|
||||
"changeme",
|
||||
"replace_me",
|
||||
"todo"
|
||||
]);
|
||||
|
||||
function normalize(value: string | undefined | null) {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isWeakSecret(value: string | undefined | null) {
|
||||
const normalized = normalize(value);
|
||||
if (!normalized) return true;
|
||||
if (WEAK_DEFAULT_VALUES.has(normalized)) return true;
|
||||
if (normalized.startsWith("bitte-")) return true;
|
||||
if (normalized.includes("random-wert-setzen")) return true;
|
||||
if (normalized.includes("random-secret-setzen")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(): ConfigIssue[] {
|
||||
const issues: ConfigIssue[] = [];
|
||||
|
||||
const nextAuthSecret = process.env.NEXTAUTH_SECRET;
|
||||
if (!nextAuthSecret || nextAuthSecret.trim().length < 32 || isWeakSecret(nextAuthSecret)) {
|
||||
issues.push({
|
||||
key: "NEXTAUTH_SECRET",
|
||||
message: "muss gesetzt, einzigartig und mindestens 32 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
if (!cronSecret || cronSecret.trim().length < 24 || isWeakSecret(cronSecret)) {
|
||||
issues.push({
|
||||
key: "CRON_SECRET",
|
||||
message: "muss gesetzt, einzigartig und mindestens 24 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const caldavKey = process.env.CALDAV_ENCRYPTION_KEY;
|
||||
if (!caldavKey || caldavKey.trim().length < 32 || isWeakSecret(caldavKey)) {
|
||||
issues.push({
|
||||
key: "CALDAV_ENCRYPTION_KEY",
|
||||
message: "muss gesetzt, einzigartig und mindestens 32 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const jitsiSalt = process.env.JITSI_ROOM_SALT;
|
||||
if (!jitsiSalt || jitsiSalt.trim().length < 24 || isWeakSecret(jitsiSalt)) {
|
||||
issues.push({
|
||||
key: "JITSI_ROOM_SALT",
|
||||
message: "muss gesetzt, einzigartig und mindestens 24 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const publicUrl = process.env.PUBLIC_URL;
|
||||
if (publicUrl && publicUrl.trim()) {
|
||||
try {
|
||||
const parsed = new URL(publicUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
issues.push({
|
||||
key: "PUBLIC_URL",
|
||||
message: "muss mit http:// oder https:// beginnen."
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
issues.push({
|
||||
key: "PUBLIC_URL",
|
||||
message: "ist kein gültiger URL-Wert."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl || !databaseUrl.trim()) {
|
||||
issues.push({
|
||||
key: "DATABASE_URL",
|
||||
message: "muss gesetzt sein."
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(databaseUrl);
|
||||
const dbPassword = decodeURIComponent(parsed.password ?? "");
|
||||
if (isWeakSecret(dbPassword) || dbPassword.length < 12) {
|
||||
issues.push({
|
||||
key: "DATABASE_URL",
|
||||
message: "nutzt ein schwaches Datenbank-Passwort."
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
issues.push({
|
||||
key: "DATABASE_URL",
|
||||
message: "ist kein gültiger URL-Wert."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateSeedConfig(): ConfigIssue[] {
|
||||
const issues: ConfigIssue[] = [];
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!adminPassword || adminPassword.trim().length < 12 || isWeakSecret(adminPassword)) {
|
||||
issues.push({
|
||||
key: "ADMIN_PASSWORD",
|
||||
message: "muss gesetzt, stark und mindestens 12 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function formatIssues(label: string, issues: ConfigIssue[]) {
|
||||
return [
|
||||
`[security] ${label}`,
|
||||
...issues.map((issue) => `- ${issue.key}: ${issue.message}`)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isProductionRuntime() {
|
||||
if (process.env.NODE_ENV !== "production") return false;
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function assertSecureRuntimeConfig() {
|
||||
if (process.env.NODE_ENV === "test") return;
|
||||
|
||||
const issues = validateRuntimeConfig();
|
||||
if (issues.length === 0) return;
|
||||
|
||||
const message = formatIssues("Unsichere Runtime-Konfiguration erkannt.", issues);
|
||||
if (isProductionRuntime()) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export function assertSecureSeedConfig() {
|
||||
if (process.env.NODE_ENV === "test") return;
|
||||
|
||||
const issues = validateSeedConfig();
|
||||
if (issues.length === 0) return;
|
||||
|
||||
throw new Error(formatIssues("Unsichere Seed-Konfiguration erkannt.", issues));
|
||||
}
|
||||
118
lib/security/request.ts
Normal file
118
lib/security/request.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { fail } from "@/lib/api";
|
||||
import { getPublicBaseUrl } from "@/lib/public-url";
|
||||
|
||||
const DEFAULT_MAX_JSON_BYTES = 64 * 1024;
|
||||
|
||||
function parseContentLength(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function mutationMethod(method: string) {
|
||||
const normalized = method.toUpperCase();
|
||||
return normalized === "POST" || normalized === "PUT" || normalized === "PATCH" || normalized === "DELETE";
|
||||
}
|
||||
|
||||
function isAllowedOrigin(origin: string, req: Request) {
|
||||
const allowedOrigins = new Set<string>();
|
||||
|
||||
try {
|
||||
allowedOrigins.add(new URL(req.url).origin);
|
||||
} catch {
|
||||
// Ignore invalid request URL.
|
||||
}
|
||||
|
||||
try {
|
||||
allowedOrigins.add(new URL(getPublicBaseUrl()).origin);
|
||||
} catch {
|
||||
// Ignore invalid public URL config.
|
||||
}
|
||||
|
||||
if (process.env.NEXTAUTH_URL) {
|
||||
try {
|
||||
allowedOrigins.add(new URL(process.env.NEXTAUTH_URL).origin);
|
||||
} catch {
|
||||
// Ignore invalid NEXTAUTH_URL config.
|
||||
}
|
||||
}
|
||||
|
||||
return allowedOrigins.has(origin);
|
||||
}
|
||||
|
||||
export function validateMutationRequestOrigin(req: Request) {
|
||||
if (!mutationMethod(req.method)) return null;
|
||||
|
||||
const fetchSite = (req.headers.get("sec-fetch-site") ?? "").toLowerCase();
|
||||
if (fetchSite === "cross-site") {
|
||||
return fail("Ungültige Anfrage-Herkunft", 403);
|
||||
}
|
||||
|
||||
const originHeader = req.headers.get("origin");
|
||||
if (!originHeader) return null;
|
||||
|
||||
let origin: string;
|
||||
try {
|
||||
origin = new URL(originHeader).origin;
|
||||
} catch {
|
||||
return fail("Ungültige Anfrage-Herkunft", 403);
|
||||
}
|
||||
|
||||
if (!isAllowedOrigin(origin, req)) {
|
||||
return fail("Ungültige Anfrage-Herkunft", 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJsonBody(
|
||||
req: Request,
|
||||
options?: { maxBytes?: number }
|
||||
) {
|
||||
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
||||
const contentLength = parseContentLength(req.headers.get("content-length"));
|
||||
|
||||
if (contentLength !== null && contentLength > maxBytes) {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: fail("Anfrage ist zu groß", 413)
|
||||
};
|
||||
}
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
raw = await req.text();
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: fail("Anfrage konnte nicht gelesen werden", 400)
|
||||
};
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(raw, "utf8") > maxBytes) {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: fail("Anfrage ist zu groß", 413)
|
||||
};
|
||||
}
|
||||
|
||||
if (!raw.trim()) {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: JSON.parse(raw) as unknown
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: fail("Ungültiges JSON", 400)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user