feat: security audit, rate limiting, server-side pricing, html escaping and security headers
This commit is contained in:
+17
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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"),
|
||||
});
|
||||
Reference in New Issue
Block a user