mcpField
S
security-convex

security-convex

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

security-convex について

Convex 開発者向けのセキュリティ監査スキルです。認証、認可、入力検証、よくある脆弱性を確認し、監査パターンと検索コマンドも提供します。

主な機能

  1. 01認証と認可の確認
  2. 02入力検証のレビュー
  3. 03所有権とメンバーシップの確認
  4. 04HTTP アクションと Webhook の確認
  5. 05監査コマンドと強化チェックリスト

活用シーン

  1. 01公開 query と mutation の監査
  2. 02ドキュメントの所有権保護の確認
  3. 03HTTP エンドポイントの署名検証のレビュー
  4. 04バリデーターがない関数の検索

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/igorwarzocha/opencode-workflows --skill security-convex

インストール後、Claude Code で/security-convexを入力して読み込まれたか確認します。

同梱ファイルscripts/scan.sh

詳細

Convex アプリケーションのセキュリティ監査パターンです。認証、認可、入力検証、Convex 固有の脆弱性を扱います。

バイブコーディングで最も多い間違い:未認証の関数

Convex の関数はデフォルトで公開されています。認証チェックを追加しない限り、あらゆるクライアントからすべてのクエリとミューテーションを呼び出せます。

// ❌ CRITICAL: Anyone can read all users
export const listUsers = query({
  handler: async (ctx) => {
    return await ctx.db.query("users").collect();
  },
});

// ❌ CRITICAL: Anyone can delete any document
export const deleteNote = mutation({
  args: { noteId: v.id("notes") },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.noteId);
  },
});

// ✓ SECURE: Check auth first
export const listUsers = query({
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    // Now safe to return data
    return await ctx.db.query("users").collect();
  },
});

認証チェック

Convex Auth を使用する場合
import { getAuthUserId } from "@convex-dev/auth/server";

// ❌ No auth check
export const getMyProfile = query({
  handler: async (ctx) => {
    // Who is "my"? Anyone can call this!
    return await ctx.db.query("users").first();
  },
});

// ✓ Get authenticated user
export const getMyProfile = query({
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    return await ctx.db.get(userId);
  },
});
サードパーティー認証(Clerk、Auth0)を使用する場合
// ❌ Just checking ctx.auth exists (wrong!)
export const sensitiveData = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    // identity could be null!
    return await ctx.db.query("secrets").collect();
  },
});

// ✓ Properly validate identity
export const sensitiveData = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthenticated");
    // Now identity.tokenIdentifier is available
    return await ctx.db.query("secrets")
      .filter(q => q.eq(q.field("userId"), identity.subject))
      .collect();
  },
});

認可:IDOR の問題

認証(あなたが誰か)≠認可(何にアクセスできるか)。

// ❌ HIGH: IDOR - Any logged-in user can read any note
export const getNote = query({
  args: { noteId: v.id("notes") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    // Authenticated but no ownership check!
    return await ctx.db.get(args.noteId);
  },
});

// ✓ Check ownership
export const getNote = query({
  args: { noteId: v.id("notes") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    
    const note = await ctx.db.get(args.noteId);
    if (!note || note.userId !== userId) {
      throw new Error("Not found"); // Don't reveal existence
    }
    return note;
  },
});
チーム/組織のメンバーシップ確認
// ❌ Trusts client-provided teamId
export const getTeamData = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    // User says they're on this team - but are they?
    return await ctx.db.query("projects")
      .filter(q => q.eq(q.field("teamId"), args.teamId))
      .collect();
  },
});

// ✓ Verify membership
export const getTeamData = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    
    const membership = await ctx.db.query("teamMembers")
      .withIndex("by_team_user", q => 
        q.eq("teamId", args.teamId).eq("userId", userId)
      ).first();
    
    if (!membership) throw new Error("Not a team member");
    
    return await ctx.db.query("projects")
      .filter(q => q.eq(q.field("teamId"), args.teamId))
      .collect();
  },
});

カスタム関数パターン(推奨)

convex-helpers を使用して、デフォルトで認証を強制します。

// convex/functions.ts
import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { getAuthUserId } from "@convex-dev/auth/server";

// All userQuery functions require authentication
export const userQuery = customQuery(query, {
  args: {},
  input: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    const user = await ctx.db.get(userId);
    if (!user) throw new Error("User not found");
    return { ctx: { user }, args: {} };
  },
});

// Usage - ctx.user is guaranteed to exist
export const getMyNotes = userQuery({
  handler: async (ctx) => {
    return await ctx.db.query("notes")
      .filter(q => q.eq(q.field("userId"), ctx.user._id))
      .collect();
  },
});

監査: カスタム関数が一貫して使用されているか確認します。raw query( と mutation( の使用箇所を検索します。

入力検証

バリデーターの不足
// ❌ No validation - args is 'any'
export const createNote = mutation({
  handler: async (ctx, args) => {
    await ctx.db.insert("notes", args); // Could insert anything!
  },
});

// ✓ Strict validators
export const createNote = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    isPublic: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    await ctx.db.insert("notes", {
      ...args,
      userId, // Server sets this, not client
    });
  },
});
所有権の確認でクライアント提供の ID を信頼する
// ❌ Client provides userId - can impersonate anyone
export const createNote = mutation({
  args: {
    title: v.string(),
    userId: v.id("users"), // Client-controlled!
  },
  handler: async (ctx, args) => {
    await ctx.db.insert("notes", args);
  },
});

// ✓ Server determines userId
export const createNote = mutation({
  args: { title: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    await ctx.db.insert("notes", {
      title: args.title,
      userId, // From auth, not args
    });
  },
});

内部関数

// ❌ Sensitive logic in public mutation
export const processPayment = mutation({
  args: { amount: v.number() },
  handler: async (ctx, args) => {
    // Anyone can call this!
    await chargeCard(args.amount);
  },
});

// ✓ Use internalMutation for sensitive operations
export const processPayment = internalMutation({
  args: { userId: v.id("users"), amount: v.number() },
  handler: async (ctx, args) => {
    // Only callable from other server functions
    await chargeCard(args.amount);
  },
});

// Public function validates then calls internal
export const purchaseItem = mutation({
  args: { itemId: v.id("items") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Unauthenticated");
    
    const item = await ctx.db.get(args.itemId);
    // Validate, then call internal
    await ctx.runMutation(internal.payments.processPayment, {
      userId,
      amount: item.price,
    });
  },
});

HTTP アクション

// ❌ No auth on HTTP endpoint
export const webhook = httpAction(async (ctx, request) => {
  const body = await request.json();
  await ctx.runMutation(api.data.processWebhook, body);
  return new Response("OK");
});

// ✓ Verify webhook signature
export const webhook = httpAction(async (ctx, request) => {
  const signature = request.headers.get("x-webhook-signature");
  const body = await request.text();
  
  if (!verifySignature(body, signature, process.env.WEBHOOK_SECRET)) {
    return new Response("Unauthorized", { status: 401 });
  }
  
  await ctx.runMutation(api.data.processWebhook, JSON.parse(body));
  return new Response("OK");
});

リアルタイムサブスクリプションからの情報漏えい

// ❌ Subscription returns all messages (data leak)
export const allMessages = query({
  handler: async (ctx) => {
    return await ctx.db.query("messages").collect();
  },
});

// ✓ Filter by user's access
export const myMessages = query({
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return []; // Or throw
    
    // Only messages user has access to
    const myChannels = await ctx.db.query("channelMembers")
      .filter(q => q.eq(q.field("userId"), userId))
      .collect();
    
    const channelIds = myChannels.map(m => m.channelId);
    return await ctx.db.query("messages")
      .filter(q => q.or(...channelIds.map(id => 
        q.eq(q.field("channelId"), id)
      )))
      .collect();
  },
});

環境変数

// ❌ Accessing env vars that don't exist (undefined behavior)
const apiKey = process.env.STRIPE_SECRET_KEY;

// ✓ Use Convex environment variables properly
// Set via: npx convex env set STRIPE_SECRET_KEY sk_live_...
// Access in actions only (not queries/mutations for determinism)
export const callStripe = action({
  handler: async (ctx) => {
    const apiKey = process.env.STRIPE_SECRET_KEY;
    if (!apiKey) throw new Error("STRIPE_SECRET_KEY not configured");
    // ...
  },
});

注: 環境変数はアクションでのみ使用でき、クエリ/ミューテーションでは使用できません。

<severity_table>

よくある脆弱性の概要

問題確認箇所重大度
クエリ/ミューテーションに認証チェックがないすべての query({ と mutation({致命的
IDOR(所有権の確認がない)引数にドキュメント ID を含む関数高
クライアント提供の userIdv.id("users") を含む引数高
バリデーターの不足args: {} のない関数高
内部ロジックに公開関数を使用機密性の高いビジネスロジック高
HTTP アクションに認証がないhttpAction(高
サブスクリプションのデータ漏えいコレクションを返すクエリ中
raw query/mutation(カスタム関数なし)userQuery/userMutation を使用していない中

</severity_table>

すぐに実行できる監査コマンド

# Find all public queries and mutations
rg "export const .* = query\(" convex/
rg "export const .* = mutation\(" convex/

# Find functions without auth checks
rg -l "export const .* = (query|mutation)\(" convex/ | \
  xargs rg -L "(getAuthUserId|getUserIdentity)"

# Find client-provided userId (potential IDOR)
rg 'userId: v\.id\("users"\)' convex/

# Find functions without validators
rg "handler: async \(ctx\)" convex/ -g "*.ts"

# Find HTTP actions
rg "httpAction" convex/

# Find internal vs public function usage
rg "internalMutation|internalQuery|internalAction" convex/

セキュリティ強化チェックリスト

  • すべてのクエリ/ミューテーションに認証チェックがある(またはカスタム関数を使用している)
  • ドキュメントへのアクセスで所有権/メンバーシップを確認している
  • userId はクライアント引数ではなく、認証コンテキストから取得している
  • すべての関数に適切なバリデーターがある
  • 機密性の高い操作で internalMutation/internalAction を使用している
  • HTTP アクションで署名/トークンを検証している
  • リアルタイムサブスクリプションでユーザーのアクセス権に基づいてフィルタリングしている
  • カスタム関数(userQuery/userMutation)を一貫して使用している
  • ESLint ルールで raw query/mutation のインポートを禁止している

コメント