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
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
This commit is contained in:
@@ -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)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,13 +35,13 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
// Track attempt
|
||||
services.analytics.track('contact-form-attempt');
|
||||
|
||||
// Anti-spam guard: honeypot, time-trap, email validation, link limit, IP rate limit
|
||||
// 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,
|
||||
formLoadedAt: Number(formData.get('form_loaded_at')) || null,
|
||||
formToken: (formData.get('form_token') as string) || null,
|
||||
now: Date.now(),
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0]?.trim() || null,
|
||||
ip: parseClientIp(requestHeaders.get('x-forwarded-for')),
|
||||
email: (formData.get('email') as string) || null,
|
||||
message: (formData.get('message') as string) || null,
|
||||
});
|
||||
|
||||
@@ -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,7 +12,18 @@ export default function ContactForm() {
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [formLoadedAt] = useState(() => Date.now());
|
||||
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
|
||||
@@ -153,8 +164,8 @@ export default function ContactForm() {
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Anti-spam time-trap: server rejects submissions faster than a human could fill the form */}
|
||||
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
|
||||
{/* 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
|
||||
|
||||
@@ -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,7 +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 [formLoadedAt] = useState(() => Date.now());
|
||||
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
|
||||
@@ -39,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 {
|
||||
@@ -175,8 +185,8 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Anti-spam time-trap: server rejects submissions faster than a human could fill the form */}
|
||||
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
|
||||
{/* 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">
|
||||
@@ -186,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)}
|
||||
@@ -201,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
</label>
|
||||
<Textarea
|
||||
id={requestId}
|
||||
name="message"
|
||||
required
|
||||
rows={3}
|
||||
value={request}
|
||||
|
||||
@@ -3,11 +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 [formLoadedAt] = React.useState(() => Date.now());
|
||||
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();
|
||||
@@ -133,7 +144,7 @@ export function ContactForm() {
|
||||
</div>
|
||||
|
||||
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
||||
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
|
||||
<input type="hidden" name="form_token" value={formToken ?? ''} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* 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;
|
||||
@@ -11,11 +13,18 @@ export const MAX_MESSAGE_LINKS = 5;
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi;
|
||||
|
||||
export type SpamReason = 'honeypot' | 'too_fast' | 'invalid_email' | 'too_many_links' | 'rate_limited';
|
||||
export type SpamReason =
|
||||
| 'honeypot'
|
||||
| 'invalid_token'
|
||||
| 'expired_token'
|
||||
| 'too_fast'
|
||||
| 'invalid_email'
|
||||
| 'too_many_links'
|
||||
| 'rate_limited';
|
||||
|
||||
export interface ContactSubmissionInput {
|
||||
honeypot: string | null;
|
||||
formLoadedAt: number | null;
|
||||
formToken: string | null;
|
||||
now: number;
|
||||
ip: string | null;
|
||||
email: string | null;
|
||||
@@ -51,11 +60,17 @@ export function checkContactSubmission(input: ContactSubmissionInput): ContactSu
|
||||
return { allowed: false, reason: 'honeypot' };
|
||||
}
|
||||
|
||||
if (
|
||||
input.formLoadedAt === null ||
|
||||
!Number.isFinite(input.formLoadedAt) ||
|
||||
input.now - input.formLoadedAt < MIN_FILL_TIME_MS
|
||||
) {
|
||||
// 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' };
|
||||
}
|
||||
|
||||
|
||||
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,62 +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';
|
||||
|
||||
const validInput = {
|
||||
process.env.FORM_TOKEN_SECRET = 'test-secret';
|
||||
|
||||
const NOW = 1_000_000_000_000;
|
||||
const TOKEN_SECRET = 'test-secret';
|
||||
|
||||
const validInput = () => ({
|
||||
honeypot: '',
|
||||
formLoadedAt: Date.now() - 10_000,
|
||||
now: Date.now(),
|
||||
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);
|
||||
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' });
|
||||
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 result = checkContactSubmission({ ...validInput, formLoadedAt: validInput.now - 1_000 });
|
||||
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' });
|
||||
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 });
|
||||
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);
|
||||
expect(checkContactSubmission(validInput()).allowed).toBe(true);
|
||||
}
|
||||
const result = checkContactSubmission(validInput);
|
||||
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);
|
||||
checkContactSubmission(validInput());
|
||||
}
|
||||
const result = checkContactSubmission({ ...validInput, ip: '5.6.7.8' });
|
||||
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