Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99467f1379 | |||
| 6b3f9b9b25 | |||
| 2c51831a81 | |||
| db0963a7db | |||
| aac0529bb6 | |||
| 5006163ddf | |||
| 35fb31249a | |||
| 120ba0488c | |||
| ffb637e05c | |||
| b97de267d9 | |||
| 1c3918d9e3 |
@@ -53,6 +53,9 @@ GATEKEEPER_PASSWORD=klz2026
|
|||||||
# Required for the Payload CMS AI Chat Agent
|
# Required for the Payload CMS AI Chat Agent
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
|
|
||||||
|
# Secret used to sign contact-form anti-spam tokens (server-anchored time-trap)
|
||||||
|
FORM_TOKEN_SECRET=
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Payload Infrastructure (Dockerized)
|
# Payload Infrastructure (Dockerized)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -44,3 +44,10 @@ next-env.d.ts
|
|||||||
# scratch
|
# scratch
|
||||||
scratch/
|
scratch/
|
||||||
|
|
||||||
|
# kilo (local agent config & session data)
|
||||||
|
.kilo/
|
||||||
|
|
||||||
|
# debug artifacts
|
||||||
|
contact-page-debug.html
|
||||||
|
*.debug.html
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { sendEmail } from '@/lib/mail/mailer';
|
|||||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||||
|
import { createFormToken } from '@/lib/antispam/form-token';
|
||||||
|
import { parseClientIp } from '@/lib/antispam/client-ip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a server-signed form token used by the anti-spam guard to prove the
|
||||||
|
* submission originates from a real page load (server-anchored time-trap).
|
||||||
|
*/
|
||||||
|
export async function issueFormTokenAction(): Promise<string> {
|
||||||
|
return createFormToken(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendContactFormAction(formData: FormData) {
|
export async function sendContactFormAction(formData: FormData) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
@@ -25,11 +35,23 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
// Track attempt
|
// Track attempt
|
||||||
services.analytics.track('contact-form-attempt');
|
services.analytics.track('contact-form-attempt');
|
||||||
|
|
||||||
// Anti-spam Honeypot Check
|
// Anti-spam guard: honeypot, signed form token (server-anchored time-trap), email validation, link limit, IP rate limit
|
||||||
const honeypot = formData.get('company_website') as string;
|
const { checkContactSubmission } = await import('@/lib/antispam/contact-guard');
|
||||||
if (honeypot) {
|
const verdict = checkContactSubmission({
|
||||||
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
|
honeypot: (formData.get('company_website') as string) || null,
|
||||||
// Silently succeed to fool the bot without doing actual work
|
formToken: (formData.get('form_token') as string) || null,
|
||||||
|
now: Date.now(),
|
||||||
|
ip: parseClientIp(requestHeaders.get('x-forwarded-for')),
|
||||||
|
email: (formData.get('email') as string) || null,
|
||||||
|
message: (formData.get('message') as string) || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verdict.allowed) {
|
||||||
|
logger.warn('Spam blocked by anti-spam guard', {
|
||||||
|
reason: verdict.reason,
|
||||||
|
email: formData.get('email'),
|
||||||
|
});
|
||||||
|
// Silently succeed to fool bots without doing actual work
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
@@ -12,6 +12,18 @@ export default function ContactForm() {
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
const [hasStarted, setHasStarted] = useState(false);
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
const [formToken, setFormToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Server-signed anti-spam token: proves the submission comes from a real page load
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
issueFormTokenAction().then((token) => {
|
||||||
|
if (!cancelled) setFormToken(token);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFocus = (fieldId: string) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
// Initial form start
|
// Initial form start
|
||||||
@@ -152,6 +164,8 @@ export default function ContactForm() {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
|
||||||
|
<input type="hidden" name="form_token" value={formToken ?? ''} />
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Input, Textarea, Button } from '@/components/ui';
|
import { Input, Textarea, Button } from '@/components/ui';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
@@ -18,6 +18,18 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
const [request, setRequest] = useState('');
|
const [request, setRequest] = useState('');
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
const [hasStarted, setHasStarted] = useState(false);
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
const [formToken, setFormToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Server-signed anti-spam token: proves the submission comes from a real page load
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
issueFormTokenAction().then((token) => {
|
||||||
|
if (!cancelled) setFormToken(token);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFocus = (fieldId: string) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
// Initial form start
|
// Initial form start
|
||||||
@@ -38,14 +50,13 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStatus('submitting');
|
setStatus('submitting');
|
||||||
|
|
||||||
const formData = new FormData();
|
// Build from the real form element so honeypot + form_token hidden inputs are included
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
formData.append('name', 'Product Inquiry'); // Default name for product inquiries
|
formData.append('name', 'Product Inquiry'); // Default name for product inquiries
|
||||||
formData.append('email', email);
|
|
||||||
formData.append('message', request);
|
|
||||||
formData.append('productName', productName);
|
formData.append('productName', productName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -172,8 +183,10 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
|
||||||
|
<input type="hidden" name="form_token" value={formToken ?? ''} />
|
||||||
|
|
||||||
<div className="space-y-2 !mt-0">
|
<div className="space-y-2 !mt-0">
|
||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
@@ -183,6 +196,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id={emailId}
|
id={emailId}
|
||||||
|
name="email"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@@ -198,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={requestId}
|
id={requestId}
|
||||||
|
name="message"
|
||||||
required
|
required
|
||||||
rows={3}
|
rows={3}
|
||||||
value={request}
|
value={request}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden"
|
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Card Banner with Signature E-TIB LogoArcs Design Element */}
|
{/* Card Banner with Signature E-TIB LogoArcs Design Element */}
|
||||||
<div className="h-36 bg-neutral-50 relative border-b border-neutral-100 overflow-hidden select-none">
|
<div className="h-44 sm:h-52 md:h-36 bg-neutral-50 relative border-b border-neutral-100 overflow-hidden select-none">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-neutral-100/90 via-neutral-50 to-orange-50/20" />
|
<div className="absolute inset-0 bg-gradient-to-br from-neutral-100/90 via-neutral-50 to-orange-50/20" />
|
||||||
|
|
||||||
{/* Signature E-TIB Logo Arcs Design Element */}
|
{/* Signature E-TIB Logo Arcs Design Element */}
|
||||||
@@ -171,17 +171,19 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
<div className="absolute inset-0 opacity-[0.025] pointer-events-none" style={{ backgroundImage: "url('data:image/svg+xml,%3Csvg width=\\'20\\' height=\\'20\\' viewBox=\\'0 0 20 20\\' xmlns=\\'http://www.w3.org/2000/svg\\'%3E%3Crect width=\\'1\\' height=\\'1\\' fill=\\'%23000000\\'/%3E%3C/svg%3E')" }} />
|
<div className="absolute inset-0 opacity-[0.025] pointer-events-none" style={{ backgroundImage: "url('data:image/svg+xml,%3Csvg width=\\'20\\' height=\\'20\\' viewBox=\\'0 0 20 20\\' xmlns=\\'http://www.w3.org/2000/svg\\'%3E%3Crect width=\\'1\\' height=\\'1\\' fill=\\'%23000000\\'/%3E%3C/svg%3E')" }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 pb-8 relative flex-grow flex flex-col">
|
<div className="px-6 sm:px-8 pb-8 relative flex-grow flex flex-col items-center md:items-start text-center md:text-left">
|
||||||
{/* Overlapping Profile Picture with Shared Layout Animation */}
|
{/* Overlapping Profile Picture (Extra Large Centered on Mobile, Lightbox modal active only on Desktop) */}
|
||||||
<m.div
|
<m.div
|
||||||
layoutId={`team-avatar-${member.id}`}
|
layoutId={`team-avatar-${member.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage([0, 0]);
|
if (typeof window !== 'undefined' && window.innerWidth >= 768) {
|
||||||
setSelectedMember(member);
|
setPage([0, 0]);
|
||||||
|
setSelectedMember(member);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => handlePreload(imageUrl)}
|
onMouseEnter={() => handlePreload(imageUrl)}
|
||||||
onTouchStart={() => handlePreload(imageUrl)}
|
onTouchStart={() => handlePreload(imageUrl)}
|
||||||
className="w-32 h-40 md:w-36 md:h-44 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-white relative -mt-20 md:-mt-24 mb-6 group-hover:-translate-y-2 transition-transform duration-500 cursor-pointer group/avatar"
|
className="w-52 h-64 sm:w-60 sm:h-72 md:w-36 md:h-44 mx-auto md:mx-0 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-white relative -mt-28 md:-mt-24 mb-6 transition-transform duration-500 cursor-default md:cursor-pointer group/avatar"
|
||||||
>
|
>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<>
|
<>
|
||||||
@@ -189,11 +191,11 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={member.name}
|
alt={member.name}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 768px) 128px, 144px"
|
sizes="(max-width: 768px) 300px, 144px"
|
||||||
className="object-cover object-[center_15%] transition-transform duration-500 group-hover/avatar:scale-105"
|
className="object-cover object-[center_15%] transition-transform duration-500 group-hover/avatar:scale-105"
|
||||||
/>
|
/>
|
||||||
{/* Zoom Icon Overlay on Hover */}
|
{/* Zoom Icon Overlay on Hover (Desktop only) */}
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 flex items-center justify-center text-white cursor-pointer">
|
<div className="hidden md:flex absolute inset-0 bg-black/40 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 items-center justify-center text-white cursor-pointer">
|
||||||
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
@@ -204,8 +206,8 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-neutral-300 bg-neutral-50 group-hover/avatar:bg-neutral-100 transition-colors">
|
<div className="absolute inset-0 flex items-center justify-center text-neutral-300 bg-neutral-50 group-hover/avatar:bg-neutral-100 transition-colors">
|
||||||
<svg className="w-14 h-14" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
<svg className="w-16 h-16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 flex items-center justify-center text-white cursor-pointer">
|
<div className="hidden md:flex absolute inset-0 bg-black/30 opacity-0 group-hover/avatar:opacity-100 transition-opacity duration-300 items-center justify-center text-white cursor-pointer">
|
||||||
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg className="w-8 h-8 drop-shadow-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
@@ -230,7 +232,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Contacts (Sticky at bottom) */}
|
{/* Contacts (Sticky at bottom) */}
|
||||||
<div className="mt-auto pt-6 border-t border-neutral-100 flex flex-col gap-4">
|
<div className="mt-auto pt-6 border-t border-neutral-100 flex flex-col gap-4 w-full text-left">
|
||||||
{member.email && (
|
{member.email && (
|
||||||
<a href={`mailto:${member.email}`} className="group/link flex items-center gap-4 text-text-secondary hover:text-primary transition-colors font-medium cursor-pointer">
|
<a href={`mailto:${member.email}`} className="group/link flex items-center gap-4 text-text-secondary hover:text-primary transition-colors font-medium cursor-pointer">
|
||||||
<div className="w-10 h-10 rounded-xl bg-neutral-50 flex items-center justify-center text-neutral-400 group-hover/link:bg-primary/10 group-hover/link:text-primary transition-colors border border-neutral-100">
|
<div className="w-10 h-10 rounded-xl bg-neutral-50 flex items-center justify-center text-neutral-400 group-hover/link:bg-primary/10 group-hover/link:text-primary transition-colors border border-neutral-100">
|
||||||
@@ -370,10 +372,10 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
{/* Mobile Drag Indicator Bar */}
|
{/* Mobile Drag Indicator Bar */}
|
||||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-30 w-12 h-1.5 rounded-full bg-white/50 backdrop-blur-sm pointer-events-none" />
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-30 w-12 h-1.5 rounded-full bg-white/50 backdrop-blur-sm pointer-events-none" />
|
||||||
|
|
||||||
{/* Portrait Container - Tall Portrait Aspect Framing for Mobile */}
|
{/* Portrait Container - Immersive Extra Large Portrait Aspect Framing for Mobile */}
|
||||||
<m.div
|
<m.div
|
||||||
layoutId={`team-avatar-${selectedMember.id}`}
|
layoutId={`team-avatar-${selectedMember.id}`}
|
||||||
className="relative w-full h-[52vh] min-h-[380px] max-h-[520px] md:h-[460px] bg-neutral-900 overflow-hidden shrink-0"
|
className="relative w-full h-[60vh] min-h-[440px] max-h-[620px] md:h-[460px] bg-neutral-900 overflow-hidden shrink-0"
|
||||||
>
|
>
|
||||||
{selectedMember.image ? (
|
{selectedMember.image ? (
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -3,10 +3,22 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
|
||||||
|
|
||||||
export function ContactForm() {
|
export function ContactForm() {
|
||||||
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
const [formToken, setFormToken] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Server-signed anti-spam token: proves the submission comes from a real page load
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
issueFormTokenAction().then((token) => {
|
||||||
|
if (!cancelled) setFormToken(token);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -132,6 +144,7 @@ export function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
||||||
|
<input type="hidden" name="form_token" value={formToken ?? ''} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ layout: "fullBleed"
|
|||||||
icon: "hdd",
|
icon: "hdd",
|
||||||
lists: [
|
lists: [
|
||||||
[
|
[
|
||||||
"Horizontalspülbohrverfahren (bis 250m)",
|
"Horizontalspülbohrverfahren (bis 400m)",
|
||||||
"Horizontalspülbohrverfahren (bis 400er Rohr)",
|
"Horizontalspülbohrverfahren (bis 400er Rohr)",
|
||||||
"Erdrakete (bis 15m DÖ-Länge)",
|
"Erdrakete (bis 15m DÖ-Länge)",
|
||||||
"Erdrakete (bis 160er Rohr)"
|
"Erdrakete (bis 160er Rohr)"
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ layout: "fullBleed"
|
|||||||
icon: "hdd",
|
icon: "hdd",
|
||||||
lists: [
|
lists: [
|
||||||
[
|
[
|
||||||
"Horizontal directional drilling (up to 250m)",
|
"Horizontal directional drilling (up to 400m)",
|
||||||
"Horizontal directional drilling (up to 400mm pipe)",
|
"Horizontal directional drilling (up to 400mm pipe)",
|
||||||
"Earth rocket (up to 15m crossing length)",
|
"Earth rocket (up to 15m crossing length)",
|
||||||
"Earth rocket (up to 160mm pipe)"
|
"Earth rocket (up to 160mm pipe)"
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ services:
|
|||||||
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0 --port 3001"
|
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0 --port 3001"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3001"
|
- "traefik.http.services.${PROJECT_NAME:-etib}-app-svc.loadbalancer.server.port=3001"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: ${COMPOSE_PROJECT_NAME:-etib}-dev-internal
|
name: ${COMPOSE_PROJECT_NAME:-etib}-dev-internal
|
||||||
|
|||||||
@@ -15,46 +15,47 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.entrypoints=web"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-web.entrypoints=web"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.middlewares=redirect-https"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-web.middlewares=redirect-https"
|
||||||
# HTTPS router (Standard)
|
# HTTPS router (Standard)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}.service=${PROJECT_NAME:-etib}-app-svc"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}"
|
||||||
|
|
||||||
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|assets|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|assets|_next|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
|
|
||||||
|
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.service=${PROJECT_NAME:-etib}-app-svc"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-public.priority=2000"
|
||||||
|
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-etib}-app-svc.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Middlewares
|
# Middlewares
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-compress.compress=true"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-ratelimit.ratelimit.average=100"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-ratelimit.ratelimit.burst=50"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
|
||||||
# Login redirect – the app's middleware sends users to /login but login lives at /gatekeeper/login
|
# Login redirect – the app's middleware sends users to /login but login lives at /gatekeeper/login
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.regex=^https?://([^/]+)/([a-z]{2}/)?login(.*)"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.regex=^https?://([^/]+)/([a-z]{2}/)?login(.*)"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.replacement=https://$${1}/gatekeeper/login$${3}"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.replacement=https://$${1}/gatekeeper/login$${3}"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.permanent=false"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.permanent=false"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?login`)"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathRegexp(`^/([a-z]{2}/)?login`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.middlewares=${PROJECT_NAME:-klz}-loginredirect"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.middlewares=${PROJECT_NAME:-etib}-loginredirect"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.service=${PROJECT_NAME:-klz}-app-svc"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.service=${PROJECT_NAME:-etib}-app-svc"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.priority=2002"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.priority=2002"
|
||||||
|
|
||||||
etib-gatekeeper:
|
etib-gatekeeper:
|
||||||
profiles: [ "gatekeeper" ]
|
profiles: [ "gatekeeper" ]
|
||||||
@@ -64,7 +65,7 @@ services:
|
|||||||
default:
|
default:
|
||||||
infra:
|
infra:
|
||||||
aliases:
|
aliases:
|
||||||
- ${PROJECT_NAME:-klz}-gatekeeper
|
- ${PROJECT_NAME:-etib}-gatekeeper
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
@@ -73,19 +74,20 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME:-etib}-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.address=http://${PROJECT_NAME:-etib}-gatekeeper:3000/gatekeeper/api/verify"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.trustForwardHeader=true"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Gatekeeper Public Router (Login/Auth UI) — basePath mode on main domain
|
# Gatekeeper Public Router (Login/Auth UI) — basePath mode on main domain
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathPrefix(`/gatekeeper`)"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-e-tib.com}`) || Host(`staging.${TRAEFIK_HOST:-e-tib.com}`) || Host(`testing.${TRAEFIK_HOST:-e-tib.com}`)}) && PathPrefix(`/gatekeeper`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.service=${PROJECT_NAME:-etib}-gatekeeper-svc"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.priority=2001"
|
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.priority=2001"
|
||||||
|
|
||||||
|
|
||||||
etib-db:
|
etib-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
|
|||||||
18
lib/antispam/client-ip.ts
Normal file
18
lib/antispam/client-ip.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Extracts the client IP from the X-Forwarded-For header.
|
||||||
|
*
|
||||||
|
* Our edge proxy (Traefik) APPENDS the real peer IP to any client-supplied
|
||||||
|
* chain, so the LAST hop is the only trustworthy entry. The first hop is
|
||||||
|
* attacker-controlled: reading it lets bots rotate fake IPs per request and
|
||||||
|
* defeat IP-based rate limiting entirely.
|
||||||
|
*/
|
||||||
|
export function parseClientIp(xForwardedFor: string | null | undefined): string | null {
|
||||||
|
if (!xForwardedFor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hops = xForwardedFor
|
||||||
|
.split(',')
|
||||||
|
.map((hop) => hop.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return hops.length > 0 ? hops[hops.length - 1] : null;
|
||||||
|
}
|
||||||
91
lib/antispam/contact-guard.ts
Normal file
91
lib/antispam/contact-guard.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Server-side anti-spam guard for contact form submissions.
|
||||||
|
* Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { verifyFormToken } from './form-token';
|
||||||
|
|
||||||
|
export const MIN_FILL_TIME_MS = 3_000;
|
||||||
|
export const RATE_LIMIT_MAX_SUBMISSIONS = 3;
|
||||||
|
export const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
|
||||||
|
export const MAX_MESSAGE_LINKS = 5;
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||||
|
const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi;
|
||||||
|
|
||||||
|
export type SpamReason =
|
||||||
|
| 'honeypot'
|
||||||
|
| 'invalid_token'
|
||||||
|
| 'expired_token'
|
||||||
|
| 'too_fast'
|
||||||
|
| 'invalid_email'
|
||||||
|
| 'too_many_links'
|
||||||
|
| 'rate_limited';
|
||||||
|
|
||||||
|
export interface ContactSubmissionInput {
|
||||||
|
honeypot: string | null;
|
||||||
|
formToken: string | null;
|
||||||
|
now: number;
|
||||||
|
ip: string | null;
|
||||||
|
email: string | null;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSubmissionVerdict {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: SpamReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionTimestampsByIp = new Map<string, number[]>();
|
||||||
|
|
||||||
|
export function resetRateLimiter(): void {
|
||||||
|
submissionTimestampsByIp.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRateLimited(ip: string, now: number): boolean {
|
||||||
|
const timestamps = (submissionTimestampsByIp.get(ip) ?? []).filter(
|
||||||
|
(ts) => now - ts < RATE_LIMIT_WINDOW_MS,
|
||||||
|
);
|
||||||
|
if (timestamps.length >= RATE_LIMIT_MAX_SUBMISSIONS) {
|
||||||
|
submissionTimestampsByIp.set(ip, timestamps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
timestamps.push(now);
|
||||||
|
submissionTimestampsByIp.set(ip, timestamps);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkContactSubmission(input: ContactSubmissionInput): ContactSubmissionVerdict {
|
||||||
|
if (input.honeypot) {
|
||||||
|
return { allowed: false, reason: 'honeypot' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-signed token: rejects direct POSTs that never loaded the form and
|
||||||
|
// anchors the time-trap to server-verified issuance time.
|
||||||
|
const token = verifyFormToken(input.formToken, input.now);
|
||||||
|
if (!token.ok) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: token.reason === 'expired' ? 'expired_token' : 'invalid_token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.now - token.issuedAt! < MIN_FILL_TIME_MS) {
|
||||||
|
return { allowed: false, reason: 'too_fast' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.email || !EMAIL_PATTERN.test(input.email)) {
|
||||||
|
return { allowed: false, reason: 'invalid_email' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = input.message ?? '';
|
||||||
|
if ((message.match(LINK_PATTERN) ?? []).length > MAX_MESSAGE_LINKS) {
|
||||||
|
return { allowed: false, reason: 'too_many_links' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.ip && isRateLimited(input.ip, input.now)) {
|
||||||
|
return { allowed: false, reason: 'rate_limited' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
64
lib/antispam/form-token.ts
Normal file
64
lib/antispam/form-token.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-signed form token: proves a submission originates from a real page
|
||||||
|
* load and anchors the time-trap to SERVER time instead of a client-supplied
|
||||||
|
* timestamp (which bots can forge arbitrarily).
|
||||||
|
*
|
||||||
|
* Token format: `<issuedAtMs>.<hmac-sha256(issuedAt, secret)>` (base64url).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_FORM_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type FormTokenFailureReason = 'malformed' | 'forged' | 'expired';
|
||||||
|
|
||||||
|
export interface FormTokenVerification {
|
||||||
|
ok: boolean;
|
||||||
|
issuedAt?: number;
|
||||||
|
reason?: FormTokenFailureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormTokenSecret(): string {
|
||||||
|
return process.env.FORM_TOKEN_SECRET || 'insecure-dev-form-token-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(issuedAt: number, secret: string): string {
|
||||||
|
return createHmac('sha256', secret).update(String(issuedAt)).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormToken(now: number, secret: string = getFormTokenSecret()): string {
|
||||||
|
return `${now}.${sign(now, secret)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyFormToken(
|
||||||
|
token: string | null,
|
||||||
|
now: number,
|
||||||
|
secret: string = getFormTokenSecret(),
|
||||||
|
): FormTokenVerification {
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, reason: 'malformed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return { ok: false, reason: 'malformed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuedAt = Number(parts[0]);
|
||||||
|
if (!Number.isFinite(issuedAt) || issuedAt <= 0) {
|
||||||
|
return { ok: false, reason: 'malformed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = Buffer.from(sign(issuedAt, secret));
|
||||||
|
const provided = Buffer.from(parts[1]);
|
||||||
|
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
|
||||||
|
return { ok: false, reason: 'forged' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const age = now - issuedAt;
|
||||||
|
if (age < 0 || age > MAX_FORM_AGE_MS) {
|
||||||
|
return { ok: false, reason: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, issuedAt };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "e-tib-nextjs",
|
"name": "e-tib-nextjs",
|
||||||
"version": "2.4.51",
|
"version": "2.4.58",
|
||||||
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
|
|||||||
11
proxy.ts
11
proxy.ts
@@ -7,11 +7,12 @@ export default createMiddleware({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Match all pathnames except for
|
// Match all pathnames except for:
|
||||||
// - /api (API routes)
|
// - /api (API routes)
|
||||||
// - /stats (Analytics proxy)
|
// - /stats (Analytics proxy)
|
||||||
// - /_next (Next.js internals)
|
// - /_next (Next.js internals & image optimizer)
|
||||||
// - /_static (inside /public)
|
// - /assets, /_static (static files in /public)
|
||||||
// - all root files inside /public (e.g. /favicon.ico)
|
// - all files with an extension (e.g. /favicon.ico, .JPG, .png, .webp, .svg)
|
||||||
matcher: ['/((?!api|stats|_next|assets|_static|_vercel|[\\w-]+\\.\\w+).*)']
|
matcher: ['/((?!api|stats|_next|assets|_static|_vercel|.*\\..*).*)']
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,22 +31,23 @@ fi
|
|||||||
get_media_path() {
|
get_media_path() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
local) echo "$LOCAL_MEDIA_DIR" ;;
|
local) echo "$LOCAL_MEDIA_DIR" ;;
|
||||||
testing) echo "/var/lib/docker/volumes/klz-testing_klz_media_data/_data" ;;
|
testing) echo "/var/lib/docker/volumes/etib-testing_etib_media_data/_data" ;;
|
||||||
staging) echo "/var/lib/docker/volumes/klz-staging_klz_media_data/_data" ;;
|
staging) echo "/var/lib/docker/volumes/etib-staging_etib_media_data/_data" ;;
|
||||||
prod|production) echo "/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data" ;;
|
prod|production) echo "/var/lib/docker/volumes/e-tibcom_etib_media_data/_data" ;;
|
||||||
*) echo "❌ Unknown environment: $1"; exit 1 ;;
|
*) echo "❌ Unknown environment: $1"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
get_app_container() {
|
get_app_container() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
testing) echo "klz-testing-klz-app-1" ;;
|
testing) echo "etib-testing-etib-app-1" ;;
|
||||||
staging) echo "klz-staging-klz-app-1" ;;
|
staging) echo "etib-staging-etib-app-1" ;;
|
||||||
prod|production) echo "klz-cablescom-klz-app-1" ;;
|
prod|production) echo "e-tibcom-etib-app-1" ;;
|
||||||
*) echo "" ;;
|
*) echo "" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
SRC_PATH=$(get_media_path "$SOURCE_ENV")
|
SRC_PATH=$(get_media_path "$SOURCE_ENV")
|
||||||
TGT_PATH=$(get_media_path "$TARGET_ENV")
|
TGT_PATH=$(get_media_path "$TARGET_ENV")
|
||||||
TGT_CONTAINER=$(get_app_container "$TARGET_ENV")
|
TGT_CONTAINER=$(get_app_container "$TARGET_ENV")
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export NEXT_PUBLIC_CI=true
|
|||||||
export CI=true
|
export CI=true
|
||||||
|
|
||||||
docker network create infra 2>/dev/null || true
|
docker network create infra 2>/dev/null || true
|
||||||
docker volume create klz_db_data 2>/dev/null || true
|
docker volume create etib_db_data 2>/dev/null || true
|
||||||
|
|
||||||
|
|
||||||
# 2. Start infra services (DB, CMS, Gatekeeper)
|
# 2. Start infra services (DB, CMS, Gatekeeper)
|
||||||
echo "📦 Starting infrastructure services..."
|
echo "📦 Starting infrastructure services..."
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const targetUrl =
|
|||||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
'http://localhost:3000';
|
'http://localhost:3000';
|
||||||
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
|
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
||||||
@@ -20,9 +21,10 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
const response = await axios.get(sitemapUrl, {
|
const response = await axios.get(sitemapUrl, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
urls = $('url loc')
|
urls = $('url loc')
|
||||||
.map((i, el) => $(el).text())
|
.map((i, el) => $(el).text())
|
||||||
@@ -64,7 +66,7 @@ async function main() {
|
|||||||
|
|
||||||
// Inject Gatekeeper session bypassing auth screens
|
// Inject Gatekeeper session bypassing auth screens
|
||||||
await page.setCookie({
|
await page.setCookie({
|
||||||
name: 'klz_gatekeeper_session',
|
name: authCookieName,
|
||||||
value: gatekeeperPassword,
|
value: gatekeeperPassword,
|
||||||
domain: new URL(targetUrl).hostname,
|
domain: new URL(targetUrl).hostname,
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import axios from 'axios';
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
// Utility for hardcoded delays
|
// Utility for hardcoded delays
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -20,7 +21,7 @@ async function main() {
|
|||||||
while (Date.now() - startTime < maxWaitMs) {
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
try {
|
try {
|
||||||
const resp = await axios.get(targetUrl, {
|
const resp = await axios.get(targetUrl, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
validateStatus: (s) => s === 200,
|
validateStatus: (s) => s === 200,
|
||||||
});
|
});
|
||||||
@@ -50,7 +51,7 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
const response = await axios.get(sitemapUrl, {
|
const response = await axios.get(sitemapUrl, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import * as path from 'path';
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
|
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
|
||||||
@@ -16,10 +17,11 @@ async function main() {
|
|||||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
|
||||||
const response = await axios.get(sitemapUrl, {
|
const response = await axios.get(sitemapUrl, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
validateStatus: (status) => status < 400,
|
validateStatus: (status) => status < 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
let urls = $('url loc')
|
let urls = $('url loc')
|
||||||
.map((i, el) => $(el).text())
|
.map((i, el) => $(el).text())
|
||||||
@@ -47,10 +49,11 @@ async function main() {
|
|||||||
const u = urls[i];
|
const u = urls[i];
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(u, {
|
const res = await axios.get(u, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
validateStatus: (status) => status < 400,
|
validateStatus: (status) => status < 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Generate a safe filename that retains URL information
|
// Generate a safe filename that retains URL information
|
||||||
const urlStr = new URL(u);
|
const urlStr = new URL(u);
|
||||||
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
|
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import axios from 'axios';
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
|
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
|
||||||
@@ -12,10 +13,11 @@ async function main() {
|
|||||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
|
||||||
const response = await axios.get(sitemapUrl, {
|
const response = await axios.get(sitemapUrl, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
validateStatus: (status) => status < 400,
|
validateStatus: (status) => status < 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
let urls = $('url loc')
|
let urls = $('url loc')
|
||||||
.map((i, el) => $(el).text())
|
.map((i, el) => $(el).text())
|
||||||
@@ -42,10 +44,11 @@ async function main() {
|
|||||||
const u = urls[i];
|
const u = urls[i];
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(u, {
|
const res = await axios.get(u, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
validateStatus: null, // Don't throw on error status
|
validateStatus: null, // Don't throw on error status
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
|
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import * as cheerio from 'cheerio';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
// Expected slug translations: German key → English value
|
// Expected slug translations: German key → English value
|
||||||
const SLUG_MAP: Record<string, string> = {
|
const SLUG_MAP: Record<string, string> = {
|
||||||
@@ -32,7 +33,8 @@ const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
|
|||||||
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
|
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` };
|
const headers = { Cookie: `${authCookieName}=${gatekeeperPassword}` };
|
||||||
|
|
||||||
|
|
||||||
function getExpectedTranslation(
|
function getExpectedTranslation(
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
const requiredHeaders = [
|
const requiredHeaders = [
|
||||||
'strict-transport-security',
|
'strict-transport-security',
|
||||||
@@ -15,10 +16,11 @@ async function main() {
|
|||||||
console.log(`\n🛡️ Starting Security Headers Scan for: ${targetUrl}\n`);
|
console.log(`\n🛡️ Starting Security Headers Scan for: ${targetUrl}\n`);
|
||||||
try {
|
try {
|
||||||
const response = await axios.head(targetUrl, {
|
const response = await axios.head(targetUrl, {
|
||||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
|
||||||
validateStatus: () => true,
|
validateStatus: () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const headers = response.headers;
|
const headers = response.headers;
|
||||||
let allPassed = true;
|
let allPassed = true;
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ module.exports = async (browser, context) => {
|
|||||||
// Using LHCI_URL or TARGET_URL if available
|
// Using LHCI_URL or TARGET_URL if available
|
||||||
const targetUrl =
|
const targetUrl =
|
||||||
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.e-tib.com';
|
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.e-tib.com';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
|
|
||||||
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie (${authCookieName}) for ${new URL(targetUrl).hostname}...`);
|
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie (${authCookieName}) for ${new URL(targetUrl).hostname}...`);
|
||||||
|
|
||||||
await page.setCookie({
|
await page.setCookie({
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ const targetUrl =
|
|||||||
process.env.LHCI_URL ||
|
process.env.LHCI_URL ||
|
||||||
'http://localhost:3000';
|
'http://localhost:3000';
|
||||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
|
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
|
||||||
console.log(`📊 Limit: ${limit} pages\n`);
|
console.log(`📊 Limit: ${limit} pages\n`);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
console.log(`🚀 Smoke Test: ${targetUrl}`);
|
console.log(`🚀 Smoke Test: ${targetUrl}`);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import * as path from 'path';
|
|||||||
|
|
||||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
|
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
|
||||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
|
||||||
|
const authCookieName = process.env.AUTH_COOKIE_NAME || 'etib_gatekeeper_session';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
|
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
|
||||||
@@ -27,11 +28,12 @@ async function main() {
|
|||||||
|
|
||||||
const response = await axios.get(sitemapUrl, {
|
const response = await axios.get(sitemapUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
Cookie: `${authCookieName}=${gatekeeperPassword}`,
|
||||||
},
|
},
|
||||||
validateStatus: (status) => status < 400,
|
validateStatus: (status) => status < 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
let urls = $('url loc')
|
let urls = $('url loc')
|
||||||
.map((i, el) => $(el).text())
|
.map((i, el) => $(el).text())
|
||||||
@@ -92,8 +94,9 @@ async function main() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
|
Cookie: `${authCookieName}=${gatekeeperPassword}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
timeout: 60000, // Increase timeout for slower pages
|
timeout: 60000, // Increase timeout for slower pages
|
||||||
},
|
},
|
||||||
urls: urls,
|
urls: urls,
|
||||||
|
|||||||
105
tests/contact-antispam.test.ts
Normal file
105
tests/contact-antispam.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { checkContactSubmission, resetRateLimiter } from '@/lib/antispam/contact-guard';
|
||||||
|
import { parseClientIp } from '@/lib/antispam/client-ip';
|
||||||
|
import { createFormToken } from '@/lib/antispam/form-token';
|
||||||
|
|
||||||
|
process.env.FORM_TOKEN_SECRET = 'test-secret';
|
||||||
|
|
||||||
|
const NOW = 1_000_000_000_000;
|
||||||
|
const TOKEN_SECRET = 'test-secret';
|
||||||
|
|
||||||
|
const validInput = () => ({
|
||||||
|
honeypot: '',
|
||||||
|
formToken: createFormToken(NOW - 10_000, TOKEN_SECRET),
|
||||||
|
now: NOW,
|
||||||
|
ip: '1.2.3.4',
|
||||||
|
email: 'customer@example.com',
|
||||||
|
message: 'I would like a quote for 50 cables.',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('client IP extraction from X-Forwarded-For', () => {
|
||||||
|
it('uses the last hop appended by our own proxy, not the client-controlled first hop', () => {
|
||||||
|
expect(parseClientIp('203.0.113.7, 198.51.100.2')).toBe('198.51.100.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a single hop', () => {
|
||||||
|
expect(parseClientIp('203.0.113.7')).toBe('203.0.113.7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for missing or empty headers', () => {
|
||||||
|
expect(parseClientIp(null)).toBeNull();
|
||||||
|
expect(parseClientIp('')).toBeNull();
|
||||||
|
expect(parseClientIp(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('contact form anti-spam guard', () => {
|
||||||
|
beforeEach(() => resetRateLimiter());
|
||||||
|
|
||||||
|
it('allows a legitimate submission', () => {
|
||||||
|
const result = checkContactSubmission(validInput());
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions where the honeypot field is filled', () => {
|
||||||
|
const result = checkContactSubmission({ ...validInput(), honeypot: 'http://spam.example' });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('honeypot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions without a form token (direct POST without loading the form)', () => {
|
||||||
|
const result = checkContactSubmission({ ...validInput(), formToken: null });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('invalid_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions with a forged form token', () => {
|
||||||
|
const result = checkContactSubmission({ ...validInput(), formToken: '1234567890.forged' });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('invalid_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions sent faster than a human could fill the form', () => {
|
||||||
|
const freshToken = createFormToken(NOW - 1_000, TOKEN_SECRET);
|
||||||
|
const result = checkContactSubmission({ ...validInput(), formToken: freshToken });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('too_fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions with a stale token (page loaded more than a day ago)', () => {
|
||||||
|
const staleToken = createFormToken(NOW - 25 * 60 * 60 * 1000, TOKEN_SECRET);
|
||||||
|
const result = checkContactSubmission({ ...validInput(), formToken: staleToken });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('expired_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions with an invalid email address', () => {
|
||||||
|
const result = checkContactSubmission({ ...validInput(), email: 'not-an-email' });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('invalid_email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks messages containing more than 5 links (typical SEO spam)', () => {
|
||||||
|
const spammy = Array.from({ length: 6 }, (_, i) => `https://spam${i}.example`).join(' ');
|
||||||
|
const result = checkContactSubmission({ ...validInput(), message: spammy });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('too_many_links');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limits an IP after 3 submissions within 10 minutes', () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
expect(checkContactSubmission(validInput()).allowed).toBe(true);
|
||||||
|
}
|
||||||
|
const result = checkContactSubmission(validInput());
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('rate_limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rate-limit different IPs independently', () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
checkContactSubmission(validInput());
|
||||||
|
}
|
||||||
|
const result = checkContactSubmission({ ...validInput(), ip: '5.6.7.8' });
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
tests/form-token.test.ts
Normal file
53
tests/form-token.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
createFormToken,
|
||||||
|
verifyFormToken,
|
||||||
|
MAX_FORM_AGE_MS,
|
||||||
|
} from '@/lib/antispam/form-token';
|
||||||
|
|
||||||
|
const SECRET = 'test-secret';
|
||||||
|
const NOW = 1_000_000_000_000;
|
||||||
|
|
||||||
|
describe('signed form token', () => {
|
||||||
|
it('issues a token that verifies with its issuance timestamp', () => {
|
||||||
|
const token = createFormToken(NOW, SECRET);
|
||||||
|
const result = verifyFormToken(token, NOW + 10_000, SECRET);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.issuedAt).toBe(NOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token that was tampered with', () => {
|
||||||
|
const token = createFormToken(NOW, SECRET);
|
||||||
|
const [issuedAt, signature] = token.split('.');
|
||||||
|
const tampered = `${issuedAt}.${signature.slice(0, -2)}xx`;
|
||||||
|
const result = verifyFormToken(tampered, NOW + 10_000, SECRET);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token forged with a different secret', () => {
|
||||||
|
const token = createFormToken(NOW, 'attacker-secret');
|
||||||
|
const result = verifyFormToken(token, NOW + 10_000, SECRET);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token with a manipulated issuance timestamp', () => {
|
||||||
|
const token = createFormToken(NOW, SECRET);
|
||||||
|
const [, signature] = token.split('.');
|
||||||
|
const forged = `${NOW - 60_000}.${signature}`;
|
||||||
|
const result = verifyFormToken(forged, NOW, SECRET);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed tokens', () => {
|
||||||
|
expect(verifyFormToken('', NOW, SECRET).ok).toBe(false);
|
||||||
|
expect(verifyFormToken('garbage', NOW, SECRET).ok).toBe(false);
|
||||||
|
expect(verifyFormToken('123.456.789', NOW, SECRET).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tokens older than the maximum form age', () => {
|
||||||
|
const token = createFormToken(NOW, SECRET);
|
||||||
|
const result = verifyFormToken(token, NOW + MAX_FORM_AGE_MS + 1, SECRET);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.reason).toBe('expired');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user