ZURÜCK

Sichere Websockets mit Node.js

Absicherung von Node.js Websockets mit TLS.

Datum: 2015-11, Tags: Node.js, Javascript, SSL, npm, Howto


Websockets bzw. das WebSocket-Protokoll ermöglichen eine bidirektionale Verbindung zwischen einem Web-Client (z.B. Browser) und dem Server. Im Gegensatz zu "normalen" HTTP-Verbinungen, bei denen ein Client eine Anfrage an einen Server stellt und daraufhin eine Antwort erhält, initialisiert der Client bei Websockets eine Verbindung zum Server, die aufrecht erhalten bleibt. Dadurch können sowohl der Client als auch der Server Daten senden. Besonders auch der Server ist in der Lage, dem Client auch ungefragt Nachrichten zu übermitteln, z.B. die Aufforderung zur Aktualisierung oder gleich einen aktualisierten Datensatz.

Eine weit verbreitete Anwendung, die sich auch in jedem zweiten Beispiel für Websockets findet, ist ein Chat-Server. Das Prinzip ist, dass sich ein Client auf einen Chat-Server verbindet und dabei eine Websocket-Verbindung aufbaut. Sobald diese aufgebaut ist, kann der Server an alle verbundenen Clients die Nachrichten schicken, die einer der Chat-Teilnehmer eingegeben hat. Es gibt jedoch noch viele andere Anwendungen, die von diesem Prinzip profitieren. Das wichtigste Merkmal ist, dass der Client problemlos hinter einer Firewall sitzen kann, oder hinter einem DSL-Anschluss, was es normalerweise unmöglich machet, dem Client direkt Daten zu liefern. Dadurch dass der Client die Verbindung initiiert hat und offen hält, kann der Server den "Rückweg" zur Datenlieferung nutzen.

Das eröffnet viele Möglichkeiten, jedoch auch viele Risiken. Wenn der Server in irgendeiner Weise kompromittiert wird, oder ein Angreifer die Websocket-Verbindung manipulieret oder umleitet, ist ein Zugriff auf das private Netzwerk oder die privaten Daten in greifbarer Nähe. Ein Baustein, um eine Websocket-Verbindung abzusichern, ist eine verschlüsselte Verbindung per SSL/TLS. Zu betonen ist, dass es EIN Baustein ist, der allein jedoch für eine sichere Websocket-Anwendung nicht ausreicht. Wie diese Absicherung am Beispiel einer Websocket-App mit Node.js umgesetzt werden kann, wird im Folgenden beschrieben.


Installation von Node.js und npm


Benötigt werden die Javascript Umgebung Node.js und der ebenfalls auf Javascript basierende Paket-Manager npm. Die beiden Pakete werden üblicherweise gemeinsam ausgeliefert und installiert. Eine Anleitung zur Installation je nach Betriebssystem und Distribution findet sich auf den jeweiligen Webseiten unter Node.js Downloads und der Paketseite von npm. Sind die beiden Pakete installiert, können die jeweiligen Versionen aus einem Terminal heraus abgefragt werden:


node -v
v4.2.2

npm -v
2.11.3


Mit npm werden Node-Module aus dem npm-Paket-Repository installiert, die in der Node-App benutzt werden sollen, z.B. "express" oder "socket.io". Dabei können Pakete entweder mit der Option -g global, also für den kompletten Rechner installiert werden, oder ansonsten lokal, das bedeutet nur innerhalb des aktuellen Verzeichnisses/Projekts verfügbar.


Websocket Server


Sowohl der Websocket Server, als auch der Websocket Client bestehen aus einer einzigen Javascript-Datei, die mit node gestartet wird und die entsprechenden Module benutzt. Dazu wird zuerst ein Arbeitsverzeichnis secure-websockets erstellt und darin die Module websocket und http geladen:


mkdir secure-websockets
cd secure-websockets
node install websocket http
> websocket@1.0.22 install /home/jens/node_modules/websocket
> (node-gyp rebuild 2> builderror.log) || (exit 0)

http@0.0.0 ../node_modules/http

websocket@1.0.22 ../node_modules/websocket
├── yaeti@0.0.4
├── nan@2.0.9
├── typedarray-to-buffer@3.0.4 (is-typedarray@1.0.0)
└── debug@2.2.0 (ms@0.7.1)


Dann erzeugen wir eine Datei websocket-server.js, in der analog zur Node WebSocket Dokumentation ein einfacher Server programmiert wird:

websocket-server.js
#!/usr/bin/env node

/* basic configuration */

var cfg = {
        port: 8080,
    };

/* start the websockets server */

var WebSocketServer = require('websocket').server;
var protocol = require('http');

var server = protocol.createServer(
        function(request, response) {
    console.log((new Date()) + ' Received request for ' + request.url);
    response.writeHead(404);
    response.end();
});

server.listen(cfg.port, function() {
    console.log((new Date()) + ' Server is listening on port 8080');
});

wsServer = new WebSocketServer({
    httpServer: server
});

function originIsAllowed(origin) {
  // put logic here to detect whether the specified origin is allowed.
  return true;
}

wsServer.on('request', function(request) {
    if (!originIsAllowed(request.origin)) {
      // Make sure we only accept requests from an allowed origin
      request.reject();
      console.log((new Date()) + ' Connection from origin ' + request.origin + ' rejected.');
      return;
    }

    var connection = request.accept('echo', request.origin);

    console.log((new Date()) + ' Connection accepted.');
    connection.on('message', function(message) {
        if (message.type === 'utf8') {
            console.log('Received Message: ' + message.utf8Data);
            connection.sendUTF(message.utf8Data);
        }
        else if (message.type === 'binary') {
            console.log('Received Binary Message of ' + message.binaryData.length + ' bytes');
            connection.sendBytes(message.binaryData);
        }
    });
    connection.on('close', function(reasonCode, description) {
        console.log((new Date()) + ' Peer ' + connection.remoteAddress + ' disconnected.');
    });
});


Der Dienst wird aus dem Terminal heraus gestartet und wartet dann auf Verbindungen auf localhost über Port 8080


node websocket-server.js
Wed Nov 25 2015 14:51:17 GMT+0100 (CET) Server is listening on port 8080


Websocket Client


In dem gleichen Verzeichnis erzeugen wir das Client-Script websocket-client.js. Die Node-Module werden von beiden Anwendungen gleichermaßen benutzt.

websocket-server.js
#!/usr/bin/env node

// start the client

var WebSocketClient = require('websocket').client;

var client = new WebSocketClient();

client.on('connectFailed', function(error) {
    console.log('Connect Error: ' + error.toString());
});

client.on('connect', function(connection) {
    console.log('WebSocket Client Connected');

    connection.on('error', function(error) {
        console.log("Connection Error: " + error.toString());
    });
    connection.on('close', function() {
        console.log('Native Connection Closed');
    });
    connection.on('message', function(message) {
        if (message.type === 'utf8') {
            console.log("Received: '" + message.utf8Data + "'");
        }
    });

    function sendNumber() {
        if (connection.connected) {
            var number = Math.round(Math.random() * 0xFFFFFF);
            connection.sendUTF(number.toString());
            setTimeout(sendNumber, 2000);
        }
    }
    sendNumber();
});

client.connect('ws://localhost:8080', 'echo');


Wenn nun der Client gestartet wird, verbindet er sich auf localhost über Port 8080 mit dem Websocket Server und fragt das Subprotokoll "echo" an. Da dies vom Server akzeptiert wird (websocket-server.js Zeile 42), fängt daraufhin der Client an, alle 2 Sekunden eine Zufallszahl an den Server zu senden (der Client wird mit der Tastenkombination CTRL-C beendet).


node websocket-client.js
WebSocket Client Connected
Received: '6384206'
Received: '16114826'
Received: '11187863'
Received: '13018980'
^C


Auf Serverseite protokolliert der Server die Verbindung und gibt die Zufallszahlen aus, die vom Client übermittelt wurden.


node websocket-server.js
Wed Nov 25 2015 14:51:17 GMT+0100 (CET) Server is listening on port 8080
Wed Nov 25 2015 14:58:31 GMT+0100 (CET) Connection accepted.
Received Message: 6384206
Received Message: 16114826
Received Message: 11187863
Received Message: 13018980
Wed Nov 25 2015 14:58:37 GMT+0100 (CET) Peer ::ffff:127.0.0.1 disconnected.


Absicherung der Verbindung durch TLS (SSL Verschlüsselung)


Diese Websocket-Anwendung überträgt die Daten im Klartext. Jeder, der diese Verbindung sehen kann, kann auch automatisch deren kompletten Inhalt mitlesen, denn der ist nicht verschlüsselt. Wenn z.B. Authentifizierungsinformationen nach Verbindungsaufbau übertragen werden, können diese leicht abgefangen werden. Um das zu verhindern, können wir für den Übertragungsweg eine SSL-Verschlüsselung einbauen. Das wird als Transport Layer Security, kurz TLS, bezeichnet.

Für TLS benötigen wir als Erstes ein SSL Zertifikat. Wir können ein selbsterzeugtes und -signiertes Zertifikat benutzen, das wird jedoch als nicht vertrauenswürdig eingestuft und führt bei Browsern dazu, dass eine Warnmeldung über dieses nicht-vertrauenswürdige Zertifikat erscheint, und die Verbindung als Betrugsversuch eingestuft wird. Für unsere Beispielanwendung können wir das getrost ignorieren, für einen Einsatz in einer Produktivumgebung ist ein öffentlich verigfiziertes Zertifikat jedoch Pflicht.

Wir erzeugen uns ein selbstsigniertes Zertifikat in Form zweier Dateien key.pem und cert.pem in dem Unterverzeichnis ssl.


mkdir ssl
openssl req -x509 -newkey rsa:2048 -keyout ssl/key.pem -out ssl/cert.pem -days 100 -nodes
Generating a 2048 bit RSA private key
..+++
.................................+++
writing new private key to 'ssl/key.pem'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:DE
State or Province Name (full name) [Some-State]:Germany
Locality Name (eg, city) []:Hamburg
Organization Name (eg, company) [Internet Widgits Pty Ltd]:limitland development
Organizational Unit Name (eg, section) []:WEB
Common Name (e.g. server FQDN or YOUR name) []:localhost
Email Address []:info@limitland.de

ls -al ssl/
insgesamt 16
drwxr-xr-x 2 user users 4096 25. Nov 15:31 .
drwxr-xr-x 3 user users 4096 25. Nov 15:31 ..
-rw-r--r-- 1 user users 1448 25. Nov 15:31 cert.pem
-rw-r--r-- 1 user users 1704 25. Nov 15:31 key.pem


Nun muss der Websocket Server so umgeschrieben werden, dass er die beiden Dateien für eine Verbindung über TLS benutzt. Dazu wird in dem Server-Script die Konfiguration in Zeile 7 erweitert, das Protokoll in Zeile 14 auf https gestellt, das zusätzliche Modul fs in Zeile 15 geladen und die TLS-Konfiguration in Zeile 18 aktiviert.

websocket-server.js
#!/usr/bin/env node

/* basic configuration */

var cfg = {
        port: 8080,
        ssl_key: 'ssl/key.pem',
        ssl_cert: 'ssl/cert.pem'
    };

/* start the websockets server */

var WebSocketServer = require('websocket').server;
var protocol = require('https');
var fs = require('fs');

var server = protocol.createServer({
                key: fs.readFileSync( cfg.ssl_key ),
                cert: fs.readFileSync( cfg.ssl_cert )
    },
        function(request, response) {
    console.log((new Date()) + ' Received request for ' + request.url);
    response.writeHead(404);
    response.end();
});

server.listen(cfg.port, function() {
    console.log((new Date()) + ' Server is listening on port 8080');
});

wsServer = new WebSocketServer({
    httpServer: server,
    // You should not use autoAcceptConnections for production applications
    autoAcceptConnections: false
});

function originIsAllowed(origin) {
  // put logic here to detect whether the specified origin is allowed.
  return true;
}

wsServer.on('request', function(request) {
    if (!originIsAllowed(request.origin)) {
      // Make sure we only accept requests from an allowed origin
      request.reject();
      console.log((new Date()) + ' Connection from origin ' + request.origin + ' rejected.');
      return;
    }


    var connection = request.accept('echo', request.origin);

    console.log((new Date()) + ' Connection accepted.');
    connection.on('message', function(message) {
        if (message.type === 'utf8') {
            console.log('Received Message: ' + message.utf8Data);
            connection.sendUTF(message.utf8Data);
        }
        else if (message.type === 'binary') {
            console.log('Received Binary Message of ' + message.binaryData.length + ' bytes');
            connection.sendBytes(message.binaryData);
        }
    });
    connection.on('close', function(reasonCode, description) {
        console.log((new Date()) + ' Peer ' + connection.remoteAddress + ' disconnected.');
    });
});


Anschließend wird das nun noch benötigte Module fs installiert und der Server erneut gestartet.


npm install fs
fs@0.0.2 ../node_modules/fs

node websocket-server.js
Wed Nov 25 2015 15:46:15 GMT+0100 (CET) Server is listening on port 8080


Der Client benötigt ebenfalls eine Anpassung: die Verbindung in Zeile 42 wird nun über wss aufgebaut, das "secure websocket" Protokoll. Da der Server ein selbstsigniertes Zertifikat benutzt, würde der Client mit der Fehlermeldung Connect Error: Error: self signed certificate beenden, wenn nicht wie in Zeile 9 die Signaturprüfung ausgeschaltet wird. Die Überprüfung darf ein einem Produktivsystem natürlich nicht ausgeschaltet werden.

websocket-client.js
#!/usr/bin/env node

// start the client

var WebSocketClient = require('websocket').client;

var client = new WebSocketClient({
        tlsOptions: {
                rejectUnauthorized: false
        }
});

client.on('connectFailed', function(error) {
    console.log('Connect Error: ' + error.toString());
});

client.on('connect', function(connection) {
    console.log('WebSocket Client Connected');

    connection.on('error', function(error) {
        console.log("Connection Error: " + error.toString());
    });
    connection.on('close', function() {
        console.log('Native Connection Closed');
    });
    connection.on('message', function(message) {
        if (message.type === 'utf8') {
            console.log("Received: '" + message.utf8Data + "'");
        }
    });

    function sendNumber() {
        if (connection.connected) {
            var number = Math.round(Math.random() * 0xFFFFFF);
            connection.sendUTF(number.toString());
            setTimeout(sendNumber, 2000);
        }
    }
    sendNumber();
});

client.connect('wss://localhost:8080', 'echo');


Wird nun der Client gestartet, erfolgt die gleiche Kommunikation, diesmal allerdings auf einem verschlüsselten Übertragungsweg.


node websocket-client.js
WebSocket Client Connected
Received: '14050046'
Received: '4588603'
Received: '1985379'
^C


Und auf Serverseite:


node websocket-server.js 
Wed Nov 25 2015 15:46:15 GMT+0100 (CET) Server is listening on port 8080
Wed Nov 25 2015 15:56:28 GMT+0100 (CET) Connection accepted.
Received Message: 14050046
Received Message: 4588603
Received Message: 1985379
Wed Nov 25 2015 15:56:33 GMT+0100 (CET) Peer ::ffff:127.0.0.1 disconnected.


Resümee


Die Verschlüsselung des Protokolls über TLS ist gut und wichtig, denn in wohl den meisten Anwendungsfällen mit Websockets folg nach dem Verbindungsaufbau eine Authentifizierung des Clients. Die Übertragung der Authentifizierungsinformationen wird so verschlüsselt und schützt die Anwendung vor unerwünschten Mitlauschern. Jedoch wir mittlerweile eine SSL-Verschlüsselung auch nicht mehr als sicher eingestuft, und es sind weitere Maßnahmen zur Absicherung der Anwendung erforderlich.