$npx -y skills add utkusen/sast-skills --skill sast-idorDetect Insecure Direct Object Reference (IDOR) vulnerabilities in a codebase using a three-phase approach: recon (find candidates), batched verify (check authorization in parallel subagents, 3 candidates each), and merge (consolidate batch results). Checks endpoints for missing o
| 1 | # IDOR (Insecure Direct Object Reference) Detection |
| 2 | |
| 3 | You are performing a focused security assessment to find IDOR vulnerabilities in a codebase. This skill uses a three-phase approach with subagents: **recon** (find candidate endpoints), **batched verify** (check authorization in parallel batches of 3), and **merge** (consolidate results). |
| 4 | |
| 5 | **Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## What is IDOR |
| 10 | |
| 11 | IDOR occurs when an application uses a user-supplied identifier (ID, slug, filename, etc.) to directly access an object **without verifying the requesting user is authorized to access that specific object**. The application authenticates the user but fails to check ownership or permissions on the requested resource. |
| 12 | |
| 13 | The core pattern: *authenticated user A can access or modify resources belonging to user B by changing an identifier in the request.* |
| 14 | |
| 15 | ### What IDOR IS |
| 16 | |
| 17 | - Changing `/api/orders/1001` to `/api/orders/1002` and seeing another user's order |
| 18 | - Sending `DELETE /api/documents/555` to delete a document you don't own |
| 19 | - Modifying `{"account_id": 789}` in a request body to transfer money from someone else's account |
| 20 | - Changing a file download parameter `?file_id=42` to access another user's private file |
| 21 | - Updating another user's profile via `PUT /api/users/other-user-id` |
| 22 | |
| 23 | ### What IDOR is NOT |
| 24 | |
| 25 | Do not flag these as IDOR: |
| 26 | |
| 27 | - **Missing authentication**: Endpoint requires no login at all → that's "Unauthenticated Access", a different class |
| 28 | - **Broken function-level access control**: Regular user accessing `/admin/dashboard` → that's vertical privilege escalation, not IDOR |
| 29 | - **Public resources**: Accessing `/api/posts/123` where posts are intentionally public is not IDOR |
| 30 | - **Parameter tampering on non-object fields**: Changing `role=admin` or `price=0` in a request → that's mass assignment or business logic, not IDOR |
| 31 | - **SQL injection via ID fields**: `?id=1 OR 1=1` → that's SQLi, not IDOR |
| 32 | |
| 33 | ### Authorization Patterns That Prevent IDOR |
| 34 | |
| 35 | When you see these patterns, the endpoint is likely **not vulnerable**: |
| 36 | |
| 37 | **1. Query scoped to current user (most common fix)** |
| 38 | ``` |
| 39 | # The query itself ensures only the user's own records are returned |
| 40 | Order.objects.filter(id=order_id, user=request.user) # Django |
| 41 | current_user.orders.find(params[:id]) # Rails |
| 42 | Order.findOne({ _id: orderId, userId: req.user.id }) # Mongoose |
| 43 | SELECT * FROM orders WHERE id = ? AND user_id = ? # Raw SQL |
| 44 | ``` |
| 45 | |
| 46 | **2. Explicit ownership check after fetch** |
| 47 | ``` |
| 48 | order = Order.find(order_id) |
| 49 | if order.user_id != current_user.id: |
| 50 | raise Forbidden |
| 51 | ``` |
| 52 | |
| 53 | **3. Policy / ability / authorization middleware** |
| 54 | ``` |
| 55 | authorize('view', order) # Laravel Policy |
| 56 | can?(:read, @order) # CanCanCan (Rails) |
| 57 | @PreAuthorize("@auth.ownsOrder(#orderId)") # Spring Security |
| 58 | ``` |
| 59 | |
| 60 | **4. Tenant/organization scoping** |
| 61 | ``` |
| 62 | # Multi-tenant apps that scope all queries to the tenant |
| 63 | tenant = get_current_tenant(request) |
| 64 | Order.objects.filter(id=order_id, tenant=tenant) |
| 65 | ``` |
| 66 | |
| 67 | --- |
| 68 | |
| 69 | ## Vulnerable vs. Secure Examples |
| 70 | |
| 71 | ### Python — Django |
| 72 | |
| 73 | ```python |
| 74 | # VULNERABLE: fetches any order by ID, no ownership check |
| 75 | def get_order(request, order_id): |
| 76 | order = Order.objects.get(id=order_id) |
| 77 | return JsonResponse(model_to_dict(order)) |
| 78 | |
| 79 | # SECURE: query scoped to requesting user |
| 80 | def get_order(request, order_id): |
| 81 | order = get_object_or_404(Order, id=order_id, user=request.user) |
| 82 | return JsonResponse(model_to_dict(order)) |
| 83 | ``` |
| 84 | |
| 85 | ### Python — Flask / SQLAlchemy |
| 86 | |
| 87 | ```python |
| 88 | # VULNERABLE |
| 89 | @app.route('/api/documents/<int:doc_id>') |
| 90 | @login_required |
| 91 | def get_document(doc_id): |
| 92 | doc = Document.query.get_or_404(doc_id) |
| 93 | return jsonify(doc.serialize()) |
| 94 | |
| 95 | # SECURE |
| 96 | @app.route('/api/documents/<int:doc_id>') |
| 97 | @login_required |
| 98 | def get_document(doc_id): |
| 99 | doc = Document.query.filter_by(id=doc_id, owner_id=current_user.id).first_or_404() |
| 100 | return jsonify(doc.serialize()) |
| 101 | ``` |
| 102 | |
| 103 | ### Node.js — Express / Mongoose |
| 104 | |
| 105 | ```javascript |
| 106 | // VULNERABLE |
| 107 | router.get('/api/orders/:id', auth, async (req, res) => { |
| 108 | const order = await Order.findById(req.params.id); |
| 109 | res.json(order); |
| 110 | }); |
| 111 | |
| 112 | // SECURE |
| 113 | router.get('/api/orders/:id', auth, async (req, res) => { |
| 114 | const order = await Order.findOne({ _id: req.params.id, userId: req.user.id }); |
| 115 | if (!order) return res.status(404).json({ error: 'Not found' }); |
| 116 | res.json(order); |
| 117 | }); |
| 118 | ``` |
| 119 | |
| 120 | ### Node.js — Express / Prisma |
| 121 | |
| 122 | ``` |