security-convex について
Convex 開発者向けのセキュリティ監査スキルです。認証、認可、入力検証、よくある脆弱性を確認し、監査パターンと検索コマンドも提供します。
主な機能
- 01認証と認可の確認
- 02入力検証のレビュー
- 03所有権とメンバーシップの確認
- 04HTTP アクションと Webhook の確認
- 05監査コマンドと強化チェックリスト
活用シーン
- 01公開 query と mutation の監査
- 02ドキュメントの所有権保護の確認
- 03HTTP エンドポイントの署名検証のレビュー
- 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 を含む関数 | 高 |
| クライアント提供の userId | v.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 のインポートを禁止している
コメント
Markdown 対応