From 90029f4b6aef42a2b820b03927eb53543c132f84 Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 6 Oct 2025 13:31:53 +0200 Subject: [PATCH] docs: README dompurify note; fix: sanitize plaintext names in booking emails; feat: use sanitizePhone in email templates; feat: extend sanitizeHtml allowed tags and URL allowlist --- README.md | 44 +++++++-------- package.json | 3 + src/server/index.ts | 22 ++++++++ src/server/lib/email-templates.ts | 67 ++++++++++++++-------- src/server/lib/sanitize.ts | 37 +++++++++++++ src/server/rpc/auth.ts | 92 +++++++++++++++++++++++++++---- src/server/rpc/bookings.ts | 64 +++++++++++++-------- 7 files changed, 250 insertions(+), 79 deletions(-) create mode 100644 src/server/lib/sanitize.ts diff --git a/README.md b/README.md index 8665050..f11635c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender un - [Hono](https://hono.dev/) - [Zod](https://zod.dev/) +> Hinweis zu DOMPurify: Wir nutzen `isomorphic-dompurify`, das DOMPurify bereits mitliefert und sowohl in Node.js als auch im Browser funktioniert. Eine zusätzliche Installation von `dompurify` ist daher nicht erforderlich und würde eine redundante Abhängigkeit erzeugen. + ## Setup ### 1. Umgebungsvariablen konfigurieren @@ -22,33 +24,28 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria cp .env.example .env ``` -### 2. Admin-Passwort Hash generieren +### 2. Admin-Passwort Hash generieren (bcrypt) -Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren: +Das Admin-Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert. So erzeugst du einen Hash: -#### PowerShell (Windows) -```powershell -# Einfache Methode mit Base64-Encoding -$password = "dein_sicheres_passwort" -$bytes = [System.Text.Encoding]::UTF8.GetBytes($password) -$hash = [System.Convert]::ToBase64String($bytes) -Write-Host "Password Hash: $hash" - -# Alternative mit PowerShell 7+ (kürzer) -$password = "dein_sicheres_passwort" -[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password)) +#### Node.js (empfohlen) +```bash +node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)" ``` -#### Node.js (falls verfügbar) +Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst): + ```javascript -// In der Node.js Konsole oder als separates Script -const password = "dein_sicheres_passwort"; -const hash = Buffer.from(password).toString('base64'); -console.log("Password Hash:", hash); +// scripts/generate-hash.js +require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => { + console.log(h); +}); ``` -#### Online-Tools (nur für Entwicklung) -- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/) +Ausführen: +```bash +node scripts/generate-hash.js "dein_sicheres_passwort" +``` ### 3. .env Datei konfigurieren @@ -57,7 +54,8 @@ Bearbeite deine `.env` Datei und setze die generierten Werte: ```env # Admin Account Configuration ADMIN_USERNAME=owner -ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash +# bcrypt-Hash des Admin-Passworts (kein Base64). Beispielwert: +ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy # Domain Configuration DOMAIN=localhost:5173 # Für Produktion: deine-domain.de @@ -209,10 +207,12 @@ Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credent ⚠️ **Wichtige Hinweise:** - Ändere das Standard-Passwort vor dem Produktionseinsatz -- Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert +- Das Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert - Verwende ein sicheres Passwort und generiere den entsprechenden Hash - Die `.env` Datei sollte niemals in das Repository committet werden +Hinweis zur Migration: Vorhandene Base64-Hashes aus älteren Versionen werden beim Server-Start automatisch in bcrypt migriert. Zusätzlich erfolgt beim nächsten erfolgreichen Login ebenfalls eine Migration, falls noch erforderlich. + ### Security.txt Endpoint Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`: diff --git a/package.json b/package.json index 810ed9f..c0273d8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "preview": "vite preview" }, "dependencies": { + "@types/bcrypt": "^5.0.2", + "bcrypt": "^5.1.1", "@hono/node-server": "^1.19.5", "@orpc/client": "^1.8.8", "@orpc/server": "^1.8.8", @@ -20,6 +22,7 @@ "@tanstack/react-query": "^5.85.5", "dotenv": "^17.2.3", "hono": "^4.9.4", + "isomorphic-dompurify": "^2.16.0", "jsonrepair": "^3.13.0", "openai": "^5.17.0", "react": "^19.1.1", diff --git a/src/server/index.ts b/src/server/index.ts index 6e253d5..4256b48 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -14,6 +14,28 @@ app.use("*", async (c, next) => { return next(); }); +// Content-Security-Policy and other security headers +app.use("*", async (c, next) => { + const isDev = process.env.NODE_ENV === 'development'; + const directives = [ + "default-src 'self'", + `script-src 'self'${isDev ? " 'unsafe-inline'" : ''}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ]; + const csp = directives.join('; '); + c.header('Content-Security-Policy', csp); + c.header('X-Content-Type-Options', 'nosniff'); + c.header('X-Frame-Options', 'DENY'); + c.header('Referrer-Policy', 'strict-origin-when-cross-origin'); + await next(); +}); + // Health check endpoint app.get("/health", (c) => { return c.json({ status: "ok", timestamp: new Date().toISOString() }); diff --git a/src/server/lib/email-templates.ts b/src/server/lib/email-templates.ts index 8b214f9..484e5cc 100644 --- a/src/server/lib/email-templates.ts +++ b/src/server/lib/email-templates.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; @@ -60,13 +61,14 @@ async function renderBrandedEmail(title: string, bodyHtml: string): PromiseHallo ${name},

+

Hallo ${safeName},

wir haben deine Anfrage für ${formattedDate} um ${time} erhalten.

Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.

${statusUrl ? ` @@ -87,13 +89,14 @@ export async function renderBookingPendingHTML(params: { name: string; date: str export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) { const { name, date, time, cancellationUrl, reviewUrl } = params; + const safeName = sanitizeText(name); const formattedDate = formatDateGerman(date); const domain = process.env.DOMAIN || 'localhost:5173'; const protocol = domain.includes('localhost') ? 'http' : 'https'; const legalUrl = `${protocol}://${domain}/legal`; const inner = ` -

Hallo ${name},

+

Hallo ${safeName},

wir haben deinen Termin am ${formattedDate} um ${time} bestätigt.

Wir freuen uns auf dich!

@@ -126,13 +129,14 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) { const { name, date, time } = params; + const safeName = sanitizeText(name); const formattedDate = formatDateGerman(date); const domain = process.env.DOMAIN || 'localhost:5173'; const protocol = domain.includes('localhost') ? 'http' : 'https'; const legalUrl = `${protocol}://${domain}/legal`; const inner = ` -

Hallo ${name},

+

Hallo ${safeName},

dein Termin am ${formattedDate} um ${time} wurde abgesagt.

Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.

@@ -154,6 +158,10 @@ export async function renderAdminBookingNotificationHTML(params: { hasInspirationPhoto: boolean; }) { const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params; + const safeName = sanitizeText(name); + const safeTreatment = sanitizeText(treatment); + const safePhone = sanitizePhone(phone); + const safeNotes = sanitizeHtml(notes); const formattedDate = formatDateGerman(date); const inner = `

Hallo Admin,

@@ -161,12 +169,12 @@ export async function renderAdminBookingNotificationHTML(params: {

📅 Buchungsdetails:

    -
  • Name: ${name}
  • -
  • Telefon: ${phone}
  • -
  • Behandlung: ${treatment}
  • +
  • Name: ${safeName}
  • +
  • Telefon: ${safePhone}
  • +
  • Behandlung: ${safeTreatment}
  • Datum: ${formattedDate}
  • Uhrzeit: ${time}
  • - ${notes ? `
  • Notizen: ${notes}
  • ` : ''} + ${safeNotes ? `
  • Notizen: ${safeNotes}
  • ` : ''}
  • Inspiration-Foto: ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}
@@ -188,13 +196,15 @@ export async function renderBookingRescheduleProposalHTML(params: { declineUrl: string; expiresAt: string; }) { + const safeName = sanitizeText(params.name); + const safeTreatment = sanitizeText(params.treatmentName); const formattedOriginalDate = formatDateGerman(params.originalDate); const formattedProposedDate = formatDateGerman(params.proposedDate); const expiryDate = new Date(params.expiresAt); const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; const inner = ` -

Hallo ${params.name},

+

Hallo ${safeName},

wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:

📅 Übersicht

@@ -209,7 +219,7 @@ export async function renderBookingRescheduleProposalHTML(params: { Behandlung - ${params.treatmentName} + ${safeTreatment}
@@ -239,15 +249,19 @@ export async function renderAdminRescheduleDeclinedHTML(params: { customerEmail?: string; customerPhone?: string; }) { + const safeCustomerName = sanitizeText(params.customerName); + const safeTreatment = sanitizeText(params.treatmentName); + const safeEmail = params.customerEmail ? sanitizeText(params.customerEmail) : undefined; + const safePhone = params.customerPhone ? sanitizeText(params.customerPhone) : undefined; const inner = `

Hallo Admin,

-

der Kunde ${params.customerName} hat den Terminänderungsvorschlag abgelehnt.

+

der Kunde ${safeCustomerName} hat den Terminänderungsvorschlag abgelehnt.

    -
  • Kunde: ${params.customerName}
  • - ${params.customerEmail ? `
  • E-Mail: ${params.customerEmail}
  • ` : ''} - ${params.customerPhone ? `
  • Telefon: ${params.customerPhone}
  • ` : ''} -
  • Behandlung: ${params.treatmentName}
  • +
  • Kunde: ${safeCustomerName}
  • + ${safeEmail ? `
  • E-Mail: ${safeEmail}
  • ` : ''} + ${safePhone ? `
  • Telefon: ${safePhone}
  • ` : ''} +
  • Behandlung: ${safeTreatment}
  • Ursprünglicher Termin: ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)
  • Abgelehnter Vorschlag: ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr
@@ -265,13 +279,15 @@ export async function renderAdminRescheduleAcceptedHTML(params: { newTime: string; treatmentName: string; }) { + const safeCustomerName = sanitizeText(params.customerName); + const safeTreatment = sanitizeText(params.treatmentName); const inner = `

Hallo Admin,

-

der Kunde ${params.customerName} hat den Terminänderungsvorschlag akzeptiert.

+

der Kunde ${safeCustomerName} hat den Terminänderungsvorschlag akzeptiert.

    -
  • Kunde: ${params.customerName}
  • -
  • Behandlung: ${params.treatmentName}
  • +
  • Kunde: ${safeCustomerName}
  • +
  • Behandlung: ${safeTreatment}
  • Alter Termin: ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr
  • Neuer Termin: ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅
@@ -299,19 +315,24 @@ export async function renderAdminRescheduleExpiredHTML(params: {

${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.

⚠️ Abgelaufene Vorschläge:

- ${params.expiredProposals.map(proposal => ` + ${params.expiredProposals.map(proposal => { + const safeName = sanitizeText(proposal.customerName); + const safeTreatment = sanitizeText(proposal.treatmentName); + const safeEmail = proposal.customerEmail ? sanitizeText(proposal.customerEmail) : undefined; + const safePhone = proposal.customerPhone ? sanitizeText(proposal.customerPhone) : undefined; + return `
    -
  • Kunde: ${proposal.customerName}
  • - ${proposal.customerEmail ? `
  • E-Mail: ${proposal.customerEmail}
  • ` : ''} - ${proposal.customerPhone ? `
  • Telefon: ${proposal.customerPhone}
  • ` : ''} -
  • Behandlung: ${proposal.treatmentName}
  • +
  • Kunde: ${safeName}
  • + ${safeEmail ? `
  • E-Mail: ${safeEmail}
  • ` : ''} + ${safePhone ? `
  • Telefon: ${safePhone}
  • ` : ''} +
  • Behandlung: ${safeTreatment}
  • Ursprünglicher Termin: ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr
  • Vorgeschlagener Termin: ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr
  • Abgelaufen am: ${new Date(proposal.expiredAt).toLocaleString('de-DE')}
- `).join('')} + `;}).join('')}

Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.

Die ursprünglichen Termine bleiben bestehen.

diff --git a/src/server/lib/sanitize.ts b/src/server/lib/sanitize.ts new file mode 100644 index 0000000..cb61256 --- /dev/null +++ b/src/server/lib/sanitize.ts @@ -0,0 +1,37 @@ +import DOMPurify from "isomorphic-dompurify"; + +/** + * Sanitize plain text inputs by stripping all HTML tags. + * Use for names, phone numbers, and simple text fields. + */ +export function sanitizeText(input: string | undefined): string { + if (!input) return ""; + const cleaned = DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }); + return cleaned.trim(); +} + +/** + * Sanitize rich text notes allowing only a minimal, safe subset of tags. + * Use for free-form notes or comments where basic formatting is acceptable. + */ +export function sanitizeHtml(input: string | undefined): string { + if (!input) return ""; + const cleaned = DOMPurify.sanitize(input, { + ALLOWED_TAGS: ["br", "p", "strong", "em", "u", "a", "ul", "li"], + ALLOWED_ATTR: ["href", "title", "target", "rel"], + ALLOWED_URI_REGEXP: /^(?:https?:)?\/\//i, + KEEP_CONTENT: true, + }); + return cleaned.trim(); +} + +/** + * Sanitize phone numbers by stripping HTML and keeping only digits and a few symbols. + * Allowed characters: digits, +, -, (, ), and spaces. + */ +export function sanitizePhone(input: string | undefined): string { + const text = sanitizeText(input); + return text.replace(/[^0-9+\-()\s]/g, ""); +} + + diff --git a/src/server/rpc/auth.ts b/src/server/rpc/auth.ts index 90e69c7..da547a7 100644 --- a/src/server/rpc/auth.ts +++ b/src/server/rpc/auth.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } from "../lib/create-kv.js"; import { config } from "dotenv"; +import bcrypt from "bcrypt"; // Load environment variables from .env file config(); @@ -29,18 +30,63 @@ type Session = z.output; const usersKV = createKV("users"); const sessionsKV = createKV("sessions"); -// Simple password hashing (in production, use bcrypt or similar) -const hashPassword = (password: string): string => { - return Buffer.from(password).toString('base64'); +// Password hashing using bcrypt +const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y + +const isBase64Hash = (hash: string): boolean => { + if (hash.startsWith(BCRYPT_PREFIX)) return false; + try { + const decoded = Buffer.from(hash, 'base64'); + // If re-encoding yields the same string and the decoded buffer is valid UTF-8, treat as base64 + const reencoded = decoded.toString('base64'); + // Additionally ensure that decoding does not produce too short/empty unless original was empty + return reencoded === hash && decoded.toString('utf8').length > 0; + } catch { + return false; + } }; -const verifyPassword = (password: string, hash: string): boolean => { - return hashPassword(password) === hash; +const hashPassword = async (password: string): Promise => { + return bcrypt.hash(password, 10); +}; + +const verifyPassword = async (password: string, hash: string): Promise => { + if (hash.startsWith(BCRYPT_PREFIX)) { + return bcrypt.compare(password, hash); + } + if (isBase64Hash(hash)) { + const base64OfPassword = Buffer.from(password).toString('base64'); + return base64OfPassword === hash; + } + // Unknown format -> fail closed + return false; }; // Export hashPassword for external use (e.g., generating hashes for .env) export const generatePasswordHash = hashPassword; +// Migrate all legacy Base64 password hashes to bcrypt on server startup +const migrateLegacyHashesOnStartup = async (): Promise => { + const users = await usersKV.getAllItems(); + let migratedCount = 0; + for (const user of users) { + if (isBase64Hash(user.passwordHash)) { + try { + const plaintext = Buffer.from(user.passwordHash, 'base64').toString('utf8'); + const bcryptHash = await hashPassword(plaintext); + const updatedUser: User = { ...user, passwordHash: bcryptHash }; + await usersKV.setItem(user.id, updatedUser); + migratedCount += 1; + } catch { + // ignore individual failures; continue with others + } + } + } + if (migratedCount > 0) { + console.log(`🔄 Migrated ${migratedCount} legacy Base64 password hash(es) to bcrypt at startup.`); + } +}; + // Initialize default owner account const initializeOwner = async () => { const existingUsers = await usersKV.getAllItems(); @@ -49,7 +95,12 @@ const initializeOwner = async () => { // Get admin credentials from environment variables const adminUsername = process.env.ADMIN_USERNAME || "owner"; - const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123"); + let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await hashPassword("admin123"); + // If provided hash looks like legacy Base64, decode to plaintext and re-hash with bcrypt + if (process.env.ADMIN_PASSWORD_HASH && isBase64Hash(process.env.ADMIN_PASSWORD_HASH)) { + const plaintext = Buffer.from(process.env.ADMIN_PASSWORD_HASH, 'base64').toString('utf8'); + adminPasswordHash = await hashPassword(plaintext); + } const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de"; const owner: User = { @@ -66,8 +117,14 @@ const initializeOwner = async () => { } }; -// Initialize on module load -initializeOwner(); +// Initialize on module load: first migrate legacy hashes, then ensure owner exists +(async () => { + try { + await migrateLegacyHashesOnStartup(); + } finally { + await initializeOwner(); + } +})(); const login = os .input(z.object({ @@ -78,10 +135,22 @@ const login = os const users = await usersKV.getAllItems(); const user = users.find(u => u.username === input.username); - if (!user || !verifyPassword(input.password, user.passwordHash)) { + if (!user) { throw new Error("Invalid credentials"); } + const isValid = await verifyPassword(input.password, user.passwordHash); + if (!isValid) { + throw new Error("Invalid credentials"); + } + + // Seamless migration: if stored hash is legacy Base64, upgrade to bcrypt + if (isBase64Hash(user.passwordHash)) { + const migratedHash = await hashPassword(input.password); + const migratedUser = { ...user, passwordHash: migratedHash } as User; + await usersKV.setItem(user.id, migratedUser); + } + // Create session const sessionId = randomUUID(); const expiresAt = new Date(); @@ -159,13 +228,14 @@ const changePassword = os throw new Error("User not found"); } - if (!verifyPassword(input.currentPassword, user.passwordHash)) { + const currentOk = await verifyPassword(input.currentPassword, user.passwordHash); + if (!currentOk) { throw new Error("Current password is incorrect"); } const updatedUser = { ...user, - passwordHash: hashPassword(input.newPassword), + passwordHash: await hashPassword(input.newPassword), }; await usersKV.setItem(user.id, updatedUser); diff --git a/src/server/rpc/bookings.ts b/src/server/rpc/bookings.ts index efe2d61..fe22b64 100644 --- a/src/server/rpc/bookings.ts +++ b/src/server/rpc/bookings.ts @@ -2,6 +2,7 @@ import { call, os } from "@orpc/server"; import { z } from "zod"; import { randomUUID } from "crypto"; import { createKV } from "../lib/create-kv.js"; +import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js"; import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js"; import { router as rootRouter } from "./index.js"; @@ -292,14 +293,26 @@ const create = os treatment.duration ); + // Sanitize user-provided fields before storage + const sanitizedName = sanitizeText(input.customerName); + const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined; + const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined; + const id = randomUUID(); const booking = { - id, - ...input, + id, + treatmentId: input.treatmentId, + customerName: sanitizedName, + customerEmail: input.customerEmail, + customerPhone: sanitizedPhone, + appointmentDate: input.appointmentDate, + appointmentTime: input.appointmentTime, + notes: sanitizedNotes, + inspirationPhoto: input.inspirationPhoto, bookedDurationMinutes: treatment.duration, // Snapshot treatment duration status: "pending" as const, createdAt: new Date().toISOString() - }; + } as Booking; // Save the booking await kv.setItem(id, booking); @@ -313,7 +326,7 @@ const create = os const formattedDate = formatDateGerman(input.appointmentDate); const homepageUrl = generateUrl(); const html = await renderBookingPendingHTML({ - name: input.customerName, + name: sanitizedName, date: input.appointmentDate, time: input.appointmentTime, statusUrl: bookingUrl @@ -321,7 +334,7 @@ const create = os await sendEmail({ to: input.customerEmail, subject: "Deine Terminanfrage ist eingegangen", - text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, + text: `Hallo ${sanitizedName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, html, }).catch(() => {}); })(); @@ -336,24 +349,24 @@ const create = os const treatmentName = treatment?.name || "Unbekannte Behandlung"; const adminHtml = await renderAdminBookingNotificationHTML({ - name: input.customerName, + name: sanitizedName, date: input.appointmentDate, time: input.appointmentTime, treatment: treatmentName, - phone: input.customerPhone || "Nicht angegeben", - notes: input.notes, + phone: sanitizedPhone || "Nicht angegeben", + notes: sanitizedNotes, hasInspirationPhoto: !!input.inspirationPhoto }); const homepageUrl = generateUrl(); const adminText = `Neue Buchungsanfrage eingegangen:\n\n` + - `Name: ${input.customerName}\n` + - `Telefon: ${input.customerPhone || "Nicht angegeben"}\n` + + `Name: ${sanitizedName}\n` + + `Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` + `Behandlung: ${treatmentName}\n` + `Datum: ${formatDateGerman(input.appointmentDate)}\n` + `Uhrzeit: ${input.appointmentTime}\n` + - `${input.notes ? `Notizen: ${input.notes}\n` : ''}` + + `${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` + `Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` + `Zur Website: ${homepageUrl}\n\n` + `Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`; @@ -361,14 +374,14 @@ const create = os if (input.inspirationPhoto) { await sendEmailWithInspirationPhoto({ to: process.env.ADMIN_EMAIL, - subject: `Neue Buchungsanfrage - ${input.customerName}`, + subject: `Neue Buchungsanfrage - ${sanitizedName}`, text: adminText, html: adminHtml, - }, input.inspirationPhoto, input.customerName).catch(() => {}); + }, input.inspirationPhoto, sanitizedName).catch(() => {}); } else { await sendEmail({ to: process.env.ADMIN_EMAIL, - subject: `Neue Buchungsanfrage - ${input.customerName}`, + subject: `Neue Buchungsanfrage - ${sanitizedName}`, text: adminText, html: adminHtml, }).catch(() => {}); @@ -441,7 +454,7 @@ const updateStatus = os await sendEmailWithAGBAndCalendar({ to: booking.customerEmail, subject: "Dein Termin wurde bestätigt - AGB im Anhang", - text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`, + text: `Hallo ${sanitizeText(booking.customerName)},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`, html, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, }, { @@ -460,7 +473,7 @@ const updateStatus = os await sendEmail({ to: booking.customerEmail, subject: "Dein Termin wurde abgesagt", - text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, + text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, html, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, }); @@ -508,7 +521,7 @@ const remove = os await sendEmail({ to: booking.customerEmail, subject: "Dein Termin wurde abgesagt", - text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, + text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, html, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, }); @@ -577,16 +590,21 @@ const createManual = os treatment.duration ); + // Sanitize user-provided fields before storage (admin manual booking) + const sanitizedName = sanitizeText(input.customerName); + const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined; + const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined; + const id = randomUUID(); const booking = { id, treatmentId: input.treatmentId, - customerName: input.customerName, + customerName: sanitizedName, customerEmail: input.customerEmail, - customerPhone: input.customerPhone, + customerPhone: sanitizedPhone, appointmentDate: input.appointmentDate, appointmentTime: input.appointmentTime, - notes: input.notes, + notes: sanitizedNotes, bookedDurationMinutes: treatment.duration, status: "confirmed" as const, createdAt: new Date().toISOString() @@ -607,7 +625,7 @@ const createManual = os const homepageUrl = generateUrl(); const html = await renderBookingConfirmedHTML({ - name: input.customerName, + name: sanitizedName, date: input.appointmentDate, time: input.appointmentTime, cancellationUrl: bookingUrl, @@ -617,13 +635,13 @@ const createManual = os await sendEmailWithAGBAndCalendar({ to: input.customerEmail!, subject: "Dein Termin wurde bestätigt - AGB im Anhang", - text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`, + text: `Hallo ${sanitizedName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`, html, }, { date: input.appointmentDate, time: input.appointmentTime, durationMinutes: treatment.duration, - customerName: input.customerName, + customerName: sanitizedName, treatmentName: treatment.name }); } catch (e) {