feat: integrate security middleware into contact API

This commit is contained in:
张翔
2026-03-24 11:04:29 +08:00
parent 60f3f371bb
commit c96c05d7f9
2 changed files with 141 additions and 70 deletions
+92 -46
View File
@@ -1,5 +1,7 @@
import { POST } from './route';
import { POST, setSecurityMiddleware } from './route';
import { NextRequest } from 'next/server';
import { generateCaptcha } from '@/lib/security/captcha';
import { SecurityMiddleware } from '@/lib/security/middleware';
if (!global.Response) {
global.Response = class Response {
@@ -47,11 +49,19 @@ describe('/api/contact', () => {
const resendInstance = new Resend();
mockSend = resendInstance.emails.send;
mockSend.mockClear();
const securityMiddleware = new SecurityMiddleware();
setSecurityMiddleware(securityMiddleware);
});
const createMockRequest = (body: any): NextRequest => {
const createMockRequest = (body: any, ip: string = '192.168.1.1'): NextRequest => {
const headers = new Headers();
headers.set('x-forwarded-for', ip);
headers.set('user-agent', 'test-agent');
return {
json: async () => body,
headers,
} as unknown as NextRequest;
};
@@ -61,12 +71,17 @@ describe('/api/contact', () => {
error: null,
});
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'Test Message',
});
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}, '192.168.1.2');
const response = await POST(mockRequest);
const data = await response.json();
@@ -80,7 +95,7 @@ describe('/api/contact', () => {
it('should validate required fields', async () => {
mockRequest = createMockRequest({
name: 'Test User',
});
}, '192.168.1.5');
const response = await POST(mockRequest);
const data = await response.json();
@@ -96,7 +111,7 @@ describe('/api/contact', () => {
email: 'invalid-email',
subject: 'Test Subject',
message: 'Test Message',
});
}, '192.168.1.6');
const response = await POST(mockRequest);
const data = await response.json();
@@ -113,7 +128,7 @@ describe('/api/contact', () => {
subject: 'Test Subject',
message: 'Test Message',
website: 'spam-bot',
});
}, '192.168.1.7');
const response = await POST(mockRequest);
const data = await response.json();
@@ -123,40 +138,25 @@ describe('/api/contact', () => {
expect(mockSend).not.toHaveBeenCalled();
});
it('should reject submission too fast', async () => {
it('should reject invalid captcha', async () => {
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'Test Message',
submitTime: Date.now().toString(),
});
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: 999,
}, '192.168.1.3');
const response = await POST(mockRequest);
const data = await response.json();
expect(response.status).toBe(400);
expect(response.status).toBe(403);
expect(data.success).toBe(false);
expect(data.error).toBe('提交过快,请稍后再试');
});
it('should validate math captcha', async () => {
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'Test Message',
mathHash: 'invalid-hash',
mathTimestamp: Date.now().toString(),
mathAnswer: '5',
});
const response = await POST(mockRequest);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('验证码错误,请重新计算');
expect(data.error).toBe('Invalid captcha');
});
it('should handle Resend API error', async () => {
@@ -165,12 +165,17 @@ describe('/api/contact', () => {
error: { message: 'API Error' },
});
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'Test Message',
});
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}, '192.168.1.4');
const response = await POST(mockRequest);
const data = await response.json();
@@ -201,13 +206,18 @@ describe('/api/contact', () => {
error: null,
});
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
phone: '13800138000',
subject: 'Test Subject',
message: 'Test Message',
});
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}, '192.168.1.10');
const response = await POST(mockRequest);
const data = await response.json();
@@ -223,19 +233,17 @@ describe('/api/contact', () => {
error: null,
});
const timestamp = Date.now();
const answer = '5';
const hash = btoa(`${answer}-${timestamp}`);
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'Test Message',
mathHash: hash,
mathTimestamp: timestamp.toString(),
mathAnswer: answer,
});
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}, '192.168.1.11');
const response = await POST(mockRequest);
const data = await response.json();
@@ -245,26 +253,64 @@ describe('/api/contact', () => {
expect(mockSend).toHaveBeenCalled();
});
it('should handle submission after minimum time', async () => {
it('should block malicious content', async () => {
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'You won a free bitcoin lottery! Act now to claim your prize.',
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}, '192.168.1.12');
const response = await POST(mockRequest);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.success).toBe(false);
expect(data.error).toContain('malicious');
});
it('should block rate limit exceeded', async () => {
mockSend.mockResolvedValue({
data: { id: 'test-id' },
error: null,
});
const pastTime = Date.now() - 3000;
for (let i = 0; i < 11; i++) {
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: `test${i}@example.com`,
subject: 'Test Subject',
message: 'Test Message',
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}, '192.168.2.1');
await POST(mockRequest);
}
const captcha = generateCaptcha('simple');
mockRequest = createMockRequest({
name: 'Test User',
email: 'test@example.com',
subject: 'Test Subject',
message: 'Test Message',
submitTime: pastTime.toString(),
});
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}, '192.168.2.1');
const response = await POST(mockRequest);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockSend).toHaveBeenCalled();
expect(response.status).toBe(403);
expect(data.success).toBe(false);
expect(data.error).toContain('Rate limit exceeded');
});
});
+47 -22
View File
@@ -1,11 +1,16 @@
import { NextRequest } from 'next/server';
import { Resend } from 'resend';
import { z } from 'zod';
import { SecurityMiddleware } from '@/lib/security/middleware';
const resend = new Resend(process.env.RESEND_API_KEY);
const companyEmail = process.env.COMPANY_EMAIL || 'contact@novalon.cn';
let securityMiddleware = new SecurityMiddleware();
export function setSecurityMiddleware(middleware: SecurityMiddleware) {
securityMiddleware = middleware;
}
export async function POST(request: NextRequest) {
try {
@@ -33,25 +38,45 @@ export async function POST(request: NextRequest) {
return Response.json({ success: true, message: '消息已发送' });
}
if (body.submitTime) {
const timeDiff = Date.now() - parseInt(body.submitTime);
if (timeDiff < 2000) {
const clientIP = request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.headers.get('x-real-ip') ||
'unknown';
const securityCheck = await securityMiddleware.checkRequest({
ip: clientIP,
email: body.email,
name: body.name,
phone: body.phone,
subject: body.subject,
message: body.message,
captchaHash: body.mathHash || '',
captchaAnswer: parseInt(body.mathAnswer) || 0,
captchaTimestamp: parseInt(body.mathTimestamp) || 0,
userAgent: request.headers.get('user-agent') || undefined,
});
if (!securityCheck.allowed) {
return Response.json(
{ success: false, error: '提交过快,请稍后再试' },
{ status: 400 }
{
success: false,
error: securityCheck.errors[0] || '安全验证失败',
warnings: securityCheck.warnings
},
{ status: 403 }
);
}
}
if (body.mathHash && body.mathTimestamp && body.mathAnswer !== undefined) {
const expectedHash = btoa(`${body.mathAnswer}-${body.mathTimestamp}`);
if (expectedHash !== body.mathHash) {
return Response.json(
{ success: false, error: '验证码错误,请重新计算' },
{ status: 400 }
);
}
}
const sanitizedData = securityMiddleware.sanitizeRequest({
ip: clientIP,
email: body.email,
name: body.name,
phone: body.phone,
subject: body.subject,
message: body.message,
captchaHash: body.mathHash || '',
captchaAnswer: parseInt(body.mathAnswer) || 0,
captchaTimestamp: parseInt(body.mathTimestamp) || 0,
});
const emailContent = `
<html>
@@ -72,11 +97,11 @@ export async function POST(request: NextRequest) {
<h1>新的客户咨询</h1>
</div>
<div class="content">
<div class="info-row"><span class="info-label">姓名:</span> ${body.name}</div>
<div class="info-row"><span class="info-label">邮箱:</span> ${body.email}</div>
${body.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${body.phone}</div>` : ''}
<div class="info-row"><span class="info-label">主题:</span> ${body.subject}</div>
<div class="info-row"><span class="info-label">留言:</span> ${body.message}</div>
<div class="info-row"><span class="info-label">姓名:</span> ${sanitizedData.name}</div>
<div class="info-row"><span class="info-label">邮箱:</span> ${sanitizedData.email}</div>
${sanitizedData.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${sanitizedData.phone}</div>` : ''}
<div class="info-row"><span class="info-label">主题:</span> ${sanitizedData.subject}</div>
<div class="info-row"><span class="info-label">留言:</span> ${sanitizedData.message}</div>
</div>
</div>
</body>
@@ -86,9 +111,9 @@ export async function POST(request: NextRequest) {
const result = await resend.emails.send({
from: '睿新致远官网 <onboarding@resend.dev>',
to: [companyEmail],
subject: `${body.subject} - ${body.name}`,
subject: `${sanitizedData.subject} - ${sanitizedData.name}`,
html: emailContent,
replyTo: body.email,
replyTo: sanitizedData.email,
});
if (result.error) {