V červnu jsme Penetrify pustili na naši vlastní aplikaci, jako to děláme před větším releasem. Scan se vrátil s problémem v našem přihlašování přes Cognito, a když jsem za tu nitku zatáhl, vyšlo z toho něco, co jsem nečetl rád: dashboard Penetrify držel access a refresh tokeny z Cognita v localStorage, v plaintextu, čitelné jakýmkoli JavaScriptem na stránce.
Nesedli jsme si a takhle se nerozhodli. Je to default auth knihovny, kterou jsme použili, a nikdy jsme ho nezpochybnili. Právě to je na tom to zajímavé.
Co přesně byl ten nález
Náš dashboard je single-page aplikace. Po přihlášení dostala tokeny z AWS Cognita a SDK je uložilo do browser storage, protože tam je SDK ukládají, když v session není zapojený server. Přečíst je mohl jakýkoli skript s přístupem na stránku.
Znamená to, že jedna cross-site scripting chyba kdekoli v aplikaci, jedno škodlivé rozšíření prohlížeče nebo jedna kompromitovaná npm závislost, a útočník odchází s oběma tokeny. Access token ho dostane do účtu. Refresh token ho tam udrží, protože zůstává platný dlouho po tom, co session vypadá zavřená, dokud ho něco výslovně nezneplatní.
U našich uživatelů byl v dosahu fakturační údaj, výsledky scanů jejich aplikací a data o affiliate výplatách. Nakolik to umíme posoudit, nikdo to nezneužil. O to ale nejde. Bylo to dosažitelné a byl to náš vlastní default k opravě.
Proč šifrování localStorage není řešení
První instinkt je tokeny před uložením zašifrovat. Nepomůže to. Dešifrovací klíč musí žít ve stejném JavaScriptu, který útočník už spouští. Přidal jsi krok, ne hranici.
Druhý instinkt je přesunout tokeny do cookie na straně klienta. To taky nepomůže. Cookie nastavená JavaScriptem je JavaScriptem čitelná. Vlastnost, na které záleží, je HttpOnly, a nastavit ji umí jen server, hlavičkou Set-Cookie. Pokud tvoje oprava běží v prohlížeči, není to oprava.
Co jsme nasadili místo toho
Celou session jsme přesunuli do cookies nastavovaných serverem, vzorem token handler uvnitř backendu, který jsme už měli:
- FastAPI backend provádí volání na Cognito a vrací session jako cookies s
HttpOnly,SecureaSameSite. Prohlížeč token nikdy nevidí. - Auth middleware čte access token z cookie místo z hlavičky
Authorization. - Protože se cookies posílají automaticky, mutující požadavky teď nesou CSRF token v hlavičce, který backend ověřuje.
- Odhlášení provede global sign-out na straně Cognita, takže token, který někdy unikl, umře se session, místo aby ji přežil.
- Auth SDK je z prohlížeče úplně venku.
Nepotřebovali jsme na to novou službu. Naše statická aplikace a naše API jsou už teď na stejném originu: CloudFront servíruje aplikaci a route /api/* posílá na API Gateway. First-party cookie se tak posílá s každým voláním API bez jakékoli další infrastruktury.
Nejdřív jsme se dívali na cestu doporučenou vendorem, což by znamenalo převzít hostované přihlašovací UI, a zamítli jsme ji. Zahodila by naše vlastní obrazovky pro login, MFA a reset hesla kvůli problému, který jsme umíme vyřešit v existujícím backendu.
Co to stálo
Asi týden práce a žádnou viditelnou změnu pro uživatele. To je upřímný souhrn většiny bezpečnostní práce a přesně proto se tyhle věci ve startupu odkládají do nekonečna. Na roadmapě se nic nepohne. Žádný zákazník o to neřekne. Změní se jen to, co s tvými uživateli dokáže udělat jediný injektovaný skript.
Jeden praktický detail, který nasazení zbezpečnil: během rolloutu backend pořád přijímal starou cestu přes hlavičku, takže revert frontend deploye byl rollback na jeden krok, kdyby se cokoli pokazilo.
Zkontroluj si vlastní aplikaci. Zabere to asi 30 sekund.
Otevři svoji aplikaci, přihlas se a otevři DevTools:
- Záložka Application, Local Storage. Hledej dlouhé hodnoty se dvěma tečkami. Tenhle tvar je JWT.
- Ve stejném panelu zkontroluj Session Storage. Stejný problém, kratší životnost.
- V konzoli napiš
document.cookie. Cokoli se tam objeví, je z definice čitelné JavaScriptem, takže session cookie, kterou v tom výpisu vidíš, tě nechrání.
Pokud token najdeš, je to nález. Ne teoretický, a ne takový, na který by tě upozornil zelený audit závislostí nebo čistý běh statické analýzy.
Proč to publikujeme
Dva důvody, a ten druhý je o našem vlastním produktu.
První: defaulty jsou rozhodnutí, která za tebe udělal někdo jiný. Přijdou s knihovnou, jsou pohodlné a skoro nikdy nejsou threat model. Ten náš byl rozumný default pro tutoriál a špatný pro aplikaci, která drží platební data.
Druhý: scan je ukazatel, ne verdikt. Ten náš označil přihlašovací flow a dokázal, že je dosažitelné. Nepodal nám větu „tvoje auth knihovna má default localStorage a právě to je potřeba změnit". Cesta od nálezu k opravě znamenala otevřít si vlastní auth cestu a přečíst ji. Přesně tak očekáváme, že budeš používat i naše reporty: nález ti řekne, kam se podívat, a ukáže request, který to dokazuje, a ty se pak rozhodneš, co je správná oprava ve tvojí architektuře.
Takže: pusť scan na vlastní aplikaci a pak si přečti kód, na který ukazuje. Udělali jsme obojí a ani jedna polovina by sama nestačila.
Pokud chceš pohled na svoji aplikaci zvenku hned teď, náš bezplatný security check zabere asi 60 sekund a nechce registraci. Pokud chceš plný report s důkazy a opravami, první scan je za $29 a započítá se do plánu. A jestli chceš radši tu věc zdarma, kterou jsme popsali výš, otevři DevTools a podívej se do svého local storage. Ta nestojí nic.
