Przejdź do treści
CodeAuditAgent
Wszystkie artykuły

Pułapki bezpieczeństwa JWT i sesji oraz jak ich uniknąć

Błędy w JWT i sesjach prowadzące do przejęcia konta: pomylenie algorytmu, słabe sekrety, brak sprawdzania roszczeń, przechowywanie tokenów i rotacja.

· 7 min czytania · Lina Source LLC

JSON Web Tokeny to rozsądny format z długą listą ostrych krawędzi. Sam token rzadko bywa problemem. Błędy tkwią w tym, jak jest weryfikowany, gdzie jest przechowywany, jak długo żyje i co dzieje się, gdy użytkownik się wyloguje. Każda z tych pomyłek ma ten sam skutek: ktoś ma token, którego mieć nie powinien, a Twój serwer go akceptuje.

Dwie najczęściej pojawiające się słabości to CWE-347, nieprawidłowa weryfikacja podpisu kryptograficznego, oraz CWE-613, niewystarczające wygasanie sesji. Poniższe przykłady używają jose, popularnej biblioteki JavaScript do JWT, która działa w Node.js, środowiskach brzegowych i przeglądarkach.

Dekodowanie to nie weryfikacja

Najbardziej bezpośrednia postać CWE-347 to odczytanie roszczeń z tokena bez sprawdzenia jego podpisu. Każda biblioteka JWT ma funkcję dekodującą przeznaczoną do debugowania, a ta pojawia się w middleware uwierzytelniającym częściej, niż powinna. Zdekodowany token to po prostu base64, który każdy może napisać. W bazach kodu JavaScript ten wzorzec często kryje się w małej funkcji pomocniczej, która dzieli token po kropkach i uruchamia JSON.parse na środkowej części. Działa w każdym teście, bo tokeny testowe są prawidłowe, i przyjmuje każdy sfałszowany token na produkcji.

import { decodeJwt, jwtVerify } from 'jose';

// Podatne: każdy może wygenerować token z sub ustawionym na dowolne ID użytkownika
const claims = decodeJwt(token);
const userId = claims.sub;

// Poprawnie: najpierw sprawdzany jest podpis, algorytm i roszczenia
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;

alg none i pomylenie algorytmu

Nagłówek JWT mówi, który algorytm podpisał token, a nagłówek kontroluje atakujący. Z zaufania mu wynikają dwa klasyczne ataki. Pierwszy to alg ustawione na none: niepodpisany token, który część starszych bibliotek uznawała za prawidłowy. Drugi to pomylenie algorytmu: serwerowi, który oczekuje RS256, ale pozwala nagłówkowi wybrać algorytm, można podsunąć token HS256 podpisany kluczem publicznym serwera użytym jako sekret HMAC. Klucz publiczny jest publiczny, więc atakujący może podpisać cokolwiek.

Nowoczesne biblioteki bronią się przed obydwoma atakami, a jose odrzuca niezabezpieczone tokeny w jwtVerify i sprawdza, czy typ klucza pasuje do algorytmu. Nie polegaj jednak wyłącznie na ustawieniach domyślnych. Przypnij listę algorytmów jawnie przy każdym wywołaniu weryfikacji, żeby przyszła refaktoryzacja albo zmiana biblioteki nie mogła jej po cichu poszerzyć. Jeśli przyjmujesz więcej niż jeden algorytm, na przykład podczas migracji kluczy, wymień oba jawnie i użyj osobnego klucza dla każdego z nich. Przy rotacji kluczy umieść w nagłówku kid i wybieraj klucz po kid z własnej listy, nigdy z adresu URL wskazanego w nagłówku tokena.

Słabe sekrety podpisujące

Token HS256 jest podpisany współdzielonym sekretem. Jeśli sekret jest krótki lub łatwy do odgadnięcia, jak „secret”, nazwa aplikacji czy wartość skopiowana z poradnika, atakujący dysponujący jednym prawidłowym tokenem może złamać go offline zwykłymi narzędziami, a potem podpisywać tokeny dla dowolnego użytkownika. Zgadywanie offline nie ma żadnego limitu prób.

  • Używaj co najmniej 32 losowych bajtów dla HS256, wygenerowanych na przykład przez openssl rand -base64 32.
  • Wczytuj sekret ze środowiska lub z menedżera sekretów i przerwij start aplikacji, jeśli go brakuje albo jest za krótki.
  • Nigdy go nie commituj, a jeśli kiedykolwiek został zacommitowany, zrotuj go.
  • Jeśli kilka usług musi weryfikować tokeny, ale tylko jedna powinna je wystawiać, użyj algorytmu asymetrycznego, takiego jak RS256 lub EdDSA, żeby weryfikujący mieli wyłącznie klucz publiczny.

Waliduj exp, aud i iss

Prawidłowy podpis dowodzi tylko tego, kto wystawił token. To roszczenia decydują, czy jest przeznaczony dla Ciebie i czy nadal jest aktualny. Token bez terminu ważności jest ważny na zawsze. Token wystawiony dla Twojego API mobilnego nie powinien być przyjmowany przez Twoją usługę administracyjną tylko dlatego, że obie ufają temu samemu dostawcy tożsamości. Do tego właśnie służą aud i iss.

import { SignJWT, jwtVerify } from 'jose';

const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
  throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);

const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';

export function signAccessToken(userId: string, tokenVersion: number) {
  return new SignJWT({ tv: tokenVersion })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('10m')
    .sign(secret);
}

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],
    issuer: ISSUER,
    audience: AUDIENCE,
    requiredClaims: ['exp', 'sub'],
  });
  return payload;
}

jose sprawdza exp zawsze, gdy jest obecne, ale token bez exp przeszedłby weryfikację. Opcja requiredClaims zamyka tę lukę. Tokeny od zewnętrznego dostawcy tożsamości weryfikuj wobec jego opublikowanego zestawu kluczy przez createRemoteJWKSet i mimo to przypinaj algorytmy, wystawcę i odbiorcę.

localStorage czy ciasteczka httpOnly

Przechowywanie tokena w localStorage sprawia, że może go odczytać dowolny skrypt na stronie. Wystarczy jeden błąd XSS albo jeden skompromitowany skrypt innej firmy i token można wysłać atakującemu oraz używać go skądkolwiek aż do wygaśnięcia.

Ciasteczka httpOnly nie mogą być odczytane przez JavaScript. XSS wciąż jest poważny, bo wstrzyknięty skrypt może wykonywać żądania jako użytkownik, dopóki strona jest otwarta, ale nie może wykraść długowiecznego poświadczenia i odtworzyć go później. Dla aplikacji przeglądarkowych rozmawiających z własnym backendem ciasteczka są lepszym domyślnym wyborem. Dla aplikacji jednostronicowej wywołującej osobne API rozsądnym kompromisem jest trzymanie tokena dostępu wyłącznie w pamięci, a tokena odświeżającego w ciasteczku httpOnly.

// Express: ciasteczko sesji z bezpiecznymi atrybutami
res.cookie('__Host-session', accessToken, {
  httpOnly: true, // nieodczytywalne z JavaScriptu
  secure: true, // tylko HTTPS; wymagane przez przedrostek __Host-
  sameSite: 'lax', // niewysyłane przy międzywitrynowych żądaniach POST
  path: '/', // wymagane przez przedrostek __Host-
  maxAge: 10 * 60 * 1000, // milisekundy, zgodnie z czasem życia tokena
});

Przedrostek __Host- mówi przeglądarce, żeby odrzuciła ciasteczko, jeśli nie ma atrybutu Secure, nie ma path ustawionego na / albo ma atrybut Domain, co powstrzymuje skompromitowaną subdomenę przed nadpisaniem go.

SameSite i czego nie obejmuje

SameSite=Lax blokuje ciasteczko przy międzywitrynowych żądaniach POST i przy wczytywaniu podzasobów, co eliminuje większość klasycznego CSRF. Nadal wysyła ciasteczko przy nawigacjach GET najwyższego poziomu, więc każdy endpoint GET zmieniający stan jest narażony. Strict blokuje także te przypadki, ale jednocześnie wylogowuje użytkowników, gdy trafiają do Twojej aplikacji z linku w e-mailu lub na innej stronie. None wyłącza ochronę i wymaga Secure.

Lax plus zasada, że żądania GET nigdy nie zmieniają stanu, to solidna baza. Przy wrażliwych mutacjach dodaj sprawdzenie nagłówka Origin albo token CSRF. Pamiętaj, że SameSite traktuje wszystkie subdomeny Twojej domeny rejestrowalnej jako tę samą witrynę, więc podatna subdomena wciąż może fałszować żądania.

Rotacja, unieważnianie i prawdziwe wylogowanie

Bezstanowego JWT nie da się unieważnić przed jego wygaśnięciem; taka jest cena za nieodpytywanie bazy danych przy każdym żądaniu. CWE-613 opisuje, co się dzieje, gdy ten kompromis się zignoruje: wylogowania, zmiany haseł i zawieszenia kont, które w rzeczywistości nie kończą dostępu. Usunięcie konta, zmiana roli i usunięcie kogoś z zespołu to również zdarzenia unieważniające i każde z nich powinno zadziałać przy następnym żądaniu, a nie przy następnym wygaśnięciu tokena.

  • Trzymaj krótki czas życia tokenów dostępu, w zakresie minut, a nie dni.
  • Przechowuj tokeny odświeżające po stronie serwera, w formie haszowanej, i rotuj je przy każdym użyciu.
  • Jeśli token odświeżający, który został już zrotowany, zostanie użyty ponownie, potraktuj to jako kradzież i unieważnij całą rodzinę tokenów.
  • Dodaj wersję tokena do wiersza użytkownika i do samego tokena; zwiększaj ją przy wylogowaniu wszędzie, zmianie hasła lub zawieszeniu konta.
  • Generuj identyfikator sesji na nowo przy logowaniu, żeby zapobiec utrwaleniu sesji.
export async function requireUser(token: string) {
  const payload = await verifyAccessToken(token);

  const user = await db.user.findUnique({
    where: { id: payload.sub },
    select: { id: true, tokenVersion: true, disabled: true },
  });

  // Zwiększona wersja lub wyłączone konto natychmiast kończą dostęp
  if (!user || user.disabled || user.tokenVersion !== payload.tv) {
    throw new Error('Session revoked');
  }
  return user;
}

Ten odczyt przywraca jedno zapytanie do bazy danych na żądanie, co jest uczciwą ceną unieważniania. Wiele aplikacji jest prostszych i bezpieczniejszych z nieprzezroczystym identyfikatorem sesji w ciasteczku i tabelą sesji. Wylogowanie oznacza wtedy usunięcie wiersza. Używaj JWT tam, gdzie ich właściwości faktycznie pomagają, na przykład jako krótkożyciowych tokenów między usługami, a nie dlatego, że są domyślne w jakimś poradniku.

Cokolwiek wybierzesz, wylogowanie musi dziać się na serwerze. Wyczyszczenie ciasteczka albo usunięcie tokena w przeglądarce usuwa tylko kopię użytkownika; wykradziona kopia działa dalej, dopóki serwer jej nie odrzuci. Przy wylogowaniu usuń sesję po stronie serwera lub token odświeżający, wyczyść ciasteczko, używając tej samej nazwy, ścieżki i atrybutów, z jakimi zostało ustawione, i zwiększ wersję tokena, jeśli użytkownik wybrał wylogowanie ze wszystkich urządzeń. Reset hasła powinien też zakończyć wszystkie pozostałe sesje.

Krótka lista kontrolna do przeglądu

  • Poszukaj wywołań dekodujących w ścieżkach uwierzytelniania.
  • Potwierdź, że każde wywołanie weryfikacji przypina algorytmy, wystawcę i odbiorcę oraz wymaga exp.
  • Sprawdź, jak sekret podpisujący jest generowany, wczytywany i walidowany przy starcie.
  • Znajdź miejsca, w których tokeny są przechowywane w przeglądarce.
  • Przetestuj, czy wylogowanie, zmiana hasła i zawieszenie konta kończą istniejące sesje.

Te sprawdzenia polegają głównie na przeczytaniu ścieżek kodu od początku do końca, czyli dokładnie tak, jak do audytu podchodzi CodeAuditAgent: znaleziska przychodzą z zacytowaną linią, CWE, scenariuszem ataku i poprawką, więc brakujące sprawdzenie odbiorcy można potwierdzić i naprawić w kilka minut.