Compare commits

...

5 Commits

Author SHA1 Message Date
99467f1379 fix(antispam): harden spam guard with signed form tokens and proxy-safe IP extraction
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 2m33s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 51s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Rate limit now uses the last X-Forwarded-For hop (appended by Traefik)
  instead of the client-controlled first hop, which bots rotated freely
- Time-trap anchored to server time via HMAC-signed form token
  (FORM_TOKEN_SECRET) instead of client-supplied form_loaded_at
- Fix RequestQuoteForm regression: FormData was built from scratch without
  honeypot/token hidden inputs and inputs lacked name attributes, so every
  legitimate quote request was silently blocked as spam
2026-09-02 10:06:07 +02:00
6b3f9b9b25 chore(gitignore): ignore .kilo local agent config and debug artifacts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Successful in 2m24s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-09-01 17:17:46 +02:00
2c51831a81 feat(antispam): add server-side contact form spam guard (honeypot, time-trap, rate limit, link cap) 2026-09-01 17:15:40 +02:00
db0963a7db chore(test): remove non-business logic config tests
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 25s
Build & Deploy / 🧪 QA (push) Successful in 1m24s
Build & Deploy / 🏗️ Build (push) Successful in 2m37s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-08-17 18:31:53 +02:00
aac0529bb6 fix(infra): replace legacy klz fallback references with etib across docker compose and test scripts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m44s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 51s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-08-17 18:31:29 +02:00
27 changed files with 516 additions and 108 deletions

View File

@@ -53,6 +53,9 @@ GATEKEEPER_PASSWORD=klz2026
# Required for the Payload CMS AI Chat Agent
MISTRAL_API_KEY=
# Secret used to sign contact-form anti-spam tokens (server-anchored time-trap)
FORM_TOKEN_SECRET=
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)
# ────────────────────────────────────────────────────────────────────────────

7
.gitignore vendored
View File

@@ -44,3 +44,10 @@ next-env.d.ts
# scratch
scratch/
# kilo (local agent config & session data)
.kilo/
# debug artifacts
contact-page-debug.html
*.debug.html

View File

@@ -4,6 +4,16 @@ import { sendEmail } from '@/lib/mail/mailer';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react';
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) {
const services = getServerAppServices();
@@ -25,11 +35,23 @@ export async function sendContactFormAction(formData: FormData) {
// Track attempt
services.analytics.track('contact-form-attempt');
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
// Silently succeed to fool the bot without doing actual work
// Anti-spam guard: honeypot, signed form token (server-anchored time-trap), email validation, link limit, IP rate limit
const { checkContactSubmission } = await import('@/lib/antispam/contact-guard');
const verdict = checkContactSubmission({
honeypot: (formData.get('company_website') as string) || null,
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 };
}

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
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 { AnalyticsEvents } from '@/components/analytics/analytics-events';
@@ -12,6 +12,18 @@ export default function ContactForm() {
const { trackEvent } = useAnalytics();
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
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) => {
// Initial form start
@@ -152,6 +164,8 @@ export default function ContactForm() {
style={{ display: 'none' }}
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">
<Label htmlFor="contact-name">{t('form.name')}</Label>
<Input

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
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 { AnalyticsEvents } from '@/components/analytics/analytics-events';
@@ -18,6 +18,18 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
const [request, setRequest] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
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) => {
// 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();
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('email', email);
formData.append('message', request);
formData.append('productName', productName);
try {
@@ -172,8 +183,10 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
tabIndex={-1}
autoComplete="off"
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-1 !mt-0">
@@ -183,6 +196,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<Input
type="email"
id={emailId}
name="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -198,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
</label>
<Textarea
id={requestId}
name="message"
required
rows={3}
value={request}

View File

@@ -3,10 +3,22 @@
import * as React from 'react';
import { m } from 'framer-motion';
import { Button } from '@/components/ui/Button';
import { sendContactFormAction } from '@/app/actions/contact';
import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
export function ContactForm() {
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>) => {
e.preventDefault();
@@ -132,6 +144,7 @@ export function ContactForm() {
</div>
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<input type="hidden" name="form_token" value={formToken ?? ''} />
<Button
type="submit"

View File

@@ -57,9 +57,10 @@ services:
sh -c "pnpm install --no-frozen-lockfile && pnpm next dev --webpack --hostname 0.0.0.0 --port 3001"
labels:
- "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"
networks:
default:
name: ${COMPOSE_PROJECT_NAME:-etib}-dev-internal

View File

@@ -15,47 +15,47 @@ services:
labels:
- "traefik.enable=true"
# 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:-klz}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.middlewares=redirect-https"
- "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:-etib}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-etib}-web.middlewares=redirect-https"
# 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:-klz}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-etib-ratelimit,etib-forward,etib-compress}"
- "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:-etib}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-etib}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-etib}.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-etib}.service=${PROJECT_NAME:-etib}-app-svc"
- "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)
- "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|_next|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.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:-klz}-app-svc.loadbalancer.server.port=3000"
- "traefik.http.services.${PROJECT_NAME:-etib}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
# Middlewares
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-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}-compress.compress=true"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-ratelimit.ratelimit.burst=50"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "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
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-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:-klz}-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:-klz}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.middlewares=${PROJECT_NAME:-klz}-loginredirect"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.priority=2002"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.regex=^https?://([^/]+)/([a-z]{2}/)?login(.*)"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.replacement=https://$${1}/gatekeeper/login$${3}"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-loginredirect.redirectregex.permanent=false"
- "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:-etib}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.middlewares=${PROJECT_NAME:-etib}-loginredirect"
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.service=${PROJECT_NAME:-etib}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-etib}-loginredir.priority=2002"
etib-gatekeeper:
profiles: [ "gatekeeper" ]
@@ -65,7 +65,7 @@ services:
default:
infra:
aliases:
- ${PROJECT_NAME:-klz}-gatekeeper
- ${PROJECT_NAME:-etib}-gatekeeper
env_file:
- ${ENV_FILE:-.env}
environment:
@@ -74,19 +74,20 @@ services:
NODE_ENV: production
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-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:-klz}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.http.services.${PROJECT_NAME:-etib}-gatekeeper-svc.loadbalancer.server.port=3000"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.address=http://${PROJECT_NAME:-etib}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-etib}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.docker.network=infra"
# 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:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.priority=2001"
- "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:-etib}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.service=${PROJECT_NAME:-etib}-gatekeeper-svc"
- "traefik.http.routers.${PROJECT_NAME:-etib}-gatekeeper.priority=2001"
etib-db:
image: postgres:15-alpine

18
lib/antispam/client-ip.ts Normal file
View 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;
}

View 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 };
}

View 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 };
}

View File

@@ -1,6 +1,7 @@
{
"name": "e-tib-nextjs",
"version": "2.4.57",
"version": "2.4.58",
"type": "module",
"private": true,
"packageManager": "pnpm@10.18.3",

View File

@@ -31,22 +31,23 @@ fi
get_media_path() {
case "$1" in
local) echo "$LOCAL_MEDIA_DIR" ;;
testing) echo "/var/lib/docker/volumes/klz-testing_klz_media_data/_data" ;;
staging) echo "/var/lib/docker/volumes/klz-staging_klz_media_data/_data" ;;
prod|production) echo "/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data" ;;
testing) echo "/var/lib/docker/volumes/etib-testing_etib_media_data/_data" ;;
staging) echo "/var/lib/docker/volumes/etib-staging_etib_media_data/_data" ;;
prod|production) echo "/var/lib/docker/volumes/e-tibcom_etib_media_data/_data" ;;
*) echo "❌ Unknown environment: $1"; exit 1 ;;
esac
}
get_app_container() {
case "$1" in
testing) echo "klz-testing-klz-app-1" ;;
staging) echo "klz-staging-klz-app-1" ;;
prod|production) echo "klz-cablescom-klz-app-1" ;;
testing) echo "etib-testing-etib-app-1" ;;
staging) echo "etib-staging-etib-app-1" ;;
prod|production) echo "e-tibcom-etib-app-1" ;;
*) echo "" ;;
esac
}
SRC_PATH=$(get_media_path "$SOURCE_ENV")
TGT_PATH=$(get_media_path "$TARGET_ENV")
TGT_CONTAINER=$(get_app_container "$TARGET_ENV")

View File

@@ -15,7 +15,8 @@ export NEXT_PUBLIC_CI=true
export CI=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)
echo "📦 Starting infrastructure services..."

View File

@@ -7,7 +7,8 @@ const targetUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
'http://localhost:3000';
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() {
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
@@ -20,9 +21,10 @@ async function main() {
try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
});
const $ = cheerio.load(response.data, { xmlMode: true });
urls = $('url loc')
.map((i, el) => $(el).text())
@@ -64,7 +66,7 @@ async function main() {
// Inject Gatekeeper session bypassing auth screens
await page.setCookie({
name: 'klz_gatekeeper_session',
name: authCookieName,
value: gatekeeperPassword,
domain: new URL(targetUrl).hostname,
path: '/',

View File

@@ -3,7 +3,8 @@ import axios from 'axios';
import * as cheerio from 'cheerio';
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
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -20,7 +21,7 @@ async function main() {
while (Date.now() - startTime < maxWaitMs) {
try {
const resp = await axios.get(targetUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
timeout: 5000,
validateStatus: (s) => s === 200,
});
@@ -50,7 +51,7 @@ async function main() {
try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
});
const $ = cheerio.load(response.data, { xmlMode: true });

View File

@@ -5,7 +5,8 @@ import * as path from 'path';
import { execSync } from 'child_process';
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() {
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
@@ -16,10 +17,11 @@ async function main() {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
@@ -47,10 +49,11 @@ async function main() {
const u = urls[i];
try {
const res = await axios.get(u, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
// Generate a safe filename that retains URL information
const urlStr = new URL(u);
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');

View File

@@ -2,7 +2,8 @@ import axios from 'axios';
import * as cheerio from 'cheerio';
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() {
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
@@ -12,10 +13,11 @@ async function main() {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
@@ -42,10 +44,11 @@ async function main() {
const u = urls[i];
try {
const res = await axios.get(u, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: null, // Don't throw on error status
});
if (res.status >= 400) {
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
hasErrors = true;

View File

@@ -12,7 +12,8 @@ import * as cheerio from 'cheerio';
*/
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
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]),
);
const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` };
const headers = { Cookie: `${authCookieName}=${gatekeeperPassword}` };
function getExpectedTranslation(
sourcePath: string,

View File

@@ -1,7 +1,8 @@
import axios from 'axios';
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 = [
'strict-transport-security',
@@ -15,10 +16,11 @@ async function main() {
console.log(`\n🛡 Starting Security Headers Scan for: ${targetUrl}\n`);
try {
const response = await axios.head(targetUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
headers: { Cookie: `${authCookieName}=${gatekeeperPassword}` },
validateStatus: () => true,
});
const headers = response.headers;
let allPassed = true;

View File

@@ -7,9 +7,10 @@ module.exports = async (browser, context) => {
// Using LHCI_URL or TARGET_URL if available
const targetUrl =
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';
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie (${authCookieName}) for ${new URL(targetUrl).hostname}...`);
await page.setCookie({

View File

@@ -18,9 +18,10 @@ const targetUrl =
process.env.LHCI_URL ||
'http://localhost:3000';
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';
async function main() {
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);

View File

@@ -1,7 +1,8 @@
import puppeteer from 'puppeteer';
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() {
console.log(`🚀 Smoke Test: ${targetUrl}`);

View File

@@ -14,7 +14,8 @@ import * as path from 'path';
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 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() {
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
@@ -27,11 +28,12 @@ async function main() {
const response = await axios.get(sitemapUrl, {
headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
Cookie: `${authCookieName}=${gatekeeperPassword}`,
},
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
@@ -92,8 +94,9 @@ async function main() {
],
},
headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
Cookie: `${authCookieName}=${gatekeeperPassword}`,
},
timeout: 60000, // Increase timeout for slower pages
},
urls: urls,

View 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
View 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');
});
});

View File

@@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest';
import { config } from '../proxy';
describe('Proxy Middleware Config Matcher', () => {
const matcherRegex = new RegExp(`^${config.matcher[0]}`);
it('matches localized page routes for next-intl processing', () => {
expect(matcherRegex.test('/de')).toBe(true);
expect(matcherRegex.test('/en/contact')).toBe(true);
expect(matcherRegex.test('/karriere')).toBe(true);
});
it('excludes static assets with any file extension including uppercase .JPG and subdirectories', () => {
// These should NOT match the middleware (expect test to be false so middleware is bypassed)
expect(matcherRegex.test('/assets/photos/DJI_0048.JPG')).toBe(false);
expect(matcherRegex.test('/assets/videos/web/hero-kabelpflug-poster.jpg')).toBe(false);
expect(matcherRegex.test('/assets/logo-white.png')).toBe(false);
expect(matcherRegex.test('/_next/image')).toBe(false);
});
});