feat: integrate security middleware into contact API
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
return Response.json(
|
||||
{ success: false, error: '提交过快,请稍后再试' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
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: 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) {
|
||||
|
||||
Reference in New Issue
Block a user