IDOR और Broken Access Control: एक व्यावहारिक गाइड
IDOR और broken access control REST, GraphQL और Next.js route handlers में कैसे घुसते हैं, इन्हें कैसे टेस्ट करें, और असली कोडबेस में टिकने वाले फ़िक्स।
· 7 मिनट का लेख · Lina Source LLC
Broken access control वह बग क्लास है जो हर फ़्रेमवर्क अपग्रेड के बाद भी ज़िंदा रहती है। आपका ORM SQL एस्केप कर देता है, आपका टेम्पलेट इंजन HTML एस्केप कर देता है, लेकिन पूरे स्टैक में किसी को यह नहीं पता कि invoice 4812 Alice की है, Bob की नहीं। यह जानकारी आपके कोड में रहती है, और जब कोई एक handler इसे लागू करना भूल जाता है, तो कोई भी लॉग-इन यूज़र किसी और का डेटा पढ़ या बदल सकता है।
सबसे आम रूप है insecure direct object reference, यानी IDOR: क्लाइंट एक identifier भेजता है, सर्वर उस identifier वाला रिकॉर्ड लोड कर देता है, और कोई यह नहीं जाँचता कि कॉलर को उसे देखने की अनुमति है या नहीं। इसका फ़ायदा उठाने के लिए किसी ख़ास टूलिंग की ज़रूरत नहीं। एक ब्राउज़र, एक दूसरा अकाउंट और URL में बदला हुआ एक नंबर काफ़ी है। ख़तरनाक फ़ंक्शन कॉल ढूँढने वाले स्कैनर इसे शायद ही पकड़ पाते हैं, क्योंकि vulnerable कोड में ख़तरनाक कुछ होता ही नहीं: एक बिल्कुल साधारण डेटाबेस lookup में बस एक शर्त छूट गई होती है।
तीन CWE जो आपको दिखेंगे
- CWE-639, यूज़र-नियंत्रित key से authorization bypass: क्लासिक IDOR। रिकॉर्ड उस ID से चुना जाता है जिसे हमलावर नियंत्रित करता है, और ownership कभी जाँची नहीं जाती।
- CWE-862, missing authorization: handler कोई authorization चेक करता ही नहीं। अक्सर यह कोई admin या internal endpoint होता है जिसे अपहुँच मान लिया गया था।
- CWE-285, improper authorization: चेक मौजूद तो है, पर ग़लत है। यह ग़लत फ़ील्ड जाँचता है, write पर read की permission जाँचता है, या क्लाइंट से भेजे गए role पर भरोसा कर लेता है।
बग ठीक करते समय यह फ़र्क़ मायने रखता है। चेक का न होना मतलब एक चेक जोड़ना; ग़लत चेक का मतलब है कि कौन क्या कर सकता है, इसका मॉडल ही ग़लत है, और वही ग़लती शायद और जगहों पर भी दोहराई गई है। इनमें से कोई भी मिले, तो टिकट बंद करने से पहले उसके भाई-बंधु खोजें। Access-control बग शायद ही कभी अकेले होते हैं; वे उन्हीं पैटर्न्स का पीछा करते हैं जिन्हें टीम एक handler से दूसरे में कॉपी करती है।
Next.js route handler में यह कैसे होता है
यह रहा वही पैटर्न अपने सबसे आम रूप में। Handler यूज़र को authenticate करता है, जो सिक्योरिटी जैसा महसूस होता है, और फिर रिकॉर्ड को सिर्फ़ उसकी ID से लोड कर लेता है। Session चेक यह बताता है कि कॉल कौन कर रहा है; यह कोई नहीं बताता कि इस कॉलर को यह invoice देखनी चाहिए या नहीं।
// app/api/invoices/[id]/route.ts (vulnerable)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
// कोई भी साइन-इन यूज़र ID बदलकर कोई भी invoice पढ़ सकता है
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}फ़िक्स यह है कि ownership को query का ही हिस्सा बना दिया जाए, न कि एक अलग स्टेप जिसे भूला जा सके। अगर रिकॉर्ड कॉलर का नहीं है, तो डेटाबेस कुछ नहीं लौटाता और handler 404 जवाब देता है।
// app/api/invoices/[id]/route.ts (fixed)
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const invoice = await db.invoice.findFirst({
where: { id, userId: session.user.id },
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(invoice);
}403 के बजाय 404 लौटाना जान-बूझकर है। 403 यह पुष्टि कर देता है कि रिकॉर्ड मौजूद है, जिससे हमलावर वैध ID गिन सकता है, भले ही वह उन्हें पढ़ न सके। यही बात timing और error messages पर भी लागू होती है: किसी और के रिकॉर्ड का जवाब उस रिकॉर्ड के जवाब से अलग नहीं दिखना चाहिए जो कभी था ही नहीं।
REST: वे endpoints जिन्हें लोग भूल जाते हैं
टीमें आमतौर पर साफ़ दिखने वाले GET by ID को सुरक्षित कर लेती हैं। बग बाक़ी verbs में और API के किनारों पर छिपते हैं:
- PATCH और DELETE handlers जो GET handler से कॉपी किए गए थे, ownership चेक जोड़े जाने से पहले।
- Nested routes जैसे /projects/:projectId/tasks/:taskId, जहाँ project तो जाँचा जाता है पर task सिर्फ़ taskId से लोड होता है और किसी दूसरे project का हो सकता है।
- Bulk endpoints जो IDs की एक array लेते हैं और सिर्फ़ पहली जाँचते हैं।
- File downloads और export jobs, जो अक्सर अपने कमज़ोर चेक वाली किसी अलग सर्विस से होकर चलते हैं।
- Update payloads जो request body से ownerId, organizationId या role स्वीकार करके सीधे डेटाबेस में लिख देते हैं (mass assignment)।
GraphQL सतह को और चौड़ा कर देता है
GraphQL में वही object कई रास्तों से पहुँच में आता है। invoice(id) पर query-level चेक किसी काम का नहीं अगर वही invoice customer { invoices }, किसी node(id) lookup, या किसी mutation के return type से भी पहुँच में हो। हर वह resolver जो कोई object लौटाता है, एक entry point है। DataLoader जैसी batching परतें एक और जाल जोड़ती हैं: सिर्फ़ ID से key किया गया loader किसी भी viewer के लिए रिकॉर्ड ख़ुशी-ख़ुशी लौटा देगा, और अगर उसका cache requests के बीच साझा है तो वह एक यूज़र का डेटा बाद की request को परोस सकता है।
भरोसेमंद तरीक़ा यह है कि authorization उस डेटा लेयर में हो जिसे resolvers कॉल करते हैं, ख़ुद resolvers में नहीं। अगर invoice तक हर रास्ता एक ही फ़ंक्शन से होकर जाए जो viewer लेकर query को scope करता है, तो कोई नया field या relationship उसे bypass नहीं कर सकता। Mutation inputs भी जाँचें: किसी input type में ownerId जैसा field रिकॉर्ड फिर से सौंपने का न्योता है। अंत में याद रखें कि introspection और error messages आपका schema उजागर करते हैं, इसलिए मान लें कि हमलावर हर उस field और relationship को जानते हैं जो आप expose करते हैं।
वही बग Python में
SQLAlchemy के साथ FastAPI में आकार बिल्कुल वही है। Vulnerable वर्ज़न db.get(Document, doc_id) कॉल करता है; fixed वर्ज़न उसी statement में owner से filter करता है।
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/documents/{doc_id}")
def get_document(
doc_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
# Vulnerable: doc = db.get(Document, doc_id)
doc = db.scalar(
select(Document).where(
Document.id == doc_id,
Document.owner_id == user.id,
)
)
if doc is None:
raise HTTPException(status_code=404, detail="Not found")
return docवे फ़िक्स जो टिकते हैं
हर query को owner या tenant से scope करें
हर read और write के WHERE clause में यूज़र या organization ID रखें। इससे authorization query की एक विशेषता बन जाती है, जिसे रिव्यू में देखना आसान है। Multi-tenant ऐप्स के लिए Postgres row-level security tenant सीमा को दूसरी परत के रूप में लागू कर सकती है, ताकि भूला हुआ filter किसी दूसरे कस्टमर की rows के बजाय कुछ भी न लौटाए। यही scoping writes पर भी लागू होती है। एक update ID और owner दोनों से filter किया गया एक ही statement होना चाहिए, जैसे दोनों शर्तों के साथ updateMany और फिर यह जाँच कि ठीक एक row बदली, न कि एक read, एक चेक और एक अलग write जो race कर सकते हैं।
फ़ैसले को एक जगह लाएँ
बिखरे हुए if statements समय के साथ अलग-अलग दिशाओं में चले जाते हैं। हर resource के लिए एक, ऐसे छोटे helpers का सेट नियम को एक जगह रखता है और बिना helper कॉल वाले handler को अलग से दिखा देता है।
// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";
const policy: Record<Role, ReadonlySet<Action>> = {
owner: new Set<Action>(["read", "update", "delete"]),
member: new Set<Action>(["read", "update"]),
viewer: new Set<Action>(["read"]),
};
export class NotFoundError extends Error {}
export async function requireProject(
userId: string,
projectId: string,
action: Action
) {
const membership = await db.membership.findFirst({
where: { userId, projectId },
include: { project: true },
});
// डिफ़ॉल्ट रूप से मना करें: membership न होना और permission न होना एक जैसे दिखते हैं
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}डिफ़ॉल्ट रूप से मना करें
अनजान roles, ग़ायब memberships और अप्रत्याशित actions, सबको मनाही पर गिरना चाहिए। Middleware वाले फ़्रेमवर्क्स में हर चीज़ के लिए authentication ज़रूरी करें और public routes को स्पष्ट रूप से चिह्नित करें, न कि इसका उल्टा। एक नया route तब तक बंद रहना चाहिए जब तक कोई और तय न करे। Role, tenant या यूज़र ID कभी request body या क्लाइंट-सेट header से न लें; उन्हें हर बार सर्वर पर verified session से निकालें।
UUID जैसे रैंडम identifiers इस्तेमाल करने लायक़ हैं, पर वे फ़िक्स नहीं हैं। IDs URLs, logs, शेयर किए गए लिंक और referrer headers से लीक होती हैं। उन्हें सिर्फ़ इस अर्थ में अनुमान-रहित मानें कि वे enumeration को धीमा करते हैं, कभी access चेक के रूप में नहीं।
इसे टेस्ट कैसे करें
IDOR टेस्टिंग सरल और दोहरावदार है, इसीलिए एक बार हाथ से कर लेने के बाद इसे ऑटोमेट करना फ़ायदेमंद है। एक inventory से शुरू करें: हर वह route, resolver और background job सूचीबद्ध करें जो identifier लेता है, जिनमें request bodies, query strings और headers में छिपी IDs भी शामिल हैं।
- दो अकाउंट बनाएँ, A और B, आदर्श रूप से दो अलग organizations में। A के रूप में एक रिकॉर्ड बनाएँ और उसकी ID नोट करें।
- उस ID का ज़िक्र करने वाली हर request को B के session के साथ दोहराएँ: GET, PATCH, DELETE, downloads, exports और उसे छूने वाली हर GraphQL query या mutation।
- सबके लिए 404 की उम्मीद करें। कोई भी 200, और अस्तित्व की पुष्टि करने वाला कोई भी 403, एक फ़ाइंडिंग है।
- मैन्युअल जाँच को हर resource के लिए एक integration test में बदलें, ताकि scoped query के बिना बना कोई नया handler CI में फ़ेल हो जाए।
- सिर्फ़ primary key से होने वाले lookups खोजें, जैसे findUnique({ where: { id } }) या db.get(Model, id), और हर एक को सही ठहराएँ।
कोड रिव्यू वह पकड़ता है जो टेस्ट छोड़ देते हैं, क्योंकि छूटा हुआ चेक सोर्स में दिखता है, भले ही उस route के लिए किसी ने टेस्ट न लिखा हो। CodeAuditAgent एक पब्लिक GitHub रिपॉज़िटरी या पेस्ट किया गया स्निपेट पढ़ता है और access-control की कमियाँ CWE, कोट की गई लाइन, एक exploit परिदृश्य और प्रस्तावित पैच के साथ रिपोर्ट करता है, जो एक ही बार में हर handler पर दूसरी नज़र डालने का तेज़ तरीक़ा है।
एक छोटी चेकलिस्ट
- क्लाइंट से आई ID लेने वाली हर query कॉलर के यूज़र या tenant से भी filter करती है।
- Authorization साझा helpers या डेटा लेयर में रहता है, कॉपी-पेस्ट किए गए if statements में नहीं।
- अनजान मामले मना करते हैं; public routes स्पष्ट अपवाद हैं।
- Write operations की जाँच reads जितनी ही सावधानी से होती है, bulk और nested routes समेत।
- हर resource के लिए दो-अकाउंट टेस्ट मौजूद हैं और CI में चलते हैं।