Modelový prípad 01Ofenzívna bezpečnosť

Cudzie faktúry cez API: chýbajúca kontrola vlastníctva

Zákaznícky portál overoval, kto je prihlásený, ale nie, komu faktúra patrí. Ako sa chyba našla, prečo vznikla a ako ju opraviť, aby sa nevrátila.

Firma
Veľkoobchod so spotrebným materiálom, 18 zamestnancov
Tím
Dvaja vývojári, jeden na čiastočný úväzok
Aplikácia
Zákaznícky portál: objednávky, faktúry v PDF, dodacie listy
Technológie
Node.js a Express 5, PostgreSQL, React, jeden VPS za Nginx
Obmedzenia
Žiadny bezpečnostný tím, aplikačné logy sa držia 30 dní

01Východiskový stav

Portál používajú firemní zákazníci veľkoobchodu. Každá zákaznícka firma má jedného alebo viac používateľov. Prihlásenie vytvorí reláciu uloženú na serveri, v ktorej je ID používateľa a ID jeho firmy. Prehliadač drží iba náhodný identifikátor relácie v cookie.

Faktúry sú v tabuľke invoices so stĺpcom company_id. Ich identifikátory sú postupné celé čísla. Aplikácia pri každej požiadavke loguje ID používateľa, metódu a cestu.

  1. Internet
  2. Nginx (TLS, reverse proxy)
  3. Node.js / Express 5 (API, relácie)
  4. PostgreSQL

02Problém

Pred rozšírením portálu o platby si firma objednala penetračný test. Tester dostal dva testovacie účty v dvoch rôznych zákazníckych firmách. Po prihlásení prvým účtom stačilo v požiadavke GET /api/invoices/1841 zmeniť číslo na faktúru druhej firmy a API ju vrátilo celú: odberateľa, adresu, položky aj sumy. Rovnako sa správalo stiahnutie PDF a detail dodacieho listu.

Predpoklad útoku: útočník potrebuje ľubovoľný platný účet v portáli, teda musí byť zákazníkom alebo ovládnuť účet zákazníka. Keďže sú čísla postupné, cudzie faktúry sa dali prechádzať po poradí. Meniť ani mazať ich nešlo, tie operácie portál zákazníkom vôbec neponúka.

Dopad: únik obchodných a osobných údajov všetkých zákazníkov portálu. Ak by sa ukázalo, že chybu niekto zneužil, išlo by o porušenie ochrany osobných údajov, ktoré treba posúdiť podľa GDPR. OWASP túto triedu chýb vedie ako Broken Object Level Authorization (API1:2023).

03Technická príčina

Portál mal v poriadku autentifikáciu, teda overenie, kto je prihlásený. Chýbala autorizácia, teda overenie, či smie tento používateľ pristúpiť práve k tomuto záznamu. Middleware requireAuth kontroloval iba to, že relácia existuje.

Dopyt vyberal faktúru výhradne podľa ID z adresy: SELECT ... FROM invoices WHERE id = $1. Je parametrizovaný, takže SQL injection nehrozila, ale nič v ňom neobmedzovalo výsledok na firmu prihláseného používateľa. Zoznam faktúr podmienku na company_id mal. Detail, PDF a dodací list vznikli neskôr, každý trochu inak, a na podmienku sa pri nich zabudlo.

04Vyšetrenie

Najprv prešli všetky koncové body, ktoré načítavajú záznam podľa ID z požiadavky, a pri každom overili, či dopyt obsahuje aj firmu z relácie. Chyba bola presne v troch: detail faktúry, PDF a detail dodacieho listu.

Potom zisťovali, či ju už niekto zneužil. Logy za posledných 30 dní porovnali s databázou a hľadali požiadavky, pri ktorých faktúra z cesty patrila inej firme než používateľ. Okrem požiadaviek z testovacích účtov žiadne nenašli.

Za obdobie staršie ako 30 dní sa to overiť nedá, logy už neexistujú. Firma to tak zapísala do záznamu o incidente a netvrdí, že k zneužitiu nedošlo. Tvrdí iba to, čo vie preukázať.

05Náprava

Každý dopyt na záznam, ktorý patrí firme, dostal podmienku na firmu. ID firmy sa berie výhradne z relácie na serveri, nikdy z tela ani z parametrov požiadavky. Prístup k týmto tabuľkám ide cez malú vrstvu funkcií, ktoré ID firmy vyžadujú ako povinný argument.

invoices.js
// data layer: the company is a required argument, not an afterthought
export async function getInvoice(companyId, invoiceId) {
  const { rows } = await pool.query(
    `SELECT id, number, issued_at, total
       FROM invoices
      WHERE id = $1 AND company_id = $2`,
    [invoiceId, companyId],
  );
  return rows[0] ?? null;
}

// route (Express 5 passes a rejected promise on to the error handler)
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  if (!/^\d+$/.test(req.params.id)) return res.sendStatus(404);

  // companyId comes from the server-side session, never from the request
  const invoice = await getInvoice(req.session.companyId, req.params.id);

  // "does not exist" and "not yours" must look the same from outside
  if (!invoice) return res.sendStatus(404);
  res.json(invoice);
});

Neexistujúca aj cudzia faktúra vracia rovnako 404. Keby cudzia vracala 403, dalo by sa ňou zisťovať, ktoré čísla faktúr existujú.

Aby sa chyba nevrátila s ďalším koncovým bodom, pribudli integračné testy s dvoma firmami. Bežia v CI pri každom pull requeste a pravidlo tímu je nezlučovať nič, čo ich poruší.

invoices.test.js
import { test } from "node:test";
import request from "supertest";
import { app } from "../src/app.js";
import { seedTwoCompanies } from "./fixtures.js";

test("a user of company A cannot read company B's invoice", async () => {
  const { userA, invoiceB } = await seedTwoCompanies();
  const agent = request.agent(app); // keeps the session cookie

  await agent.post("/api/login").send(userA.credentials).expect(200);
  await agent.get(`/api/invoices/${invoiceB.id}`).expect(404);
  await agent.get(`/api/invoices/${invoiceB.id}/pdf`).expect(404);
});

Zvážené alternatívy: Row Level Security v PostgreSQL by pridala kontrolu priamo v databáze, no vyžaduje nastaviť kontext firmy pri každej transakcii a aplikácia sa nesmie pripájať ako vlastník tabuliek, na ktorého sa politiky štandardne nevzťahujú. Pre dvoch vývojárov bol čitateľnejší explicitný filter s testami. Náhodné UUID namiesto postupných čísel by sťažili hádanie, ale autorizáciu nenahrádzajú.

06Prečo oprava funguje

Databáza vráti riadok iba vtedy, keď sedí ID faktúry aj firma. Firmu určuje relácia uložená na serveri a klient jej obsah zmeniť nevie. Zmenou čísla v adrese tak útočník dostane vždy iba 404.

Povinný argument companyId presúva kontrolu z pamäti vývojára do kódu: funkciu bez firmy sa nedá zavolať. Testy s dvoma firmami zachytia prípad, keď niekto vrstvu obíde a napíše dopyt priamo.

Čo to nerieši: kto ovládne účet zákazníka, napríklad uhádnutým heslom, uvidí faktúry tej firmy, pretože k nim oprávnenie naozaj má.

07Overenie

  • Tester zopakoval pôvodný postup s oboma účtami pri detaile faktúry, PDF aj dodacom liste. Všetky tri vrátili 404.
  • Integračné testy s dvoma firmami prechádzajú a pri zámerne odstránenej podmienke na firmu zlyhajú. Tým je overené, že testujú to, čo majú.
  • Po nasadení sa požiadavky na cudzie faktúry objavujú v logoch už iba s odpoveďou 404.

08Výsledky

Opravené

  • Čítanie cudzích faktúr, PDF a dodacích listov zmenou ID už nefunguje.

Znížené riziko

  • Riziko, že nový koncový bod zopakuje tú istú chybu, znižuje povinná vrstva prístupu k dátam a testy s dvoma firmami.

Ostáva otvorené

  • Za obdobie pred viac ako 30 dňami sa nedá preukázať, či chybu niekto nezneužil.
  • Faktúry majú stále postupné čísla. Samo o sebe to nie je chyba, ale ďalšia chyba autorizácie by sa dala zneužiť ľahšie.

09Obmedzenia

  • Testy pokrývajú iba koncové body, pre ktoré niekto test napíše. Nový typ záznamu bez testu môže chybu zopakovať.
  • Oprava nechráni pred prevzatím účtu zákazníka. Na to slúži dvojfaktorové overenie a obmedzenie neúspešných prihlásení, ktoré portál zatiaľ nemá.
  • Kontrola je v aplikácii, nie v databáze. Kto má priamy prístup k databáze, vidí všetko.

10Poučenia

  • Prihlásený neznamená oprávnený. Každý prístup k záznamu podľa ID potrebuje aj kontrolu vlastníctva.
  • Kontrola patrí do dopytu, nie za neho. Čo databáza nevráti, to neunikne.
  • Neexistuje a nepatrí vám majú zvonku vyzerať rovnako.
  • Test s dvoma účtami v dvoch firmách je najlacnejšia ochrana pred touto triedou chýb.
  • Logy s ID používateľa rozhodujú o tom, či sa po chybe dá povedať, čo sa stalo.

11Ďalšie kroky pre malú firmu

  1. 1.Doplniť test s dvoma firmami ku každému koncovému bodu, ktorý číta podľa ID.
  2. 2.Zapnúť dvojfaktorové overenie aspoň pre administrátorov portálu a obmedziť neúspešné prihlásenia.
  3. 3.Predĺžiť uchovávanie aplikačných logov na 90 dní a ukladať ich mimo servera aplikácie.
  4. 4.Zvážiť Row Level Security v PostgreSQL ako druhú vrstvu.
Ozvite sa

Spoznávate v tom svoju firmu?

Napíšte nám, čoho sa to týka. Ozveme sa do jedného pracovného dňa a povieme, či je to práca pre nás, aj keď odpoveď bude nie.