feat: security audit, rate limiting, server-side pricing, html escaping and security headers

This commit is contained in:
MichalC
2026-08-01 13:24:41 +02:00
parent f07f2c2407
commit 47a9ac07fb
24 changed files with 292 additions and 151 deletions
+63 -26
View File
@@ -1,35 +1,46 @@
'use server';
import { z } from 'zod';
import { Resend } from 'resend';
import { verifyRecaptcha } from '@/lib/recaptcha';
import { headers } from 'next/headers';
import { getProductBySlug } from '@/content/products';
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from '@/lib/security';
import { getMailClient } from '@/lib/mail';
const resend = new Resend(process.env.RESEND_API_KEY);
const orderSchema = z.object({
name: z.string().min(2),
phone: z.string().min(9),
email: z.string().email(),
const orderSchema = baseContactSchema.extend({
address: z.string().optional(),
notes: z.string().optional(),
consent: z.string(),
products: z.string(),
total: z.string(),
total: z.string(), // We don't trust this total, just checking if it exists
});
const productItemSchema = z.array(z.object({
slug: z.string(),
quantity: z.number().int().min(1).max(100),
}));
export async function submitOrderForm(prevState: unknown, formData: FormData) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
const token = formData.get('recaptchaToken') as string;
if (!token) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(token);
const success = await verifyRecaptcha(token);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
const mailClient = getMailClient();
if (!mailClient) {
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
}
const data = orderSchema.parse({
name: formData.get('name'),
phone: formData.get('phone'),
@@ -41,18 +52,44 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
total: formData.get('total'),
});
const products = JSON.parse(data.products);
const total = parseFloat(data.total);
let rawProducts;
try {
rawProducts = JSON.parse(data.products);
} catch {
return { success: false, message: "Błąd przetwarzania koszyka." };
}
const productRows = products.map((p: { name: string; quantity: number; price: number }) =>
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${p.name}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
const parsedProducts = productItemSchema.parse(rawProducts);
if (parsedProducts.length === 0) {
return { success: false, message: "Koszyk jest pusty." };
}
let calculatedTotal = 0;
const validatedProducts = [];
for (const p of parsedProducts) {
const dbProduct = getProductBySlug(p.slug);
if (!dbProduct || !dbProduct.price) {
return { success: false, message: "Jeden z produktów w koszyku jest niedostępny." };
}
validatedProducts.push({
name: dbProduct.name,
quantity: p.quantity,
price: dbProduct.price
});
calculatedTotal += dbProduct.price * p.quantity;
}
const productRows = validatedProducts.map(p =>
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${escapeHtml(p.name)}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
).join('');
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
to: process.env.RESEND_TO_EMAIL || '[email protected]',
await mailClient.resend.emails.send({
from: mailClient.fromEmail,
to: mailClient.toEmail,
replyTo: data.email,
subject: `Nowe zamówienie od ${data.name} — ${total.toLocaleString('pl-PL')} zł`,
subject: `Nowe zamówienie od ${escapeHtml(data.name)} — ${calculatedTotal.toLocaleString('pl-PL')} zł`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#0F2A47;padding:20px;text-align:center;">
@@ -60,11 +97,11 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
</div>
<div style="padding:24px;">
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
<p><strong>Imię:</strong> ${data.name}</p>
<p><strong>Telefon:</strong> <a href="tel:${data.phone}">${data.phone}</a></p>
<p><strong>Email:</strong> ${data.email}</p>
${data.address ? `<p><strong>Adres:</strong> ${data.address}</p>` : ''}
${data.notes ? `<p><strong>Uwagi:</strong> ${data.notes}</p>` : ''}
<p><strong>Imię:</strong> ${escapeHtml(data.name)}</p>
<p><strong>Telefon:</strong> <a href="tel:${escapeHtml(data.phone)}">${escapeHtml(data.phone)}</a></p>
<p><strong>Email:</strong> ${escapeHtml(data.email)}</p>
${data.address ? `<p><strong>Adres:</strong> ${escapeHtml(data.address)}</p>` : ''}
${data.notes ? `<p><strong>Uwagi:</strong> ${escapeHtml(data.notes)}</p>` : ''}
<h2 style="font-size:16px;">Zamówione produkty</h2>
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="background:#f8fafc;">
@@ -75,7 +112,7 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
<tbody>${productRows}</tbody>
<tfoot><tr>
<td colspan="2" style="padding:8px;font-weight:bold;">Łącznie (urządzenia)</td>
<td style="padding:8px;font-weight:bold;text-align:right;">${total.toLocaleString('pl-PL')} zł</td>
<td style="padding:8px;font-weight:bold;text-align:right;">${calculatedTotal.toLocaleString('pl-PL')} zł</td>
</tr></tfoot>
</table>
<p style="margin-top:16px;font-size:12px;color:#64748b;">+ koszt montażu do ustalenia telefonicznie</p>
@@ -86,6 +123,6 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
return { success: true, message: 'Dziękujemy! Skontaktujemy się z Tobą w ciągu 24 godzin, aby potwierdzić zamówienie i ustalić termin montażu.' };
} catch {
return { success: false, message: 'Wystąpił błąd. Spróbuj ponownie lub zadzwoń do nas bezpośrednio.' };
return { success: false, message: 'Wystąpił błąd podczas wysyłania zamówienia. Spróbuj ponownie.' };
}
}