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
+17
View File
@@ -0,0 +1,17 @@
import { Resend } from 'resend';
export function getMailClient() {
const apiKey = process.env.RESEND_API_KEY;
const fromEmail = process.env.RESEND_FROM_EMAIL;
const toEmail = process.env.RESEND_TO_EMAIL;
if (!apiKey || !fromEmail || !toEmail) {
return null;
}
return {
resend: new Resend(apiKey),
fromEmail,
toEmail,
};
}
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod';
export function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const rateLimitMap = new Map<string, { count: number; expiresAt: number }>();
const MAX_REQUESTS = 5;
const WINDOW_MS = 10 * 60 * 1000; // 10 minutes
export function rateLimit(ip: string | undefined | null): boolean {
if (!ip) return true; // fallback if IP can't be resolved
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || entry.expiresAt < now) {
rateLimitMap.set(ip, { count: 1, expiresAt: now + WINDOW_MS });
return true;
}
if (entry.count >= MAX_REQUESTS) {
return false;
}
entry.count++;
return true;
}
export async function verifyRecaptcha(token: string): Promise<boolean> {
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
if (!secretKey) return false;
try {
const res = await fetch(`https://www.google.com/recaptcha/api/siteverify`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `secret=${secretKey}&response=${token}`,
});
const data = await res.json();
return data.success;
} catch (err) {
return false;
}
}
export const baseContactSchema = z.object({
name: z.string().min(2, "Imię musi mieć co najmniej 2 znaki").max(100, "Imię jest zbyt długie"),
phone: z.string().regex(/^[+\d\s-]{9,20}$/, "Niepoprawny format telefonu"),
email: z.string().email("Niepoprawny adres e-mail").max(150, "E-mail jest zbyt długi"),
consent: z.string().refine(val => val === "on" || val === "true", "Wymagana zgoda"),
});