Artikel Säkerhet · Auktorisering

Inloggad men inte behörig: objektnivåkontrollen som AI-koden hoppar över

AI-genererad CRUD-kod kan kontrollera att någon är inloggad men ändå missa att just den efterfrågade posten tillhör den som frågar. Endpointen ser korrekt ut i granskning, testsviten är grön — och sedan byter någon ett id i adressfältet.

Senast granskad: 22 aug 2026

Nätverksformad gorilla framför en mörk passage med gröna ljus
Inloggningen öppnar dörren, men ägarkontrollen avgör vilken post användaren får nå.

Kortfattat

Autentisering svarar på frågan vem är du. Auktorisering på objektnivå svarar på frågan är den här posten din. Den första frågan syns tydligt när koden har ett autentiseringsmiddleware. Den andra frågan finns i en where-sats som ingen saknar när den inte är där. OWASP kallar felet API1:2023 Broken Object Level Authorization och rankar det som den vanligaste API-sårbarheten [1]; CWE kallar samma sak Authorization Bypass Through User-Controlled Key [3]. Motmedlet är ett sökmönster, ett test med två användare och en frågeväg där den ofiltrerade varianten inte går att skriva.

Endpointen som passerar granskningen

Det här är felmodellen i konkret form: en Express-route, ett autentiseringsmiddleware, en hämtning och ett prydligt 404-svar:

router.get('/api/fakturor/:id', kravInloggad, async (req, res) => {
  const faktura = await db.faktura.findUnique({
    where: { id: req.params.id }
  });

  if (!faktura) {
    return res.status(404).json({ fel: 'Hittades inte' });
  }
  res.json(faktura);
});

kravInloggad gör precis vad den heter: den avvisar den som saknar giltig session. Sedan slår where-satsen upp posten på enbart id. Varje inloggad användare i systemet kan alltså hämta varje faktura i tabellen. OWASP beskriver mekanismen rakt: angripare utnyttjar sårbara endpoints genom att manipulera det objekt-id som skickas i anropet, och id:n kan vara allt från löpnummer till UUID-värden eller vanliga strängar [1].

Anledningen till att felet överlever både granskning och testsvit är att båda tittar på rätt saker i fel konstellation. Granskaren ser ett autentiseringsmiddleware och bockar av behörighet. Testsviten har en enda testanvändare, och den användaren råkar äga allt som fixturen skapar. Ingen befintlig kontroll ställer den enda fråga som avslöjar buggen: vad händer när någon annan frågar?

Slumpade id:n är inte kontrollen

OWASP rekommenderar slumpade och oförutsägbara GUID-värden som id [1], och det är ett rimligt extra lager. Men det är fördröjning, inte skydd. Ett id läcker via delade länkar, exportfiler, webhooks, supportärenden och den egna listvyn. CWE-639 sätter den faktiska åtgärden först: kontrollera användarens behörighet vid varje dataåtkomst, innan posten hämtas [3]. Om svaret på “varför är den här endpointen säker?” är “id:t går inte att gissa”, saknas kontrollen.


Sök fram kandidaterna i tre pass

Du letar efter handlers som läser ett id från klienten men aldrig nämner ägarkolumnen. Det går att närma sig mekaniskt, i tre pass. Först: var kommer klientens id in?

rg -n "req\.params\.|req\.query\.|req\.body\.id" src --glob '!**/*.test.*'

Sedan: nämns ägarskap över huvud taget i samma fil? Byt ut kolumnnamnen mot dina egna — ownerId, userId, tenantId, kundId:

rg -c "ownerId|userId|tenantId" src/routes/fakturor.js

En fil som ger träffar i första passet och 0 i det andra är en stark kandidat för granskning. Det tredje passet är det som faktiskt kräver ögon: i en fil där båda ger träffar kan en handler bära kontrollen och grannen sakna den. Räkna per funktion, inte per fil.

Motsvarande i ett Django-projekt är skillnaden mellan Faktura.objects.get(pk=pk) och Faktura.objects.filter(agare=request.user).get(pk=pk). Sök då efter objects.get(pk= och get_object_or_404( med modellklassen som första argument — den formen slår upp globalt i tabellen.

Gör kandidatlistan till en tabell

Skriv ner varje träff som en rad: metod, sökväg, resurstyp, ägarkolumn, ja eller nej. Tabellen är arbetsmaterialet för både testerna längre ner och för CI-regeln. Ta med alla verb, inte bara GET. En DELETE utan ägarkontroll kan få större konsekvenser än en GET: den är destruktiv och lämnar inget kvar att jämföra med. OWASP:s tredje exempelscenario är precis det — dokument som raderas via ett id utan behörighetskontroll [1].

Sökningen ger en kandidatlista, inte en dom. Det är en granskningsuppgift av samma slag som resten av kodgranskningen av AI-genererad kod, men med ett mycket smalare mål: en rad per endpoint, ett ja eller nej per rad.


Testet som kräver två användare

Ett test med en användare kan inte se det här felet, oavsett hur många fall det innehåller. Testet måste ha en ägare och en främling. Användare A skapar posten, användare B begär den:

test('B kommer inte åt A:s faktura', async () => {
  const a = await skapaAnvandare('a@example.test');
  const b = await skapaAnvandare('b@example.test');

  const skapad = await request(app)
    .post('/api/fakturor')
    .set('Authorization', `Bearer ${a.token}`)
    .send({ belopp: 1200 })
    .expect(201);

  const svar = await request(app)
    .get(`/api/fakturor/${skapad.body.id}`)
    .set('Authorization', `Bearer ${b.token}`);

  expect(svar.status).toBe(404);
  expect(JSON.stringify(svar.body)).not.toContain('1200');
});

Tre detaljer avgör om testet är värt något. Den andra assertionen är den första: ett svar kan ha rätt statuskod och ändå bära med sig posten i kroppen, till exempel när felhanteraren serialiserar det objekt som redan hämtats. Statuskoden ensam bevisar ingenting om vad som faktiskt lämnade servern.

Den andra detaljen är ingången. Testet går via app och därmed genom hela middlewarekedjan. Anropar du bara servicefunktionen riskerar du att hoppa över det lager där kontrollen faktiskt ligger. Den tredje: kör samma testkropp för GET, PATCH och DELETE på varje ägd resurstyp. En tabelldriven testfil över kandidatlistan från förra avsnittet gör det billigt nog att faktiskt bli av.

Det här är också vad källorna uttryckligen efterfrågar. OWASP:s förebyggande lista för API1:2023 avslutas med att skriva tester som utvärderar auktoriseringsmekanismens sårbarhet och att inte driftsätta ändringar som får testerna att fallera [1]. OWASP Top 10:2025 formulerar samma krav mot utvecklare och testare: funktionell åtkomstkontroll ska ingå i enhets- och integrationstesterna [2].


404 eller 403: välj en gång, per resurstyp

Båda svaren är protokollmässigt korrekta, och RFC 9110 säger det rakt ut. Om 403 gäller att servern förstod anropet men vägrar utföra det, och att en server som vill dölja att en förbjuden resurs finns i stället får svara 404 [4]. Om 404 gäller att servern inte hittade någon representation — eller inte är villig att avslöja att en sådan finns [4].

Avvägningen är alltså inte rätt mot fel utan vad du lämnar ifrån dig. Svarar du konsekvent 403 på andras poster och 404 på id:n som inte finns, har du byggt ett existensorakel: den som stegar igenom löpnummer kan kartlägga vilka poster som existerar, utan att se innehållet. Antalet fakturor och kundnummerserien kan då gå att uppskatta ur skillnaden mellan två statuskoder.

Välj 404 när enbart existensen är känslig och id:t är gissningsbart: fakturor, journalposter, ärenden med löpnummer. Då är svaret identiskt oavsett om posten saknas eller tillhör någon annan.
Välj 403 när användaren redan vet att objektet finns men saknar roll för åtgärden — ett dokument i en delad arbetsyta, en rapport hen ser i en lista men inte får exportera. Ett 404 där skickar bara supportärendet fel.
Blanda inte inom en resurstyp. Inkonsekvensen mellan endpoints är själva läckan, inte kodvalet i sig.
Logga alltid skillnaden på serversidan. OWASP Top 10:2025 vill att åtkomstfel loggas och att administratörer larmas när det är befogat [2]. Loggen är där detaljen du tog bort ur svaret ska finnas kvar.

Statuskoden är i sig data som lämnar huset, vilket gör valet till en fråga av samma slag som resten av säkerhetsguiden om vad som får lämna huset. Skillnaden är att den här läckan ryms i tre siffror.


Lägg kontrollen där nästa endpoint inte kan missa den

Den snabba lagningen är en if-sats efter hämtningen: jämför faktura.agareId med req.user.id och svara 404 om de skiljer sig. Den kan vara korrekt för just den endpointen, men kontrollen är frivillig och därför lätt att utelämna i nästa handler.

Flytta i stället ägarskapet in i frågan, så att den ofiltrerade varianten inte har någon plats att stå på:

const faktura = await db.faktura.findFirst({
  where: { id: req.params.id, agareId: req.user.id }
});

if (!faktura) {
  return res.status(404).json({ fel: 'Hittades inte' });
}

Nu faller “finns inte” och “tillhör någon annan” ihop i samma gren, och 404-beteendet uppstår av sig självt i stället för att behöva kommas ihåg. Ett steg till, i en större kodbas, är en åtkomstfunktion — fakturorFor(user) — som returnerar en redan filtrerad fråga, så att en handler inte kan nå tabellen ofiltrerad. Det motsvarar OWASP Top 10:2025:s tre första råd: neka som standard utom för publika resurser, implementera mekanismen en gång och återanvänd den, och låt modellen upprätthålla ägarskap i stället för att låta användaren läsa eller ändra vilken post som helst [2].

Priset ska sägas ut. Ett extra lager är kod att underhålla, och admin-, support- och batchflöden behöver en väg förbi. Poängen är att den vägen då blir ett ställe med explicit namn och egen loggning, i stället för en vana att skriva ofiltrerade frågor. Radnivåsäkerhet i databasen — ROW LEVEL SECURITY i PostgreSQL — kan flytta kontrollen ännu ett steg ner och låta en policy begränsa vilka rader normala frågor får läsa eller ändra [5]. I gengäld måste applikationen föra användar- eller tenantidentiteten till databasen på ett konsekvent sätt och hantera anslutningspoolen därefter. Det är en avvägning, inte en uppgradering.


Gör kontrollen till en stoppregel

Ett test som finns hindrar den bugg det täcker. Det hindrar inte nästa endpoint. Skillnaden mellan de två ligger i om bygget kan bli grönt utan testet. Lägg därför till en vaktkontroll som räknar: enumerera de registrerade routerna vid start, plocka ut dem vars sökväg innehåller en id-parameter, och fallera när en sådan route saknar rad i tabellen över ägarskapstester. Då blir “vi glömde” ett rött bygge i stället för en upptäckt i produktion. OWASP formulerar grundregeln som en driftsättningsspärr: driftsätt inte ändringar som får auktoriseringstesterna att fallera [1].

Två saker gör vaktkontrollen hållbar. Den ska tillåta ett uttryckligt undantag med motivering i koden, annars kringgås den; en publik delningslänk är ett legitimt undantag. Och den ska köra mot routerregistret, inte mot en handskriven lista över endpoints, eftersom en handskriven lista åldras exakt lika fort som den kontroll den skulle ersätta.

Den bredare säkerhetsgranskningen av en AI-genererad kodbas täcker autentisering, beroenden och loggning. Objektnivåkontrollen ligger ett lager under alla tre: den avgör inte vem som släpps in, utan vad var och en av dem får se när de väl är inne.


Källor

[1] OWASP API Security Top 10 2023, API1:2023 Broken Object Level Authorization — manipulerade objekt-id:n, exempelscenarierna samt de förebyggande råden om auktoriseringsmekanism, oförutsägbara id:n och tester som inte får fallera vid driftsättning. [2] OWASP Top 10:2025, A01 Broken Access Control — insecure direct object references som exempel, samt råden om deny by default, återanvänd mekanism, ägarskap i modellen, loggning av åtkomstfel och åtkomstkontroll i enhets- och integrationstester. [3] MITRE, CWE-639: Authorization Bypass Through User-Controlled Key — definitionen av felet och åtgärden att verifiera behörighet vid varje åtkomst till data. [4] IETF, RFC 9110, avsnitt 15.5.4 och 15.5.5 — 403 Forbidden respektive 404 Not Found, inklusive att en server som vill dölja en förbjuden resurs får svara 404. [5] PostgreSQL 18-dokumentationen, Row Security Policies — hur tabellpolicyer begränsar vilka rader vanliga frågor får läsa, infoga, ändra eller radera. Samtliga källor lästa 22 augusti 2026. Kodexemplen är förkortade illustrationer av mönstret och innehåller varken felhantering eller indatavalidering i full form.