diff --git a/src/app/api/contact/route.test.ts b/src/app/api/contact/route.test.ts index 16c0086..185f3fb 100644 --- a/src/app/api/contact/route.test.ts +++ b/src/app/api/contact/route.test.ts @@ -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'); }); }); diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts index dc2f356..50489db 100644 --- a/src/app/api/contact/route.ts +++ b/src/app/api/contact/route.ts @@ -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 = ` @@ -72,11 +97,11 @@ export async function POST(request: NextRequest) {

新的客户咨询

-
姓名: ${body.name}
-
邮箱: ${body.email}
- ${body.phone ? `
电话: ${body.phone}
` : ''} -
主题: ${body.subject}
-
留言: ${body.message}
+
姓名: ${sanitizedData.name}
+
邮箱: ${sanitizedData.email}
+ ${sanitizedData.phone ? `
电话: ${sanitizedData.phone}
` : ''} +
主题: ${sanitizedData.subject}
+
留言: ${sanitizedData.message}
@@ -86,9 +111,9 @@ export async function POST(request: NextRequest) { const result = await resend.emails.send({ from: '睿新致远官网 ', to: [companyEmail], - subject: `${body.subject} - ${body.name}`, + subject: `${sanitizedData.subject} - ${sanitizedData.name}`, html: emailContent, - replyTo: body.email, + replyTo: sanitizedData.email, }); if (result.error) {