Åtkomstkontroll — serversidans auktorisering
Observera
Genomför PortSwigger-akademins moduler om access control och IDOR (Insecure Direct Object Reference), eller motsvarande utmaningar i Juice Shop. Notera hur ofta problemet är att applikationen kontrollerar vad användaren kan se i gränssnittet snarare än vad användaren har rätt till på servern.
Analysera grundorsaken
Två klassiska mönster:
# Mönster 1: identifieraren litas på utan ägarkontroll
def visa_faktura(request, faktura_id):
faktura = Faktura.objects.get(id=faktura_id)
return render(request, "faktura.html", {"faktura": faktura})
# Ingen kontroll av att fakturan tillhör den inloggade användaren
Vad koden gör — förklaring rad för rad
Det sårbara mönstret i Python. Funktionen hämtar en faktura utifrån numret i webbadressen — men kontrollerar aldrig att fakturan tillhör den som frågar.
- def visa_faktura(request, faktura_id):
- Funktionen tar emot anropet och det fakturanummer som stod i adressen.
- Faktura.objects.get(id=faktura_id)
- Hämtar fakturan med det numret ur databasen. Här finns felet: vilket nummer som helst fungerar.
- return render(...)
- Skickar tillbaka en färdig sida till besökaren.
- # Ingen kontroll...
- Kommentaren markerar vad som saknas. Byt siffran i adressen och du ser någon annans faktura.
// Mönster 2: kontrollen finns bara i gränssnittet
{användare.ärAdmin && <AdminPanel />} // döljer knappen...
// ...men /api/admin/users saknar motsvarande kontroll på serversidan
Vad koden gör — förklaring rad för rad
Det andra sårbara mönstret, i JavaScript för gränssnittet. Knappen döljs för den som inte är administratör — men bara knappen.
- {användare.ärAdmin && <AdminPanel />}
- Läses som: om användaren är administratör, rita ut adminpanelen. Är hen det inte ritas ingenting ut.
- //
- Två snedstreck inleder en kommentar i JavaScript.
- /api/admin/users
- Adressen som panelen hämtar data från. Den går att anropa direkt, utan att gå via knappen — och saknar här en egen kontroll. Att dölja knappen är alltså ingen spärr.
Att dölja något i gränssnittet är inte åtkomstkontroll. Det enda som räknas är kontrollen på serversidan, vid varje anrop.
Bygg försvaret
1. Kontrollera ägarskap vid varje åtkomst — inte bara inloggning:
def visa_faktura(request, faktura_id):
faktura = get_object_or_404(
Faktura,
id=faktura_id,
organisation=request.user.organisation # auktoriseringen ligger i frågan
)
return render(request, "faktura.html", {"faktura": faktura})
Vad koden gör — förklaring rad för rad
Den säkra versionen. Ägarkontrollen flyttas in i själva databasfrågan, vilket gör att den inte kan glömmas bort.
- get_object_or_404(
- Hämta posten, eller svara "finns inte" om den inte hittas. 404 är webbens kod för en sida som inte finns.
- id=faktura_id,
- Första villkoret: rätt fakturanummer.
- organisation=request.user.organisation
- Andra villkoret: och fakturan måste tillhöra den inloggades organisation. Båda villkoren måste stämma, annars hittas ingen post.
- # auktoriseringen ligger i frågan
- Poängen: det finns ingen separat kontroll som kan glömmas, eftersom kontrollen är en del av själva uppslagningen.
Att låta auktoriseringen ingå i databasfrågan är ett robust mönster: det går inte att glömma en separat if-sats som inte finns.
2. Neka som standard. Bygg systemet så att en ny endpoint är stängd tills någon aktivt öppnar den, i stället för tvärtom. Ett centralt mellanlager (middleware) som kräver explicit behörighetsdeklaration per route är ett sätt:
@krav_behorighet("faktura.las")
def visa_faktura(request, faktura_id):
...
Vad koden gör — förklaring rad för rad
En dekorator — ett kort sätt att hänga en regel på en funktion. Raden ovanför funktionen kräver en viss behörighet innan funktionen överhuvudtaget körs.
- @
- Snabel-a framför ett namn markerar en dekorator i Python. Den omsluter funktionen under och körs före den.
- krav_behorighet("faktura.las")
- Kräver behörigheten faktura.las. Saknas den körs aldrig funktionen.
- ...
- Tre punkter betyder bara att resten av funktionen utelämnats i exemplet.
3. Använd oförutsägbara identifierare där det är lämpligt. UUID istället för löpnummer gör inte systemet säkert i sig — auktoriseringskontrollen är fortfarande det som skyddar — men det minskar risken att resurser hittas av misstag och försvårar kartläggning.
4. Testa behörighetsmatrisen systematiskt. Gör en tabell med roller i ena ledden och resurser i den andra, och verifiera varje kombination. Detta är tråkigt och exakt därför missas det ofta.
| Roll | Egen faktura | Annans faktura | Adminpanel |
|---|---|---|---|
| Anonym | Nekas | Nekas | Nekas |
| Användare | Tillåts | Nekas | Nekas |
| Admin | Tillåts | Tillåts | Tillåts |
Verifiera
Skriv automatiserade tester för de nekande fallen, inte bara de tillåtande. Ett test som verifierar att användare A inte kan hämta användare B:s data är mer värdefullt än ett test som verifierar att A kan hämta sin egen:
def test_annan_anvandare_nekas(client, anvandare_a, faktura_tillhor_b):
client.force_login(anvandare_a)
svar = client.get(f"/fakturor/{faktura_tillhor_b.id}/")
assert svar.status_code == 404 # inte 200
Vad koden gör — förklaring rad för rad
Ett automatiskt test. Det bevisar att användare A inte kommer åt användare B:s faktura — och det är det testet som faktiskt har värde.
- def test_annan_anvandare_nekas(...)
- Testfunktioner namnges så att namnet beskriver vad som ska gälla. Verktyget hittar dem på att namnet börjar med test.
- client.force_login(anvandare_a)
- Logga in som användare A i testet.
- client.get(f"/fakturor/{...}/")
- Försök hämta fakturan som tillhör B.
- assert svar.status_code == 404
- assert betyder "detta måste stämma, annars underkänns testet". Här krävs svaret 404, alltså nekad. Hade det blivit 200 vore sårbarheten kvar.
Dokumentera
Bifoga behörighetsmatrisen i rapporten och markera de kombinationer som brast. Detta ger beställaren en konkret bild av omfattningen.
Reflektionsfrågor: Varför är "neka som standard" ett starkare designval än att lägga till kontroller efterhand? Varför bör svaret vara 404 snarare än 403 i vissa fall?
Kunskapskontroll
Välj ett svar per fråga. Du får återkoppling direkt, och avsnittet markeras som klart när alla frågor är besvarade.
1Att dölja en adminknapp i gränssnittet för användare utan behörighet är:
2Vilket mönster är mest robust för att kontrollera att en resurs tillhör den inloggade användaren?
3Vilket test är mest värdefullt för åtkomstkontroll?
Access control — server-side authorization
Observe
Work through PortSwigger Academy's access control and IDOR (Insecure Direct Object Reference) modules, or the equivalent Juice Shop challenges. Note how often the problem is that the application checks what the user can see in the interface rather than what the user is entitled to on the server.
Analyze the root cause
Two classic patterns:
# Pattern 1: the identifier is trusted without an ownership check
def view_invoice(request, invoice_id):
invoice = Invoice.objects.get(id=invoice_id)
return render(request, "invoice.html", {"invoice": invoice})
# No check that the invoice belongs to the logged-in user
What this code does — line by line
The vulnerable pattern in Python. The function fetches an invoice by the number in the web address — but never checks that the invoice belongs to whoever is asking.
- def view_invoice(request, invoice_id):
- The function receives the request and the invoice number that was in the address.
- Invoice.objects.get(id=invoice_id)
- Fetches the invoice with that number from the database. The flaw is here: any number works.
- return render(...)
- Sends a finished page back to the visitor.
- # No check...
- The comment marks what is missing. Change the number in the address and you see someone else's invoice.
// Pattern 2: the check exists only in the interface
{user.isAdmin && <AdminPanel />} // hides the button...
// ...but /api/admin/users has no corresponding server-side check
What this code does — line by line
The second vulnerable pattern, in JavaScript for the interface. The button is hidden from anyone who is not an administrator — but only the button.
- {user.isAdmin && <AdminPanel />}
- Reads as: if the user is an administrator, draw the admin panel. If they are not, nothing is drawn.
- //
- Two slashes start a comment in JavaScript.
- /api/admin/users
- The address the panel fetches data from. It can be called directly, without going through the button — and here it has no check of its own. Hiding the button is therefore no barrier.
Hiding something in the interface is not access control. The only thing that counts is the server-side check, on every request.
Build the defense
1. Check ownership on every access — not just authentication:
def view_invoice(request, invoice_id):
invoice = get_object_or_404(
Invoice,
id=invoice_id,
organization=request.user.organization # authorization lives in the query
)
return render(request, "invoice.html", {"invoice": invoice})
What this code does — line by line
The safe version. The ownership check moves into the database query itself, which means it cannot be forgotten.
- get_object_or_404(
- Fetch the record, or answer "not found" if it is not there. 404 is the web's code for a page that does not exist.
- id=invoice_id,
- First condition: the right invoice number.
- organization=request.user.organization
- Second condition: and the invoice must belong to the logged-in user's organization. Both conditions must hold, otherwise no record is found.
- # authorization lives in the query
- The point: there is no separate check to forget, because the check is part of the lookup itself.
Making authorization part of the database query is a robust pattern: you cannot forget a separate if statement that does not exist.
2. Deny by default. Build the system so a new endpoint is closed until someone actively opens it, rather than the reverse. Central middleware requiring an explicit permission declaration per route is one way:
@requires_permission("invoice.read")
def view_invoice(request, invoice_id):
...
What this code does — line by line
A decorator — a short way of hanging a rule on a function. The line above the function demands a particular permission before the function runs at all.
- @
- An at-sign before a name marks a decorator in Python. It wraps the function below and runs before it.
- requires_permission("invoice.read")
- Demands the invoice.read permission. Without it, the function never runs.
- ...
- Three dots simply mean the rest of the function has been left out of the example.
3. Use unpredictable identifiers where appropriate. UUIDs instead of sequential numbers do not make the system secure on their own — the authorization check is still what protects you — but they reduce the chance of resources being found by accident and make enumeration harder.
4. Test the permission matrix systematically. Build a table with roles on one axis and resources on the other, and verify every combination. This is tedious, which is exactly why it gets skipped.
| Role | Own invoice | Another's invoice | Admin panel |
|---|---|---|---|
| Anonymous | Deny | Deny | Deny |
| User | Allow | Deny | Deny |
| Admin | Allow | Allow | Allow |
Verify
Write automated tests for the denial cases, not just the allow cases. A test verifying that user A cannot fetch user B's data is more valuable than one verifying A can fetch their own:
def test_other_user_denied(client, user_a, invoice_owned_by_b):
client.force_login(user_a)
response = client.get(f"/invoices/{invoice_owned_by_b.id}/")
assert response.status_code == 404 # not 200
What this code does — line by line
An automated test. It proves user A cannot reach user B's invoice — and that is the test with real value.
- def test_other_user_denied(...)
- Test functions are named so the name describes what must hold. The tool finds them by the name starting with test.
- client.force_login(user_a)
- Log in as user A within the test.
- client.get(f"/invoices/{...}/")
- Attempt to fetch the invoice belonging to B.
- assert response.status_code == 404
- assert means "this must hold, otherwise the test fails". Here the answer must be 404, meaning denied. Had it been 200, the vulnerability would still be there.
Document
Include the permission matrix in the report and mark the combinations that failed. This gives the client a concrete picture of the scope.
Reflection: Why is "deny by default" a stronger design choice than adding checks as you go? Why should the response be 404 rather than 403 in certain cases?
Knowledge check
Pick one answer per question. You get feedback immediately, and the unit is marked complete once every question is answered.
1Hiding an admin button in the interface from unauthorized users is:
2Which pattern is most robust for checking that a resource belongs to the logged-in user?
3Which test is most valuable for access control?