Cookies: parametry, niuanse i podpisywanie
Ostatnio omówiliśmy sobie podstawy działania cookies i nauczyliśmy się ich używać w naszej aplikacji z wykorzystaniem biblioteki cookie-parser. Na tej lekcji zajmiemy się bardziej zaawansowanymi zagadnieniami związanymi z cookies a także nauczymy się podpisywać ich zawartość.
Wideo towarzyszące
Parametry cookies
Na poprzedniej lekcji powiedzieliśmy sobie o parametrach Max-Age i Expires służących do przedłużania życia cookie poza czas trwania obecnej sesji przeglądarki albo do usuwania cookie. Aby przedłużyć czas życia cookie odpowiedzialnego w naszej aplikacji za przechowanie zgody na używanie cookies możemy dodać następujący kod do naszej aplikacji:
| |
- W liniach 22 i 29 do ustawiania wartości cookie dodaliśmy też dodatkowy obiekt zawierający właściwości ustawianego cookie, w tym wypadku wyłącznie
maxAge. Dzięki temu nasze cookie będzie przechowywane w przeglądarce przez 30 dni (o ile użytkownik go ręcznie nie usunie) i po zamknięciu przeglądarki i ponownym otwarciu nasza strona nie będzie ponownie prosiła o zgodę na używanie cookie. - W liniach 57-61 dodajemy mechanizm ponownego ustawiania cookie
fisz-consentna tę samą wartość ale z czasem ważności przedłużonym znów o pełne 30 dni do przodu. Dzięki temu jeżeli użytkownik korzysta z naszej strony regularnie, jego zgoda będzie stale pamiętana przez naszą aplikację. Jeżeli użytkownik wejdzie na stronę po ponad miesiącu nieobecności, będzie po raz kolejny poproszony o udzielenie zgody na korzystanie z cookies.
Ponieważ przenieśliśmy middleware ustawiający wartość zmiennej res.locals do modułu models/settings.js musimy pamiętać o skonfigurowaniu użycia go w naszej aplikacji.
| |
HttpOnly
Cookies domyślnie są dostępne w przeglądarce nie tylko z poziomu narzędzi deweloperskich i czy ustawień użytkownika, ale także w kodzie JavaScript. Najprostszym mechanizmem dostępu do nich jest użycie właściwości obiektu document.cookie. Poniższy kod pokazuje, jak możemy podejrzeć wartości cookies oraz cofnąć zgodę na wykorzystanie cookies w naszej aplikacji:
console.log(document.cookies); // wydrukuje zawartość wszystkich dostępnych cookies w formie listy nazwa1=wartosc1; nazwa2=wartosc2
document.cookies = "fisz-consent=false" // ustawi wartosc cookie "fisz-consent" na false. Pozostale cookies nie zostana zmodyfikowane
Dzięki temu API możemy zarządzać naszymi cookies z poziomu JavaScript, co może znacząco usprawnić działanie aplikacji zdejmując część odpowiedzialności za ustawianie cookies przez serwer. Jeżeli w tym momencie pojawia się obawa, że użytkownik może w ten sposób zmienić zawartość cookie bez naszej wiedzy, to zalecam głęboki oddech. Użytkownik i tak w pełni kontroluje cookies w swojej przeglądarce, nie potrzebuje do tego dostępu z poziomu JavaScript. Ale jest inne potencjalne niebezpieczeństwo.
Cookies dostępne z poziomu JavaScript są potencjalnym zagrożeniem bezpieczeństwa jeżeli ktoś jest w stanie umieścić na naszej stronie złośliwy kawałek kodu JS i używając go przesłać zawartość cookies użytkownika do kontrolowanego przez siebie serwera. Nie jest to problem w przypadku zgody na użycie cookies czy ustawienie tematu kolorystycznego, ale jeżeli w cookies znajdzie się np. identyfikator sesji użytkownika (o tym na następnej lekcji), to wykradzenie tej informacji pozwoli na podszywanie się pod innych użytkowników aplikacji i zarządzanie ich zasobami. Dlatego dla każdego cookie jakie ustawiamy w naszej aplikacji warto się zastanowić czy potencjalne wydobycie tej informacji z komputera użytkownika (albo eksfiltracja danych) może być zagrożeniem bezpieczeństwa. Jeżeli tak, warto oznaczyć cookie jako HttpOnly.
Dodajmy tę opcję do naszego cookie przechowującego informację o ostatnio odwiedzanych kategoriach. Nie jest to najbardziej zasadne użycie tego atrybutu, ale nauczmy się jak możemy to zrobić:
| |
Sprawdź w narzędziach deweloperskich przeglądarki, czy faktycznie cookie jest niedostępne z poziomu JavaScript i czy nie możesz zmienić jego wartości używając konsoli JS.
Secure
Ważne Dodanie do cookie atrybutu Secure spowoduje, że cookie może być przesłane tylko przez bezpieczne połączenie TLS, czyli w uproszczeniu do serwerów obsługujących protokół https://. Wyjątek stanowi localhost, tutaj TLS nie jest wymagane co pozwala na lokalny development znacząco ułatwiając programistom znacząco życie. Ograniczenie przekazywania cookie do połączeń szyfrowanych uniemożliwia podglądnięcie ich zawartości przez innych użytkowników znajdujących się w tej samej sieci co nasz użytkownik, kiedy jego żądania HTTP będą podróżować przez sieć otwartym tekstem.
W dobie istnienia usługi LetsEncrypt, darmowych certyfikatów TLS i wszechobecnego wsparcia dla szyfrowania ruchu sieciowego nie ma żadnego powodu, żeby nasze cookies nie były oznaczone jako Secure. Wszelkie aplikacje jakie będziemy tworzyć powinny być serwowane przez TLS i nie ma najmniejszego sensu korzystanie z cookies bez tego parametru. No dobrze, mogą się zdarzyć pewne przypadki brzegowe, ale na te problemy możemy się natknąć przy aplikacjach zupełnie innej skali niż te, nad którymi pracujemy.
Równie ważne Dodanie atrybutu Secure do cookie nie zapewnia żadnych innych zabezpieczeń. Cookie nadal może zostać zmodyfikowane przez użytkownika i nadal musimy je traktować jak dane pochodzące z zewnątrz. Przypominam: toksyczne, radioaktywne odpady.
Przedrostki cookies
Cookies mogą też posiadać przedrostki (ang. prefix) w swoich nazwach, co wymaga od przeglądarek przestrzegania pewnych zasad.
Jeżeli nazwa cookie zaczyna się od przedrostka __Host- takie cookie:
- Musi zostać ustawione z atrybutem
Secure - Musi pochodzić z URI uznanego za bezpieczne przez przeglądarkę
- Nie może posiadać zdefiniowanego atrybutu
Domain - Musi być ustawione na ścieżkę
/
Jeżeli nazwa cookie zaczyna się od przedrostka __Secure- takie cookie:
- Musi zostać ustawione z atrybutem
Secure - Musi pochodzić z URI uznanego za bezpieczne przez przeglądarkę
Nie są to mocne zabezpieczenia i nadal nie zapewniają, że zawartość cookie nie zostanie zmodyfikowana przez użytkownika, ale jest to zawsze dodatkowa warstwa zabezpieczania naszych cookie.
Path
Path pozwala na ograniczenie wysyłania cookies tylko dla wybranych ścieżek. Jeżeli mamy wydzielone pod-ścieżki z konkretnymi funkcjonalnościami wymagającymi cookies, które są zbędne w reszcie aplikacji, dodanie do nich atrybutu Path jest dobrym pomysłem, bo w ten sposób nie będą dołączane do niezwiązanych żądań. Co do zasady im krótsze są żądania do naszego serwera, tym szybciej będzie działała nasza aplikacja.
Inne atrybuty i ustawienia cookies
Nie poruszyliśmy tematu wszystkich atrybutów, jak np. SameSite pozwalające na ograniczenie wysyłania cookies w zależności od tego, czy przeglądarka wysyła żądanie powiązane z aktualnie odwiedzaną domeną i jeszcze do tego atrybutu wrócimy w jednej z przyszłych lekcji. Po pozostałe informację odsyłam do przewodnika MDN poświęconego cookies.
Podpisywanie cookies
Jeżeli chcemy być pewni, że zawartość naszego cookie nie została zmieniona przez użytkownika i rzeczywiście pochodzi z naszego serwera możemy sięgnąć po narzędzia kryptograficzne, a konkretnie po podpisywanie cookie.
Co znaczy “podpis” w kontekście danych?
Załóżmy że robimy turową strategiczną grę online, w której zasoby użytkownika przechowujemy w takim cookie zawierającym obiekt w formacie JSON:
zasoby={zloto:100, drewno:20, kamien:13}Nie jest to prawdopodobnie dobry projekt, ale potraktujmy to jako przykład dydaktyczny. Nasz użytkownik może dowolnie zmodyfikować cookie w swojej przeglądarce, więc w następnym żądaniu od użytkownika możemy dostać dowolne wartości i pozostanie nam je przyjąć jako prawdziwe. Co możemy z tym zrobić? Możemy do naszych danych dodać podpis wykorzystując kryptograficzną technikę HMAC.
Nie jestem ekspertem od kryptografii, nie wytłumaczę wszystkich niuansów z nią związanych, to co tutaj podaję to moja wiedza zdobyta poprzez czytanie informacji od ekspertów od kryptografii i bezpieczeństwa.
W dużym skrócie: wyobraźmy sobie funkcję HMAC przyjmującą “sekret” oraz dowolny zbiór danych. Ta funkcja przetwarza te dwie połączone informacje na dużą, pozornie losową liczbę. Ta transformacja ma kilka właściwości:
- niewielka zmiana w danych wejściowych powinna spowodować znaczącą zmianę w wynikowej liczbie
- na podstawie uzyskanego wyniku nie da się odtworzyć danych wejściowych
- te same dane wejściowe zawsze dadzą ten sam wynik
Gdybyśmy mieli opisać proces podpisywania kodem z pominięciem szczegółów działania samego algorytmu HMAC, to moglibyśmy napisać to w ten sposób:
// Podpisywanie
let dane = "to jest zawartosc ktora bedzie podpisana";
let sekret = "$uper_t4jny_c1ag_znak0w_1337@#!";
let podpis = HMAC(dane, sekret);
let cookie = `${dane}.${podpis}`;
// Weryfikacja
let [dane_weryfikowane, podpis_weryfikowany] = cookie.split('.');
let podpis_prawidlowy = HMAC(dane_weryfikowane, sekret);
if (podpis_prawidlowy === podpis_weryfikowany) {
console.log("Dane zweryfikowane poprawnie")
}Tak długo, jak sekret pozostaje znany tylko dla naszego serwera, użytkownicy nie będą w stanie wygenerować poprawnego podpisu dla danych, jeżeli je zmodyfikują.
Node.js udostępnia dość bogaty moduł kryptograficzny w oparciu o bibliotekę OpenSSL. Na szczęście nie będziemy musieli korzystać z tego dobrodziejstwa bezpośrednio ponieważ moduł cookie-parser już oferuje implementację która wykona za nas całą robotę. Aby móc skorzystać z tego dobrodziejstwa musimy zainicjalizować cookie-parser sekretem, który będzie używany do podpisywania i weryfikacji cookies.
Sekret musi być stringiem i najlepiej żeby był długim ciągiem możliwie różnorodnych znaków. Możemy wykorzystać narzędzia linuksowe do wygenerowania dla nas takiego zestawu znaków:
> cat /dev/random | tr -cd "[:graph:]" | head -c64 ; echoRozbijając to na części pierwsze:
- Komenda
cat /dev/randomwypisuje losowe znaki na konsolę tr -cd "[:graph:]"filtruje znaki przepuszczając tylko te, które mają graficzną reprezentacjęhead -c64ogranicza wynik do pierwszych 64 znaków;rozdziela komendy aechopowoduje przejście do nowej linii
Ograniczenie sekretu do typu string jest pewnym problemem z punktu widzenia bezpieczeństwa i uważam, że jest to błąd w implementacji cookie-parser. Nie jest to rażący błąd i wynika po części z faktu, że moduł został napisany dość dawno, kiedy Node.js jeszcze nie oferowało najlepszego interfejsu do funkcji kryptograficznych. Problem polega na tym, że w przypadku stringów większość narzędzi na nich operujących może nie obsługiwać poprawnie wielu znaków, przez co tworząc sekrety ograniczamy się do mniejszego podzbioru możliwych wartości, potencjalnie ułatwiając złamanie tego zabezpieczenia. W praktyce może mieć to marginalne znaczenie.
Przechowywanie sekretów i środowisko wykonania
Mamy już losowy ciąg znaków który może nam posłużyć jako sekret do podpisywania danych, ale nie chcemy umieszczać go w kodzie. Sekrety nazywamy tak ponieważ dokładnie tym powinny pozostać, sekretami. Umieszczenie ich w kodzie a potem udostępnienie w publicznym repozytorium kompletnie przekreśla ideę sekretu. Nawet jeżeli nasze repozytorium będzie prywatne, istnieje niezerowa szansa, że kod naszej aplikacji kiedyś zostanie udostępniony albo zwyczajnie wycieknie. Nigdy nie przechowuj potencjalnie wrażliwych danych w repozytorium w otwartym tekście.
Dodawanie sekretnego klucza do naszej aplikacji
Skoro nie chcemy przechowywać sekretów w repozytorium, to gdzie je umieścić? Proponuję w tzw. pliku środowiskowym (ang env file).
Plik środowiskowy to zbiór nazw i przypisanych do nich wartości w formie:
ZMIENNA=wartosc
INNA_ZMIENNA=inna wartoscNode.js zapewnia wbudowane wsparcie dla takich plików i już wcześniej korzystaliśmy ze zmiennych środowiskowych w celu zmiany zachowania naszej aplikacji.
Stwórzmy sobie narzędzie, które wygeneruje dla nas plik środowiskowy:
| |
Aby móc uruchomić nasze narzędzie w systemie Linux musimy dodać do niego flagę pliku wykonywalnego przy pomocy narzędzia chmod
> chmod a+x utils/generate_env.sh
> utils/generate_env.sh > .envTeraz możemy uruchomić naszą aplikację dodając dodatkową flagę --env-file do polecenia node
> node --watch --env-file .env ./index.jsWykluczenie pliku środowiskowego z systemu kontroli wersji
Skoro nie chcemy pliku .env w repozytorium pamiętaj o dodaniu go do listy ignorowanych plików w .gitignore.
Podpisywanie cookie przy pomocy cookie-parser
Mamy już sekret przechowany w bezpieczny sposób w zmiennych środowiskowych, co teraz? Aby wykorzystać podpisywanie cookie w naszej aplikacji dokonajmy następujących zmian w pliku index.js.
1import express from "express";
2import morgan from "morgan";
3import cookieParser from "cookie-parser";
4
5import flashcards from "./models/flashcards.js";
6import settings from "./models/settings.js";
7
8const port = process.env.PORT || 8000;
9const LAST_VIEWED_COOKIE = "__Host-fisz-last-viewed";
10const ONE_DAY = 24 * 60 * 60 * 1000;
11const ONE_MONTH = 30 * ONE_DAY;
12const SECRET = process.env.SECRET;
13
14if (SECRET == null) {
15 console.error(
16 "SECRET environment variable missing. Please create an env file or provide SECRET via environment variables."
17 );
18 process.exit(1);
19}
20
21const app = express();
22app.set("view engine", "ejs");
23app.use(express.static("public"));
24app.use(express.urlencoded());
25app.use(morgan("dev"));
26app.use(cookieParser(SECRET));
27
28app.use(settings.settingsHandler);
29
30const settingsRouter = express.Router();
31settingsRouter.use("/toggle-theme", settings.themeToggle);
32settingsRouter.use("/accept-cookies", settings.acceptCookies);
33settingsRouter.use("/decline-cookies", settings.declineCookies);
34settingsRouter.use("/manage-cookies", settings.manageCookies);
35app.use("/settings", settingsRouter);
36
37app.get("/", (req, res) => {
38 var last_viewed_categories = null;
39 if (res.locals.app.cookie_consent && req.signedCookies[LAST_VIEWED_COOKIE]) {
40 let last_viewed = req.signedCookies[LAST_VIEWED_COOKIE] || [];
41 last_viewed_categories = last_viewed
42 .map((x) => parseInt(x, 10))
43 .filter((x) => !isNaN(x))
44 .map((id) => flashcards.getCategorySummary(id));
45 }
46 res.render("categories", {
47 title: "Kategorie",
48 categories: flashcards.getCategorySummaries(),
49 last_viewed_categories,
50 });
51});
52
53app.get("/view/:category_id", (req, res) => {
54 const category = flashcards.getCategory(req.params.category_id);
55 if (category != null) {
56 if (res.locals.app.cookie_consent) {
57 let last_viewed_dirty = req.signedCookies[LAST_VIEWED_COOKIE] || [];
58 let last_viewed = [
59 category.category_id,
60 ...last_viewed_dirty
61 .map((x) => parseInt(x, 10))
62 .filter((x) => !isNaN(x) && x !== category.category_id)
63 .slice(0, 2),
64 ];
65 res.cookie(LAST_VIEWED_COOKIE, last_viewed, {
66 httpOnly: true,
67 secure: true,
68 maxAge: ONE_MONTH,
69 signed: true,
70 });
71 }
72 res.render("category", {
73 title: category.name,
74 category,
75 });
76 } else {
77 res.sendStatus(404);
78 }
79});
80
81// ...
Zmiany są dość rozsiane, ale nie ma ich tak wiele:
- W liniach 8 i 12 korzystamy z naszych zmiennych środowiskowych do ustawienia portu nasłuchiwania serwera i sekretu do podpisu.
- W liniach 14-19 upewniamy się, że sekret był ustawiony na jakąś wartość. W przeciwnym wypadku kończymy działanie programu ze stosownym komunikatem błędu.
- W linii 26 podaję do
cookie-parsersekret którego ma używać do podpisywania i weryfikacji cookie. - W liniach 39-40 oraz 57 musimy zmienić pole z którego odczytujemy cookie z
cookiesnasignedCookies. Zmieniłem też sposób przechowywania wartości ze stringu na tablicę JSON, o tym za moment. - W linii 65 ustawiam wartość cookie na całą tablicę zamiast przerabiania jej na string oddzielony przecinkami.
cookie-parserw tym wypadku użyje wbudowanych w JavaScript funkcji do serializacji obiektu na string JSON z drobną adnotacją, która pozwoli na odwrócenie tego procesu podczas przetwarzania przychodzących cookie. Ułatwia to trochę manipulację danymi, ale przede wszystkim chodzi o interfejs podpisanych cookie. W przypadku błędu weryfikacji podpisucookie-parserustawia wartość cookie nafalse. Jeżeli użyję jako wartości cookie obiektu albo tablicy JavaScript, to wiem że w przypadku nawet pustego obiektu, warunekif (req.signedCookies["some-cookie"])będzie spełniony, jeżeli tylko podpis się zgadza. - W linii 69 dodaję do opcji cookie pole
signedustawione natrue, co każecookie-parserpodpisać ciasteczko wykorzystując podany wcześniej sekret.
Replay attacks czyli przed czym podpis nas nie ochroni
Jak napisałem powyżej, HMAC z definicji dla tych samych danych wejściowych zawsze zwróci taki sam podpis. Co oznacza że jeżeli nasz użytkownik zdobędzie podpisane cookie o pożądanych wartościach z prawidłowym podpisem, to może podmienić obecną zawartość cookie na poprzednią. Ponowne używanie zapisanych poprzednich wartości jest nazywane replay attack.
Jak temu zapobiegać? W przypadku naszego dydaktycznego przykładu z turową grą strategiczną moglibyśmy dołożyć do cookie pole z licznikiem aktualnej tury. W takim przypadku nawet jeżeli użytkownik chciałby oszukać system i po wydaniu zasobów przywrócić wartości z jednej z poprzednich tur łatwo możemy wykryć podstęp i odrzucić cookie jako przedawnione mimo prawidłowego podpisu. W przypadku innych podpisanych cookies często dokładane jest pole mówiące o tym, kiedy cookie zostało podpisane i jak długo pozostaje ważne. Jeżeli wystawimy użytkownikowi cookie ważne kilka minut, to jego przydatność do ataków typu replay attack znacząco spada.
Dodawanie skryptów pomocniczych do npm
Ponieważ nasza biblioteka narzędzi w folderze utils powoli rośnie i komenda do uruchamiania aplikacji stała się trochę żmudna do wpisywania, możemy uprościć sobie życie definiując odpowiednie komendy w pliku package.json:
| |
W liniach 8-10 definiujemy sub-komendy, które teraz możemy wykonywać w terminalu w formie npm run <komenda>. Uruchomienie naszego serwera to teraz po prostu
> npm run dev