.claude/skills/idor-testing/SKILL.md
This skill should be used when the user asks to "test for insecure direct object references," "find IDOR vulnerabilities," "exploit broken access control," "enumerate user IDs or object references," or "bypass authorization to access other users' data." Adapted for MGM-Web multi-tenant architecture.
npx skillsauth add vitoropereira/claude-starter-kit IDOR Vulnerability TestingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Systematic methodologies for identifying Insecure Direct Object Reference (IDOR) vulnerabilities. Adapted for MGM-Web's multi-tenant architecture where group_owner isolation is the primary security boundary.
MGM-Web uses group_owner = org.organizationRootUserId to isolate tenant data. If any API route forgets this filter, all groups from all tenants are exposed.
# Group data — MUST filter by group_owner
GET /api/groups
GET /api/groups/[id]
GET /api/groups/[id]/hot-topics
# Analytics — MUST verify group belongs to org
GET /api/analytics/overview
GET /api/analytics/blocks/*
# Members — cross-org access risk
GET /api/team/members
POST /api/team/invite
# Summaries — group data exposure
GET /api/summaries
# Alerts — cross-org alert access
GET /api/alerts
GET /api/alerts/triggers
// ✅ SECURE: Always filter by org context
export async function GET(req: Request) {
const org = await getOrgContextFromCookies();
if (!org) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const supabase = await createClient();
const { data } = await supabase
.from("groups")
.select("*")
.eq("group_owner", org.organizationRootUserId); // CRITICAL filter
return NextResponse.json({ data });
}
// ❌ VULNERABLE: No org filter — exposes ALL tenants' data
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const groupId = searchParams.get("groupId");
const supabase = await createClient();
const { data } = await supabase
.from("groups")
.select("*")
.eq("id", groupId); // Missing group_owner check!
return NextResponse.json({ data });
}
MGM has 3 permission levels: Owner (1), Admin (2), Member (3).
// Test: Can a Member access admin-only endpoints?
// Endpoint that should check canManageUsers
POST /api/team/invite
DELETE /api/groups
// ❌ VULNERABLE: Only checks auth, not permission level
if (!org) return 401;
// Missing: if (!org.canManageUsers) return 403;
# Test: Can Org A access Org B's group details?
GET /api/groups/[id] — where [id] belongs to different org
# Secure check:
const { data: group } = await supabase
.from("groups")
.select("*")
.eq("id", groupId)
.eq("group_owner", org.organizationRootUserId) // Must have this
.single();
MGM uses auto-incrementing numeric IDs for users (users.id). These are predictable:
# Sequential IDs are guessable
/api/team/members?userId=1
/api/team/members?userId=2
/api/team/members?userId=3
Direct Reference to Database Objects:
GET /api/groups/123 → GET /api/groups/124 (another org's group)
Direct Reference to Static Files:
/static/receipt/205.pdf → /static/receipt/200.pdf
# Step 1: Capture authenticated request (Org A)
GET /api/groups/100 HTTP/1.1
Cookie: sb-access-token=orgA_session
# Step 2: Change group ID to Org B's group
GET /api/groups/200 HTTP/1.1
Cookie: sb-access-token=orgA_session
# VULNERABLE if: Returns Org B's group data with Org A's session
// Original (own group)
POST /api/groups/add
{"group_owner": 10, "invite_code": "abc123"}
// Modified (target another org)
{"group_owner": 20, "invite_code": "abc123"}
GET /api/team/members/5 → 403 Forbidden
PUT /api/team/members/5 → 200 OK (Vulnerable!)
DELETE /api/team/members/5 → 200 OK (Vulnerable!)
| Location | MGM-Web Examples |
|----------|------------------|
| URL path params | /api/groups/[id], /api/alerts/[id] |
| Query params | ?groupId=123, ?userId=456 |
| Request body | {"group_owner": 10}, {"user_id": 5} |
| Supabase filters | Missing .eq("group_owner", org.organizationRootUserId) |
| Test | Method | IDOR Indicator |
|------|--------|----------------|
| Increment group ID | Change id=5 to id=6 | Returns different org's group |
| Change group_owner | Modify body group_owner field | Assigns group to wrong org |
| Cross-org member access | Use member ID from different org | Returns cross-org data |
| Permission bypass | Member accessing admin endpoint | Action succeeds without check |
| Enumerate user IDs | Test IDs 1-100 | Find valid users from other orgs |
| Status | Interpretation |
|--------|----------------|
| 200 OK | Potential IDOR — verify data ownership |
| 403 Forbidden | Access control working (check org.canManageUsers) |
| 404 Not Found | Could be secure (empty result) or missing resource |
| 401 Unauthorized | Auth check working |
| 500 Error | Possible input validation gap |
// ✅ Every query MUST include group_owner filter
const { data } = await supabase
.from("groups")
.select("*")
.eq("group_owner", org.organizationRootUserId);
// ✅ Check RBAC level before destructive actions
if (!org.canManageUsers) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// ✅ Verify group belongs to org before returning details
const { data: group } = await supabase
.from("groups")
.select("*")
.eq("id", groupId)
.eq("group_owner", org.organizationRootUserId)
.single();
if (!group) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// ✅ Instead of exposing numeric IDs in URLs
// Use org-scoped queries that don't need external IDs
const { data } = await supabase
.from("subscriptions")
.select("*")
.eq("user_id", org.userId) // Always use session user
.single();
| Issue | Solution |
|-------|----------|
| All requests return 403 | Try method switching (GET→POST→PUT), parameter pollution |
| App uses UUIDs | Check response bodies for leaked UUIDs, JS files for hardcoded values |
| Rate limited | Add delays, target specific high-value IDs, test during off-peak |
| Can't verify impact | Create unique data in victim account, compare response lengths |
| Supabase RLS blocks everything | Test with createAdminClient() to see if RLS is the protection vs app code |
security-threat-model — Map trust boundaries and attack pathssecurity-best-practices — Next.js/React security reviewapi-security-best-practices — API auth, validation, rate limitingxss-html-injection — Client-side injection testingtesting
Draft cold emails, warm intro blurbs, follow-ups, update emails, and investor communications for fundraising. Use when the user wants outreach to angels, VCs, strategic investors, or accelerators and needs concise, personalized, investor-facing messaging.
testing
Create and update pitch decks, one-pagers, investor memos, accelerator applications, financial models, and fundraising materials. Use when the user needs investor-facing documents, projections, use-of-funds tables, milestone plans, or materials that must stay internally consistent across multiple fundraising assets.
tools
iMessage/SMS CLI for listing chats, history, and sending messages via Messages.app.
development
Full conversion audit for any homepage or landing page. Use when someone asks to "review my homepage," "audit my landing page," "why isn't my page converting," "check my website," or wants feedback on their marketing page. Requires URL or screenshot before proceeding.