-15% na nową książkę sekuraka: Wprowadzenie do bezpieczeństwa IT. Przy zamówieniu podaj kod: 10000

Wykorzystanie CloudFlare Workers do zabezpieczenia dostępu do aplikacji webowej

10 marca 2021, 10:30 | Teksty | komentarzy 9

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 przypadek użycia, w którym wykorzystaliśmy workera jako API pośredniczące w komunikacji pomiędzy dwoma serwisami: mamy API, które pozwala nam odbierać SMS-y (i wysyła callbacki, gdy jakiś SMS został odebrany); a z drugiej strony mamy webhooka w pokoju Google Chat. Chcemy żeby treści tych SMS-ów wyświetlały w pokoju Google Chat. Mogliśmy więc utworzyć workera, który odbierał zapytanie z callbackiem, w odpowiedni sposób przetwarzał i wysyłał na webhooka z Chata.

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).

Rys 1. Przejście do workerów

W kolejnym ekranie skorzystajmy z opcji „Create a Worker” (rys 2.).

Rys 2. Tworzenie nowego Workera

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.).

Rys 3. Podstawowy widok tworzenia workera

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 = {
    "&": "&amp;",
    "<": "&lt;",
    '"': "&quot;",
  }
  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))
})
W tym i w kolejnych fragmentach kodu będzie widać komentarze JSDoc (np. w powyższym kodzie widać @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:

  1. Jeśli użytkownik ma ważne ciasteczko z kodem, jest przekierowywany do serwera origin,
  2. Jeśli użytkownik wykonał zapytanie POST, sprawdzamy czy podał w nim właściwą wartość kodu.
  3. 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);
}

Spodobał Ci się wpis? Podziel się nim ze znajomymi:



Komentarze

  1. Wadera

    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 ;)

    Odpowiedz
    • Michał Bentkowski

      Basic Auth odpadało, jako że aplikacja później korzysta z „Authorization: Bearer”, więc nagłówki Authorization by się „gryzły” :)

      Odpowiedz
  2. Kingu

    Bardzo fajny przykład.
    Coś nie mogłem się przekonać do serverless ale coraz bardziej mi się to podoba.

    Odpowiedz
  3. Korektor

    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!

    Odpowiedz
    • Michał Bentkowski

      Dzięki, poprawiłem :)

      Odpowiedz
  4. Odpowiedz
    • Michał Bentkowski

      Darmowa wersja pozwala na 100 tys. zapytań dziennie. Więc jeśli ktoś potrzebuje więcej, to tutaj będzie ograniczenie.

      Odpowiedz
      • 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..

        Odpowiedz
        • Michał Bentkowski

          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.

          Odpowiedz

Odpowiedz na Michał Bentkowski