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 { NextRequest } from 'next/server';
|
||||||
|
import { generateCaptcha } from '@/lib/security/captcha';
|
||||||
|
import { SecurityMiddleware } from '@/lib/security/middleware';
|
||||||
|
|
||||||
if (!global.Response) {
|
if (!global.Response) {
|
||||||
global.Response = class Response {
|
global.Response = class Response {
|
||||||
@@ -47,11 +49,19 @@ describe('/api/contact', () => {
|
|||||||
const resendInstance = new Resend();
|
const resendInstance = new Resend();
|
||||||
mockSend = resendInstance.emails.send;
|
mockSend = resendInstance.emails.send;
|
||||||
mockSend.mockClear();
|
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 {
|
return {
|
||||||
json: async () => body,
|
json: async () => body,
|
||||||
|
headers,
|
||||||
} as unknown as NextRequest;
|
} as unknown as NextRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,12 +71,17 @@ describe('/api/contact', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const captcha = generateCaptcha('simple');
|
||||||
|
|
||||||
mockRequest = createMockRequest({
|
mockRequest = createMockRequest({
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
message: 'Test Message',
|
||||||
});
|
mathHash: captcha.hash,
|
||||||
|
mathTimestamp: captcha.timestamp,
|
||||||
|
mathAnswer: captcha.answer,
|
||||||
|
}, '192.168.1.2');
|
||||||
|
|
||||||
const response = await POST(mockRequest);
|
const response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -80,7 +95,7 @@ describe('/api/contact', () => {
|
|||||||
it('should validate required fields', async () => {
|
it('should validate required fields', async () => {
|
||||||
mockRequest = createMockRequest({
|
mockRequest = createMockRequest({
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
});
|
}, '192.168.1.5');
|
||||||
|
|
||||||
const response = await POST(mockRequest);
|
const response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -96,7 +111,7 @@ describe('/api/contact', () => {
|
|||||||
email: 'invalid-email',
|
email: 'invalid-email',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
message: 'Test Message',
|
||||||
});
|
}, '192.168.1.6');
|
||||||
|
|
||||||
const response = await POST(mockRequest);
|
const response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -113,7 +128,7 @@ describe('/api/contact', () => {
|
|||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
message: 'Test Message',
|
||||||
website: 'spam-bot',
|
website: 'spam-bot',
|
||||||
});
|
}, '192.168.1.7');
|
||||||
|
|
||||||
const response = await POST(mockRequest);
|
const response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -123,40 +138,25 @@ describe('/api/contact', () => {
|
|||||||
expect(mockSend).not.toHaveBeenCalled();
|
expect(mockSend).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject submission too fast', async () => {
|
it('should reject invalid captcha', async () => {
|
||||||
|
const captcha = generateCaptcha('simple');
|
||||||
|
|
||||||
mockRequest = createMockRequest({
|
mockRequest = createMockRequest({
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
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 response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(403);
|
||||||
expect(data.success).toBe(false);
|
expect(data.success).toBe(false);
|
||||||
expect(data.error).toBe('提交过快,请稍后再试');
|
expect(data.error).toBe('Invalid captcha');
|
||||||
});
|
|
||||||
|
|
||||||
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('验证码错误,请重新计算');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle Resend API error', async () => {
|
it('should handle Resend API error', async () => {
|
||||||
@@ -165,12 +165,17 @@ describe('/api/contact', () => {
|
|||||||
error: { message: 'API Error' },
|
error: { message: 'API Error' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const captcha = generateCaptcha('simple');
|
||||||
|
|
||||||
mockRequest = createMockRequest({
|
mockRequest = createMockRequest({
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
message: 'Test Message',
|
||||||
});
|
mathHash: captcha.hash,
|
||||||
|
mathTimestamp: captcha.timestamp,
|
||||||
|
mathAnswer: captcha.answer,
|
||||||
|
}, '192.168.1.4');
|
||||||
|
|
||||||
const response = await POST(mockRequest);
|
const response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -201,13 +206,18 @@ describe('/api/contact', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const captcha = generateCaptcha('simple');
|
||||||
|
|
||||||
mockRequest = createMockRequest({
|
mockRequest = createMockRequest({
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
phone: '13800138000',
|
phone: '13800138000',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
message: 'Test Message',
|
||||||
});
|
mathHash: captcha.hash,
|
||||||
|
mathTimestamp: captcha.timestamp,
|
||||||
|
mathAnswer: captcha.answer,
|
||||||
|
}, '192.168.1.10');
|
||||||
|
|
||||||
const response = await POST(mockRequest);
|
const response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -223,19 +233,17 @@ describe('/api/contact', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const captcha = generateCaptcha('simple');
|
||||||
const answer = '5';
|
|
||||||
const hash = btoa(`${answer}-${timestamp}`);
|
|
||||||
|
|
||||||
mockRequest = createMockRequest({
|
mockRequest = createMockRequest({
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
message: 'Test Message',
|
||||||
mathHash: hash,
|
mathHash: captcha.hash,
|
||||||
mathTimestamp: timestamp.toString(),
|
mathTimestamp: captcha.timestamp,
|
||||||
mathAnswer: answer,
|
mathAnswer: captcha.answer,
|
||||||
});
|
}, '192.168.1.11');
|
||||||
|
|
||||||
const response = await POST(mockRequest);
|
const response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -245,26 +253,64 @@ describe('/api/contact', () => {
|
|||||||
expect(mockSend).toHaveBeenCalled();
|
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({
|
mockSend.mockResolvedValue({
|
||||||
data: { id: 'test-id' },
|
data: { id: 'test-id' },
|
||||||
error: null,
|
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({
|
mockRequest = createMockRequest({
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
message: 'Test Message',
|
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 response = await POST(mockRequest);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(403);
|
||||||
expect(data.success).toBe(true);
|
expect(data.success).toBe(false);
|
||||||
expect(mockSend).toHaveBeenCalled();
|
expect(data.error).toContain('Rate limit exceeded');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { SecurityMiddleware } from '@/lib/security/middleware';
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
const companyEmail = process.env.COMPANY_EMAIL || 'contact@novalon.cn';
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -33,25 +38,45 @@ export async function POST(request: NextRequest) {
|
|||||||
return Response.json({ success: true, message: '消息已发送' });
|
return Response.json({ success: true, message: '消息已发送' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.submitTime) {
|
const clientIP = request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||||
const timeDiff = Date.now() - parseInt(body.submitTime);
|
request.headers.get('x-real-ip') ||
|
||||||
if (timeDiff < 2000) {
|
'unknown';
|
||||||
return Response.json(
|
|
||||||
{ success: false, error: '提交过快,请稍后再试' },
|
const securityCheck = await securityMiddleware.checkRequest({
|
||||||
{ status: 400 }
|
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 sanitizedData = securityMiddleware.sanitizeRequest({
|
||||||
const expectedHash = btoa(`${body.mathAnswer}-${body.mathTimestamp}`);
|
ip: clientIP,
|
||||||
if (expectedHash !== body.mathHash) {
|
email: body.email,
|
||||||
return Response.json(
|
name: body.name,
|
||||||
{ success: false, error: '验证码错误,请重新计算' },
|
phone: body.phone,
|
||||||
{ status: 400 }
|
subject: body.subject,
|
||||||
);
|
message: body.message,
|
||||||
}
|
captchaHash: body.mathHash || '',
|
||||||
}
|
captchaAnswer: parseInt(body.mathAnswer) || 0,
|
||||||
|
captchaTimestamp: parseInt(body.mathTimestamp) || 0,
|
||||||
|
});
|
||||||
|
|
||||||
const emailContent = `
|
const emailContent = `
|
||||||
<html>
|
<html>
|
||||||
@@ -72,11 +97,11 @@ export async function POST(request: NextRequest) {
|
|||||||
<h1>新的客户咨询</h1>
|
<h1>新的客户咨询</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="info-row"><span class="info-label">姓名:</span> ${body.name}</div>
|
<div class="info-row"><span class="info-label">姓名:</span> ${sanitizedData.name}</div>
|
||||||
<div class="info-row"><span class="info-label">邮箱:</span> ${body.email}</div>
|
<div class="info-row"><span class="info-label">邮箱:</span> ${sanitizedData.email}</div>
|
||||||
${body.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${body.phone}</div>` : ''}
|
${sanitizedData.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${sanitizedData.phone}</div>` : ''}
|
||||||
<div class="info-row"><span class="info-label">主题:</span> ${body.subject}</div>
|
<div class="info-row"><span class="info-label">主题:</span> ${sanitizedData.subject}</div>
|
||||||
<div class="info-row"><span class="info-label">留言:</span> ${body.message}</div>
|
<div class="info-row"><span class="info-label">留言:</span> ${sanitizedData.message}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -86,9 +111,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const result = await resend.emails.send({
|
const result = await resend.emails.send({
|
||||||
from: '睿新致远官网 <onboarding@resend.dev>',
|
from: '睿新致远官网 <onboarding@resend.dev>',
|
||||||
to: [companyEmail],
|
to: [companyEmail],
|
||||||
subject: `${body.subject} - ${body.name}`,
|
subject: `${sanitizedData.subject} - ${sanitizedData.name}`,
|
||||||
html: emailContent,
|
html: emailContent,
|
||||||
replyTo: body.email,
|
replyTo: sanitizedData.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user