WebSocket (in)Security e Autenticazione - Parte 2

The trust of the innocent is the liar's most useful tool. - Stephen King

Nella prima parte abbiamo presentato le caratteristiche principali del protocollo WebSocket e ne abbiamo analizzato le principali problematiche di sicurezza. In questo articolo completeremo la panoramica approfondendo la problematica dell'autenticazioe, la quale era stata volutamente lasciata in sospeso, e implementeremo un semplice proof-of-concept per una versione hardenizzata di un server WebSocket al quale aggiungeremo, appunto, un meccanismo di autenticazione. Il codice è disponibile su questo repository GitHub.

Designed by storyset / Freepik

WebSocket e Autenticazione

In molti casi d'uso reali, possiamo aspettarci che i server WebSocket abbiano la necessità di autenticare i client. Tuttavia, l'RFC 6455 - ricordiamo che il protocollo WS è definito in questo standard - non fornisce una soluzione specifica al problema dell'autenticazione, al quale dedica solo le seguenti righe:

This protocol doesn’t prescribe any particular way that servers can authenticate clients during the WebSocket handshake. The WebSocket server can use any client authentication mechanism available to a generic HTTP server, such as cookies, HTTP authentication, or TLS authentication.

Di fatto, però, nessuna di queste opzioni funziona bene nella pratica, specialmente se si vuole implementare una soluzione generale in cui poter utilizzare anche il browser come client WS. Gli sviluppatori del modulo Python websockets forniscono degli utilissimi esempi pratici su ognuno dei metodi riportati di seguito, ma molti fra questi non sono utilizzabili su implementazioni differenti. In un'ottica più generale, infatti, l'uso dei cookie diventa uno scomodo compromesso per i motivi che a breve vedremo, l'autenticazione HTTP non è supportata da tutti i browser e l'autenticazione TLS in un browser risulterebbe in una pessima esperienza per l'utente, oltre a causare problemi di privacy.

In un'applicazione Web è possibile utilizzare i cookie per identificare la sessione dell'utente. Tuttavia, nel caso in cui il server WS venisse eseguito su un dominio diverso dall'applicazione Web, andremmo incontro alle politiche di Same-Origin: per motivi di sicurezza, è impossibile impostare un cookie su un'origine diversa. Vi sarebbero delle soluzioni a tale problema, utilizzando un iframe nascosto - ma bisognerebbe agire sulla gestione dei cookie lato server e non è detto che sia supportata nativamente dalla libreria che usiamo -, oppure condividendo un dominio "padre" tra il server HTTP e il server WebSocket e impostando il cookie sul dominio principale, ma ciò farebbe sì che il cookie venga condiviso con tutti i sottodomini, il che è inaccettabile.

I parametri di una query nell'URI WS

Certamente un metodo affidabile. Esso comporta l'autenticazione prima dell'handshake WS - sebbene effettuata a livello dell'applicazione e non del protocollo - ma espone a un rischio solitamente inaccettabile: gli URI finiscono nei log, il che significa potenziale leak di credenziali. Ciò si potrebbe evitare in parte con l'utilizzo di token monouso, ma bisognerebbe effettuare il parsing dei parametri lato server e non tutte le implementazioni del protocollo potrebbero supportarlo (se non ad handshake avvenuto).

HTTP Basic Auth

Se vogliamo puntare sulla migliore soluzione, rinunciando però alla possibilità di utilizzare il browser come client WS, allora abbiamo una risposta efficace senza alcun dubbio: l'autenticazione HTTP. Questa sarebbe anche una buona idea per i browser in teoria, ma nella pratica tale meccanismo non funziona a causa dello scarso supporto: solo Chrome e Firefox pare che lo supportino, sebbene Firefox abbia qualche problema di caching.

Custom HTTP header

La soluzione più elegante in assoluto, che risolverebbe la maggior parte dei problemi. Tuttavia, indovinate un po', le WebSocket API non supportano la possibilità di impostare degli header personalizzati.

TLS Client Auth

Nell'autenticazione TLS il client (browser) utilizza un certificato per autenticarsi durante l'handshake TLS. Esistono diversi problemi con l'utilizzo di un certificato, che ne hanno impedito l'adozione nel Web: esperienza utente, problemi di privacy e portabilità. In sintesi, questo meccanismo risulterebbe troppo complesso e sconveniente per l'utente medio.

Il primo messaggio è quello che conta!

L'invio di credenziali nel primo messaggio rappresenta il metodo più affidabile e semplice in assoluto per l'autenticazione di un client WebSocket, applicabile indifferentemente dall'implementazione che utilizziamo. Ciò si può realizzare grazie a un meccanismo basato sui ticket - nella prossima sezione vedremo tutti i dettagli. È bene comunque evidenziare che l'autenticazione viene così gestita dall'applicazione - e non a livello di protocollo come è buona norma seguire - e viene effettuata comunque dopo l'handshake, quindi a connessione stabilita. Questi due "lati negativi" rappresentano però il male minore, se il nostro obiettivo rimane la semplicità e la possibilità di slegarci da un'implementazione specifica del protocollo, rispetto ai metodi che abbiamo elencato sopra, i quali richiederebbero invece delle soluzioni più su misura.

WebSocket Server Auth Hardening

A questo punto, avendo più chiara la teoria, passiamo finalmente alla parte pratica in cui andremo a implementare un meccanismo di autenticazione in un semplice server WebSocket secondo l'ultima opzione, ovvero l'invio della prova di autenticazione come primo messaggio della connesione. Lo schema di seguito riassume il flusso di autenticazione completo, includendo la parte - per semplicità da noi simulata e non implementata - in cui il client richiede il token di autenticazione al server - questi in generale sarà differente dal server WS - che se ne occupa prima di inoltrarlo come primo messaggio al server WebSocket:

WebSocket Ticket-Based Auth

Per questa demo il codice che analizzeremo è stato scritto in Python e, per l'implementazione del server WebSocket, è stato usato un modulo che implementa il protocollo in maniera semplice e intuitiva: SimpleWebSocketServer. La parte frontend è costituita da un semplice file HTML con l'aggiunta di codice JavaScript per la gestione della connessione WebSocket al server.

WS Server

Per semplicità, iniziamo con l'implementare la classe SimpleEchoServer, estendendo la classe WebSocket dal modulo SimpleWebSocketServer, che rappresenta proprio un echo server che ritorna indietro qualunque messaggio - il formato atteso è JSON in questo caso - inviato dal client e stampa informazioni basilari di logging:

class SimpleEchoHandler(WebSocket):

    def handleMessage(self):
        data = json.loads(self.data)
        logging.info('> Received msg from %s: %s' % (self.address[0], data))
        self.sendMessage(json.dumps(data))

    def handleConnected(self):
        logging.info('> Client connected %s' % self.address[0])

    def handleClose(self):
        logging.info('> Client disconnected %s' % self.address[0])

Per aggiungere il meccanismo di autenticazione - e con esso si pressupone anche una gestione dell'autorizzazione dietro le quinte - è sufficiente aggiungere un attributo booleano alla classe, nel nostro caso authenticated, il quale terrà lo stato dell'autenticazione del client e, successivamente, implementare un decorator grazie al quale possiamo estrapolare il token dal messaggio ed effettuare il controllo di autenticazione, che nel nostro caso avviene invocando la funzione simulate_auth_server_check. Se il controllo va a buon fine, allora si torna alla normale esecuzione del metodo di handling dei messaggi. Altrimenti, il server invia un frame di chiusura e la connessione verrà chiusa. Nel caso in cui il client risultasse già autenticato, tale controllo viene saltato e si procederà con la normale esecuzione del metodo. Di seguito l'implementazione del decorator:

def login_required(func):
    def wrapper_login_required(self, *args, **kwargs):
        if not self.authenticated:  # Not (yet) authenticated
            token = json.loads(self.data).get('token', '')
            self.authenticated = simulate_auth_server_check(token)  # Check if token is valid
            if not self.authenticated:
                logging.info('> Client unauthenticated %s' % self.address[0])
                self.close(1011, u'unauthenticated')
            else:
                logging.info('> Client authenticated %s' % self.address[0])
                self.sendMessage('Successfully authenticated.')
        else:   # Regular handling if already authenticated
            func(self, *args, **kwargs)
    return wrapper_login_required

Il nostro SimpleEchoServer apparirà quindi in definitiva come segue:

class SimpleEchoHandler(WebSocket):

    def __init__(self, server, sock, address):
        super().__init__(server, sock, address)
        self.authenticated = False

    @login_required
    def handleMessage(self):
        data = json.loads(self.data)
        logging.info('> Received msg from %s: %s' % (self.address[0], data))
        self.sendMessage(json.dumps(data))

    def handleConnected(self):
        logging.info('> Client connected %s' % self.address[0])

    def handleClose(self):
        logging.info('> Client disconnected %s' % self.address[0])

La parte del controllo del token di autenticazione sarà, per comodità, simulata da una funzione banalissima. In uno scenario reale si avrebbe probabilmente una chiamata al server dedicato al controllo, ma per questo PoC possiamo accontentarci di qualcosa del genere:

def simulate_auth_server_check(token):
    # Simulate a check request to the auth server
    return token == 'ebfb7ff0-b2f6-41c8-bef3-4fba17be410c'

Per concludere l'implementazione backend, il main si occuperà di lanciare il nostro bellissimo server WS e un classico Web server - questi risulterà utile per fornire le pagine Web dalle quali il browser potrà connettersi tramite connessione WebSocket - che rimarrà in ascolto su un nuovo thread:

def main():
    # ...Arguments parsing here...

    (options, args) = parser.parse_args()

    if options.ssl == 1:
        ws_server = SimpleSSLWebSocketServer(options.host, options.port, SimpleEchoHandler, options.cert, options.key, version=options.ver)
        httpd = HTTPServer(('', 443), SimpleHTTPRequestHandler)
        httpd.socket = ssl.wrap_socket(httpd.socket, server_side=True, certfile='./cert.pem', keyfile='./key.pem', ssl_version=options.ver)
    else:
        ws_server = SimpleWebSocketServer(options.host, options.port, SimpleEchoHandler)
        httpd = HTTPServer(('', 80), SimpleHTTPRequestHandler)

    # Graceful shutdown handling
    def close_sig_handler(signal, frame):
        ws_server.close()
        if options.ssl == 1:
            httpd.shutdown()
            http_server_t.join()
        sys.exit()

    signal.signal(signal.SIGINT, close_sig_handler)

    # Start the HTTP(S) server in a thread
    http_server_t = Thread(target=httpd.serve_forever, daemon=True)
    http_server_t.start()
    # Start the WebSocket server
    ws_server.serveforever()

Nel caso avessimo un certificato e una chiave TLS, il tutto è configurato per eseguire il server WebSocket utilizzando il protocollo WSS e, ovviamente, il server HTTP utilizzerà HTTPS. Sarà sufficiente eseguire lo script passando le opzioni e i file come argomenti.

WS Client

La parte client si presenta con del codice JavaScript aggiunto in un file HTML in modo da gestire la connessione, i messagi ed eventuali errori in maniera abbastanza semplificata, oltre a inviare come primo messagio il famoso token per l'autenticazione. La pagina è composta da una casella di testo che serve da input per il token, una per l'invio e un'altra per la ricezione dei messaggi e, infine dei classici pulsanti per la connessione, disconnessione e invio dei messaggi. Nel momento in cui viene cliccato il pulsante Connect, viene creata una connessione WS verso l'indirizzo al quale risulta in ascolto il server WS e viene inviato il token di autenticazione come primo messaggio. Se l'autenticazione ha successo, la connessione rimane aperta ed è possibile testare l'echo server che ritornerà indietro qualunque messaggio inviamo. Di seguito il codice:

<body>
    <input type="text" name="token" id="token" value="ebfb7ff0-b2f6-41c8-bef3-4fba17be410c">
    <input type="button" value="Connect" onClick="connect();">
    <input type="button" value="Disconnect" onClick="disconnect();">
    <input type="text" name="text" id="message" placeholder="Add a message here">
    <input type="button" value="Send" onClick="sendMessage();">
    <div name="output" id="output" cols="30" rows="1"></div>
</body>
<script>

    /* Button clicks handling */

    const connect = () => {
        token = document.getElementById('token').value;   // Assume the client retrieved its token from the auth server

        // Create a WebSocket instance and register main events
        ws_client = new WebSocket('ws://localhost:5678/');
        ws_client.onopen = (event) => { onOpen(event); };
        ws_client.onmessage = (event) => { onMessage(event); };
        ws_client.onclose = (event) => { onClose(event); };
        ws_client.onerror = (event) => { onError(event); };
    };

    const sendMessage = () => {
        let message = document.getElementById('message').value;
        ws_client.send(JSON.stringify({ 'msg': message }));
    }

    const disconnect = () => { ws_client.close(); }

    /* WebSocket main events handling */

    const onOpen = (event) => { ws_client.send(JSON.stringify({ token })); };
    const onMessage = (event) => {
        console.log(event.data);
        document.getElementById('output').textContent = event.data;
    };
    const onClose = (event) => {
        console.log('Connection closed.', event.reason);
        document.getElementById('output').textContent = `Disconnected. ${event.reason}`;
    };
    const onError = (event) => {
        ws_client.close();
        document.getElementById('output').textContent = `Error. ${event.reason}`;
    };

</script>

Demo

Per eseguire il codice è sufficiente lanciare lo script Python e recarsi da un browser all'indirizzo sul quale è in ascolto il Web server. A questo punto è possibile osservare - il server stamperà dei messaggi di log - che al click del pulsante Connect il client invia il proprio token come primo messaggio subito dopo l'handshake e il server, avendolo autenticato, non termina la connessione:

Valid Auth Token
Test Message

Se proviamo invece a inviare un token diverso, simulando quindi una prova di autenticazione non valida, il server chiuderà immediatamente la connessione:

Unvalid Auth Token

Conclusioni

In questa seconda parte abbiamo completato la panoramica sulla sicurezza del protocollo WebSocket, approfondendo in particolare il problema della mancanza di autenticazione. Dopo aver riportato i principali metodi, ognuno con i suoi pro e contro, per poter risolvere tale problematica, ci siamo concentrati sul meccanismo di autenticazione basato sui ticket. Questi risulta infatti il metodo più generico e applicabile indipendentemente dall'implementazione del protocollo e, al tempo stesso, uno tra i più sicuri. Abbiamo infine presentato un semplice proof-of-concept su come implementare una versione hardenizzata di un server WebSocket, aggiungendo appunto il livello di autenticazione ticket-based, che si spera possa rappresentare uno spunto iniziale per casi d'uso reali e scenari.


References