Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book
Wykorzystanie CloudFlare Workers do zabezpieczenia dostępu do aplikacji webowej
W tym artykule pokażę jedno z możliwych rozwiązań następującego problemu: mamy aplikację webową, której nie chcemy wystawiać na świat, a tylko umożliwić do niej dostęp określonej grupie użytkowników. Kilka przykładowych, tradycyjnych rozwiązań to:
- Użycie tzw. basic authentication. Jeśli jednak aplikacja korzysta dalej z nagłówka
Authorization
do przekazywania tokenów, to jedno rozwiązanie z drugim może się gryźć. - Wystawienie aplikacji przez VPN. Polityka organizacji może jednak mocno ograniczać możliwość tworzenia kont użytkowników dla osób spoza organizacji (jeśli i im chcemy aplikację udostępnić).
- Wykorzystanie modułów w popularnych webserwerach (np. w nginx), które umożliwiają podejmowanie decyzji dotyczących umożliwienia dostępu do zasobów na podstawie odpowiedzi innych usług.
Sami zmierzyliśmy się ostatnio z tym problemem – rozwijamy właśnie nową wersję sklepu Sekuraka i chcieliśmy wersję testową udostępnić tylko pewnej grupie osób, które będą znały kod. Jako że wykorzystujemy CloudFlare jako DNS, postanowiliśmy skorzystać z rozwiązania CloudFlare Workers.
Czym są CloudFlare Workers?
Workers to rozwiązanie umożliwiające napisanie własnego kodu JavaScript obsługującego żądania HTTP/S. Typowe więc podejście serverless. Możemy je skonfigurować w taki sposób, że kod workera będzie niejako serwerem typu proxy, który będzie podejmował decyzję, czy podesłać żądanie dalej do serwera origin, czy też w inny sposób obsłużyć to żądanie. Z drugiej strony kod workera nie musi w żaden sposób odwoływać się do serwera origin – możemy w nim po prostu zaimplementować API. Jedyny warunek jest taki, że workery mają dość ograniczony czas wykonywania – 10ms dla wersji bezpłatnej i 50ms dla wersji płatnej.
Przykładowy kod w Workers
Przejdźmy jednak od razu do działania – zobaczmy przykład prostego workera, który wyciągnie wszystkie parametry z zapytania HTTP i wyświetli je w HTML-u.
Pierwsza istotna kwestia jest taka, że kod JS pisany w workerach nie jest pisany w expressie (czyli popularnym frameworku NodeJS do pisania API), tylko wykorzystuje Web Workers API. JS który będziemy pisać będzie więc bardzo mocno przypominać kod pisany w przeglądarkach.
Aby przejść do pisania workerów, należy kliknąć „Workers” w ekranie głównym domeny na Cloudflarze (widoczne na rysunku 1).
W kolejnym ekranie skorzystajmy z opcji „Create a Worker” (rys 2.).
Kolejny ekran zawiera edytor kodu JS oraz konsolę przypominającą tę z przeglądarki Chrome. Mamy też podstawowy kod pozwalający na obsługę żądań HTTP (rys 3.).
Jak widać na rysunku 3, worker który właśnie utworzyłem jest dostępny pod domeną https://muddy-union-aaf1.mibe.workers.dev; w lewym górnym rogu istnieje możliwość zmiany nazwy na własną.
Tak jak pisałem wyżej, napiszemy prostą aplikację, która wydobędzie z adresu URL obecnej strony wszystkie parametry zapytania i wyświetli je w wynikowym HTML-u. Możemy do tego użyć prostego kodu jak poniżej:
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
/**
* Respond to the request
* @param {Request} request
*/
async function handleRequest(request) {
// Przetwarzamy URL zapytania i wyciągamy z niego parametry
const params = new URL(request.url).searchParams
// W zmiennej `html` będziemy budować wynikowy HTML.
let html = '<!doctype><meta charset=utf-8><ul>';
// Iterujemy po nazwach i wartościach wszystkich parametrów
for (const [param, value] of params.entries()) {
html += `<li>${htmlEscape(param)}: ${htmlEscape(value)}</li>`
}
html += '</ul>'
// Zwracamy odpowiedź z odpowiednim nagłówkiem Content-type
return new Response(html, {
headers: {
'Content-type': 'text/html; charset=utf-8'
}
});
}
/**
* Funkcja anty-XSS :)
*
* @param {string} s
* @returns {string}
*/
function htmlEscape(s) {
const repl = {
"&": "&",
"<": "<",
'"': """,
}
return s.replace(/[&<"]/g, c => repl[c] || c);
}
Po kliknięciu „Save & deploy” możemy potwierdzić, że worker spełnia swoje zadanie:
https://muddy-union-aaf1.mibe.workers.dev/?x=sekurak&y=super
Wykorzystanie workera jako proxy w aplikacji
Po krótkim wstępie przejdźmy zatem do właściwego zadania. Celem jest napisanie workera, który będzie pełnił rolę proxy i dawał dostęp do aplikacji tylko tym użytkownikom, którzy będą w posiadaniu współdzielonego kodu.
Ogólnie koncepcję napisania tego workera można zawrzeć w kilku punktach:
- Worker będzie miał zdefiniowane współdzielone kody dostępowe wraz z ich datą ważności,
- Jeśli zapytanie http będzie zawierało ciasteczko z ważnym kodem, użytkownik zostanie przekierowany do serwera origin,
- W przeciwnym wypadku użytkownikowi zostanie wyświetlony formularz z prośbą o podanie kodu dostępowego,
- Formularz będzie wysyłał zapytanie POST z kodem dostępowym; jeśli kod będzie poprawny – zostanie ustawione ciasteczko i użytkownik zostanie przekierowany do właściwej aplikacji.
By jednak worker w ogóle działał z naszą aplikacją, musimy go najpierw skonfigurować w taki sposób, by przechwytywał ruch idący do serwera. W tym celu najpierw upewnijmy się, że w konfiguracji DNS CloudFlare ruch jest ustawiony jako „proxied”:
Następnie w zakładce „Workers” musimy zdefiniować reguły, dla których worker ma być używany. W moim przypadku chcę żeby cały ruch do domeny sklep-ng-test.sekurak.pl
był przechwytywany przez worker o nazwie sklep-ng-auth
.
Po takiej konfiguracji możemy już przejść do napisania kodu samego workera. Będę poniżej omawiał pisanie tego kodu krok po kroku wraz z małymi fragmentami kodu.
Po pierwsze musimy zdefiniować nazwę ciasteczka, którego będziemy chcieli używać, oraz kody wraz z ich datą ważności. Dodatkowo zdefiniujemy też główną metodę odbierającą zdarzenie fetch
:
const COOKIE_NAME = '__Host-magic-cookie';
/** @type {{[key: string]: Date}} */
const CODES = {
'bardzo-tajny-kod': new Date(2021, 5, 30)
}
addEventListener("fetch", ev => {
ev.respondWith(handle(ev.request))
})
@type
) określające typy zmiennych, parametrów funkcji i ich wartości zwracanych. Te komentarze w żadnym wypadku nie są konieczne ale bardzo usprawniają działanie automatycznego uzupełniania w edytorze, więc pisanie kodu staje się dzięki temu przyjemniejsze. Ergo – zachęcam do ich dodawania. Ustawiłem zatem nazwę ciasteczka, zdefiniowałem kod wraz z datą ważności (tutaj uwaga: miesiące indeksowane od zera, zatem datą ważności tokenu jest 30 czerwca 2021, a nie 30 maja!) oraz przekazuję sterowanie do funkcji handle
w przypadku otrzymania zapytania.
Funkcja handle
będzie musiała obsłużyć trzy przypadki:
- Jeśli użytkownik ma ważne ciasteczko z kodem, jest przekierowywany do serwera origin,
- Jeśli użytkownik wykonał zapytanie POST, sprawdzamy czy podał w nim właściwą wartość kodu.
- W przeciwnym razie, zwracamy stronę z formularzem.
Implementacja może więc wyglądać następująco:
/**
* Główna funkcja do obsługi zapytań
*
* @param {Request} request
* @returns {Promise<Response>}
*/
function handle(request) {
// Jeśli użytkownik ma właściwe ciasteczko, przekazujemy
// zapytanie do serwera origin
if (hasValidCookie(request)) {
return fetch(request);
}
// Jeśli wykonano zapytanie POST, obsługujemy je w szczególny
// sposób.
if (request.method === 'POST') {
return handlePost(request);
}
// W przeciwnym wypadku zwracamy główną stronę z formularzem.
return index();
}
Zaimplementujemy teraz po kolei kolejne funkcje. Funkcja hasValidCookie
musi zweryfikować, czy w zapytaniu istnieje w ogóle nagłówek Cookie
. Jeśli go nie ma, to od razu możemy stwierdzić, że użytkownik nie powinien mieć dostępu do aplikacji. Następnie będziemy musieli przetworzyć wartość tego nagłówka.
Przykładowy nagłówek Cookie może wyglądać następująco:
Cookie: COOKIE1=VALUE1; COOKIE2=VALUE2
Mamy zatem poszczególne ciasteczka oddzielone średnikiem, a następnie ciasteczka i wartości oddzielone znakiem równości. Prosi się zatem, by wykonać tutaj dwie operacje split – najpierw po ;
, a potem po =
. W efekcie dostaniemy tablicę składającą się z dwuelementowych tablic z nazwami i wartościami ciasteczek. Przykładowo, z powyższych ciasteczek dostalibyśmy:
[["COOKIE1", "VALUE1"], ["COOKIE2", "VALUE2"]]
Wygodnie było teraz zamienić to na obiekt, w którym nazwy ciasteczek byłyby nazwami właściwości tego obiektu, a wartości ciasteczek – ich wartościami. Na szczęście od pierwszej połowy 2019 istnieje w standardzie JS funkcja Object.fromEntries, która taką zamianę wykona za nas. W efekcie dostaniemy obiekt:
{
COOKIE1: "VALUE1",
COOKIE2: "VALUE2"
}
Jeśli zatem przetworzymy ciasteczka w taki sposób, naturalnym następnym krokiem jest sprawdzenie, czy wśród nich istnieje ciasteczko o zdefiniowanej wcześniej nazwie __Host-magic-cookie
. Jeśli nie – od razu wiemy, że użytkownik nie ma dostępu. Jeśli istnieje, to ostatnim krokiem jest sprawdzenie czy jego wartość jest na liście kodów oraz czy ten kod jest ważny.
Całość zamyka się w następującym fragmencie JS:
/**
* Funkcja sprawdzająca, czy użytkownik ma ustawione ciasteczko,
* pozwalające na dostęp do serwisu.
*
* @param {Request} request
* @returns {boolean}
*/
function hasValidCookie(request) {
const cookie = request.headers.get('Cookie');
// Jeśli nagłówek nie jest ustawiony, od razu zwracamy fałsz.
if (!cookie) {
return false;
}
// Przetwarzamy ciasteczka do obiektu postaci:
// {"nazwa_ciasteczka": "wartosc"}
const cookies = Object.fromEntries(
cookie.split(/;\s*/).map(c => c.split('='))
);
// Sprawdzamy czy ustawiono magiczne ciasteczko.
const magicCookie = cookies[COOKIE_NAME];
if (!magicCookie) {
return false;
}
// Sprawdzamy, czy ciasteczko ma poprawny kod
const expiration = CODES[magicCookie];
if (!expiration) {
return false;
}
// Zwracamy prawdę, jeśli ciastko jest nadal ważne
return new Date() < expiration
}
Kolejną funkcją do zaimplementowania jest handlePost
. Musimy w niej pobrać treść parametrów przekazywanych w POST, poszukać tam parametru o nazwie code
(lub jakiejkolwiek innej, jaką sobie wymyślimy) i sprawdzić, czy jego wartością jest ważny kod. Jeśli tak jest, to w odpowiedzi musimy ustawić nagłówek Set-Cookie
z odpowiednią nazwą i wartością ciasteczka:
**
* Funkcja weryfikująca zapytanie POST (tj. zapytanie z podanym kodem)
*
* @param {Request} request
* @returns {Promise<Response>}
*/
async function handlePost(request) {
// Przygotowujemy obiekt z odpowiedzią. Na pewno będziemy przekierowywać,
// więc z góry ustawiamy status na 302 i nagłówek Location
const response = new Response('', {
status: 302,
headers: {
'Location': '/'
}
})
// Pobieramy parametry przekazane w zapytaniu
const params = new URLSearchParams(await request.text());
// Pobieramy parametr code
const code = params.get('code')
// Sprawdzamy czy kod jest poprawny i ważny.
/** @type {boolean} */
const validCode = (
code &&
CODES.hasOwnProperty(code) &&
new Date() < CODES[code]
)
// Jeśli mamy ważny kod, to ustawiamy ciasteczko
if (validCode) {
response.headers.set(
'Set-Cookie',
`${COOKIE_NAME}=${code}; Path=/; Secure; HttpOnly; Expires=${CODES[code].toGMTString()}`
)
}
return response;
}
W ostateczności pozostanie nam zaimplementowanie tylko funkcji index
, której jedyną rolą jest wyświetlenie HTML-a użytkownikowi, który nie ma ciasteczka z poprawnym kodem:
/**
* Funkcja zwracająca HTML-a umożliwiającego podanie hasła.
*
* @returns Promise<Response>
*/
async function index() {
const HTML_CONTENT = `<!doctype html><meta charset=utf-8>
<p>Dostęp kontrolowany. Podaj kod, by uzyskać dostęp do aplikacji.</p>
<form method=post enctype=application/x-www-form-urlencoded>
<label for=code>Kod: </label>
<input name=code id=code>
<button>Wyślij</button>
</form>
`;
const response = new Response(HTML_CONTENT, {
headers: {
'Content-type': 'text/html; charset=utf-8'
}
});
return Promise.resolve(response);
}
Wynik działania
Po zapisaniu kodu workera i wejściu na stronę testową nowego sklepu Sekuraka, zostaniemy uraczeni widokiem:
Zaś po podaniu poprawnego kodu, wszystkie zapytania są już przekierowywane do serwera origin:
Podsumowanie
CloudFlare Workers to ciekawy mechanizm, który pozwala na napisanie kodu JS (w edytorze dostępnym z przeglądarki), który może być zwykłym API lub serwerem proxy chroniącym dostępu do serwera origin. Oficjalna dokumentacja zawiera też kilka innych ciekawych przykładowych jej wykorzystania.
— Michał Bentkowski (zwykle pentester w Securitum choć ostatnio bardziej programista)
Pełny kod workera
Dla przejrzystości, poniżej pełny kod workera:
const COOKIE_NAME = '__Host-magic-cookie';
/** @type {{[key: string]: Date}} */
const CODES = {
'bardzo-magiczny-kod': new Date(2021, 5, 30)
}
addEventListener("fetch", ev => {
ev.respondWith(handle(ev.request))
})
/**
* Główna funkcja do obsługi zapytań
*
* @param {Request} request
* @returns {Promise<Response>}
*/
function handle(request) {
// Jeśli użytkownik ma właściwe ciasteczko, przekazujemy
// zapytanie do serwera origin
if (hasValidCookie(request)) {
return fetch(request);
}
// Jeśli wykonano zapytanie POST, obsługujemy je w szczególny
// sposób.
if (request.method === 'POST') {
return handlePost(request);
}
// W przeciwnym wypadku zwracamy główną stronę z formularzem.
return index();
}
/**
* Funkcja sprawdzająca, czy użytkownik ma ustawione ciasteczko,
* pozwalające na dostęp do serwisu.
*
* @param {Request} request
* @returns {boolean}
*/
function hasValidCookie(request) {
const cookie = request.headers.get('Cookie');
// Jeśli nagłówek nie jest ustawiony, od razu zwracamy fałsz.
if (!cookie) {
return false;
}
// Przetwarzamy ciasteczka do obiektu postaci:
// {"nazwa_ciasteczka": "wartosc"}
const cookies = Object.fromEntries(
cookie.split(/;\s*/).map(c => c.split('='))
);
// Sprawdzamy czy ustawiono magiczne ciasteczko.
const magicCookie = cookies[COOKIE_NAME];
if (!magicCookie) {
return false;
}
// Sprawdzamy, czy ciasteczko ma poprawny kod
const expiration = CODES[magicCookie];
if (!expiration) {
return false;
}
// Zwracamy prawdę, jeśli ciastko jest nadal ważne
return new Date() < expiration
}
/**
* Funkcja weryfikująca zapytanie POST (tj. zapytanie z podanym kodem)
*
* @param {Request} request
* @returns {Promise<Response>}
*/
async function handlePost(request) {
// Przygotowujemy obiekt z odpowiedzią. Na pewno będziemy przekierowywać,
// więc z góry ustawiamy status na 302 i nagłówek Location
const response = new Response('', {
status: 302,
headers: {
'Location': '/'
}
})
// Pobieramy parametry przekazane w zapytaniu
const params = new URLSearchParams(await request.text());
// Pobieramy parametr code
const code = params.get('code')
// Sprawdzamy czy kod jest poprawny i ważny.
/** @type {boolean} */
const validCode = (
code &&
CODES.hasOwnProperty(code) &&
new Date() < CODES[code]
)
// Jeśli mamy ważny kod, to ustawiamy ciasteczko
if (validCode) {
response.headers.set(
'Set-Cookie',
`${COOKIE_NAME}=${code}; Path=/; Secure; HttpOnly; Expires=${CODES[code].toGMTString()}`
)
}
return response;
}
/**
* Funkcja zwracająca HTML-a umożliwiającego podanie hasła.
*
* @returns Promise<Response>
*/
async function index() {
const HTML_CONTENT = `<!doctype html><meta charset=utf-8>
<p>Dostęp kontrolowany. Podaj kod, by uzyskać dostęp do aplikacji.</p>
<form method=post enctype=application/x-www-form-urlencoded>
<label for=code>Kod: </label>
<input name=code id=code>
<button>Wyślij</button>
</form>
`;
const response = new Response(HTML_CONTENT, {
headers: {
'Content-type': 'text/html; charset=utf-8'
}
});
return Promise.resolve(response);
}
Można prościej
https://pastebin.com/Yce6UC1A
Jak odwiedzimy naszą sekretna stronę przez unikalny adres, to ustawimy ciastko i dalej nas apache przepuści. Można też do tego dodać basic auth jak ktoś chce dodatkowo hasło ;)
Basic Auth odpadało, jako że aplikacja później korzysta z „Authorization: Bearer”, więc nagłówki Authorization by się „gryzły” :)
Bardzo fajny przykład.
Coś nie mogłem się przekonać do serverless ale coraz bardziej mi się to podoba.
W przykładzie jest:
`${COOKIE_NAME}=${code}; Path=/; Secure; HttpOnly; Expires=${CODES[code].toGMTString}`
powinno być:
`${COOKIE_NAME}=${code}; Path=/; Secure; HttpOnly; Expires=${CODES[code].toGMTString()}`
Fajne przedstawienie możliwości workerów!
Dzięki, poprawiłem :)
w sumie można też to wykorzystać żeby zrobić darmowy load balancer zamiast płatnego LB cloudflare :P
https://blog.cloudflare.com/update-response-headers-on-cloudflare-workers/
ciekawe tylko jakie to ma ograniczenia..
Darmowa wersja pozwala na 100 tys. zapytań dziennie. Więc jeśli ktoś potrzebuje więcej, to tutaj będzie ograniczenie.
Myślę, że kolejnym ograniczeniem może być również maksymalny czas wykonywania 'workera’ (10ms w darmowej wersji) z opcja fallback.. chociaż widzę na blogu CF taki przykład:
}, 2000 /* 2 seconds */);
to nie wiem jak to miałoby działać skoro zostanie przekroczony czas na wykonanie skryptu..
Z tego co rozumiem, to 10ms to czas procesora, a nie czas „rzeczywisty”. Wydaje mi się mało prawdopodobne, żeby ten czas przekroczyć; kody workerów są zazwyczaj na tyle proste, że potrzebują pewnie nie więcej niż 1ms czasu procesora.