Server, stream, XSS támadás – (Node.js – 2)

A kódbazis.hu (Bolgár Máté) Node.js tutorial videói alapján.

Szerver létrehozása

A Node.js-ben beépített könyvtárak vannak, azokhoz is a require() utasítással tudok hozzájutni. A http könyvtárral tudok szervert létrehozni. Google keresés: nodejs create server: How do I create a HTTP server? | Node.js – ott szereplő kód snippet:

const http = require('http');

const requestListener = function (req, res) {
     res.writeHead(200);
     res.end('Hello, World!');
} 

const server = http.createServer(requestListener);
server.listen(8080);

A require(‘http’) utasítással behúzzuk a könyvtárat. (Nyilván: legyen telepítve node.js a gépre.) Szervert úgy hozzuk létre, hogy meghívjuk a createServer() functiont, amelynek az argumentumaként meg kell adni egy handler function-t. A handler function két paramétere a req (request) és a res (reponse). Össze is vonhatjuk:

//Ez lesz a filename.js fájl

const http = require('http');

const server = http.createServer(function (req, res) {
     res.writeHead(200);
     res.end('Hello, World!');
});

server.listen(8080);

A server függvény fog futni minden egyes alkalommal, amikor egy http kérés beérkezik a szerverre a böngészőből. A server.listen function egy végtelenített ciklus, ami folyamatosan figyeli az ott megadott portot a beérkező kérdésekre hallgatózva. Ezek után a programot a parancssorból – VS Code termináljáról – futtatni kell: node filename.js. A terminálon látható, hogy egy végtelenített process kezd el futni, ui. nem tudunk újabb parancsot küldeni. Megszakítása: Ctrl+c. Ekkor, ha beírjuk a böngészőbe, hogy http://localhost:8080/, akkor megjelenik a Hello, World! üzenet az ablakban. Az a tartalom jelenik meg, amit a res.end függvénnyel adunk meg. (The method, response.end(), MUST be called on each response.) A writeHead itt most a státuskódot állítja be, amit a konzol network fülén ellenőrizhetünk.

A server és kliens most ugyanazon a gépen fut, az operációs rendszeren két process fut: a böngésző és ez a Node.js-es process.

Hogyan tudunk a request-ből adatot kiszedni és a response-be adatot beírni?

A request rendelkezik:

  • URL: req.url
  • method: req.method
  • query paraméterek
  • header

A response rendelkezik:

  • header
  • body
  • status

Létre kell hozni egy útvonalválasztot, hogy attól függően, hogy milyen URL-re milyen method-dal érkezik a request, a megfelelő választ adja. Ezt switch útvonalválasztó mechanizmust használjuk. A switch(kifejezés) a kifejezésben megadott változó értékeit vizsgálja. Ha oda azt írjuk be, hogy true, akkor az egyes case-k kapcsán komplexebb kifejezéseket is meghatározhatok.

const http = require('http');

const server = http.createServer(function (req, res) {
    switch(true){
        case req.url=='/' && req.method=='GET':
            res.setHeader('content-type','text/html; charset=utf-8');
            res.writeHeader(200);
            res.end(`Címlap <a href='/bejelentkezes'>Bejelentkezés</a>`);
            break;
        case req.url=='/bejelentkezes' && req.method=='GET':
            res.setHeader('content-type','text/html; charset=utf-8');
            res.writeHeader(200);
            res.end(`Bejelentkezés <a href='/'>Címlap</a>`);
            break;
        default:
            res.setHeader('content-type','text/html; charset=utf-8');
            res.writeHeader(404);
            res.end(`Oldal nem található!`);
    }
});

server.listen(8080);

A setHeader állítja be a headerben a content-type-ot, ami a böngészővel közli, hogy a body-ban érkező tartalmat hogyan kell értelmeznie. Mivel itt template string-ben HTML tag-eket is küldünk, a content-type értéke ‘text/html; charset=utf-8‘ lesz, ahol a karakterkódolást is beállítjuk. Ha nem állítjuk be text/html-re, akkor a böngésző a html tag-eket is szövegként fogja megjeleníteni, a charset=utf-8 nélkül pedig a karakterkódolás nem jeleníti meg az ékezetes betűket. Így:

CĂ­mlap <a href='/bejelentkezes'>BejelentkezĂŠs

Beégetett tartalom helyett fájlrendszer használata

A filesystem könyvtárat be kell húzni: const fs = require(‘fs’); Ekkor minden útvonalhoz létrehozhatunk egy-egy html fájlt, amit az adott útvonalra érkező kérések alkalmával fogunk kiszolgálni (A fenti példához: home.html, login.html, 404.html.) A filesystemből fájl beolvasása: fs.readFile(); Két paramétert vár

  • az első a beolvasandó fájlhoz az operációs rendszerben vezető útvonal (path) lesz: __dirname + ‘/home.html’ – azaz itt vesszük az éppen futó fájlt tartalmazó könyvtárhoz vezető útvonalat és hozzáfűzzük a beolvasandó fájl nevét. A __dirname annak a mappának az elérési útvonala, ahol a szervert futtató fájl fut (bővebben itt).
  • a másik pedig egy callback függvény: function(err, data){…}, az ebben levő utasítások fognak lefutni. Mivel ez aszinkron, így a switch adott ágában lévő utasításokat ebbe a függvénytörzsbe kell behúzni.
const fs = require('fs');
const http = require('http');

const server = http.createServer(function (req, res) {
    switch(true){
        case req.url=='/home' && req.method=='GET':
            fs.readFile(__dirname+'/home.html',function(err,data){
            res.setHeader('content-type','text/html; charset=utf-8');
            res.writeHeader(200);
            res.end(data);    
            })
            break;
        case req.url=='/login' && req.method=='GET':
            fs.readFile(__dirname+'/login.html',function(err,data){
                res.setHeader('content-type','text/html; charset=utf-8');
                res.writeHeader(200);
                res.end(data);    
            });
            break;
        default:
            fs.readFile(__dirname+'/404.html',function(err,data){
                res.setHeader('content-type','text/html; charset=utf-8');
                res.writeHeader(404);
                res.end(data);    
            });
    }
    
});

server.listen(8080);

Stream

A content.txt-ből kiindul egy csak olvasható – readable – stream. Az áramló stream felölt egy buffert, amelynek van egy bizonyos kapacitása. Amikor ez a buffer feltöltődik – ez lesz a data esemény -, az adat átadódik mindenkinek, aki fel akarja dolgozni, majd a buffer kiürül, s újra elkezd töltődni a streamből. A megkapott adatot átírjuk egy írható – writable – streambe, ahonnan az adat átkerül a copy.txt-be (kép: kódbázis).

  • Létrehozok egy text.txt fájlt nagy mennyiségű szöveggel;
  • fs.createReadStream() argumentuma az a fájl, amelyből a readable stream indul;
  • fs.createWriteStream() argumentuma az a fájl, ahová a writable stream érkezik, ez lesz az a fájl, amibe átmásoljuk a text,txt tartalmát; nem kell létrehozni, a függvény automatikusan létrehozza;
  • rs.on(‘data’,function(data){…}) egyik paramétere a data esemény, azaz amikor a buffer megtelik és átadja az adatokat, a másik paraméter egy függvény.
  • ws.write(data) függvény írja be az adatfolyam adott részét hozzá a célfájlhoz.
  • rs.on(‘end’, function(){}) egyik paramétere az end esemény, ami csak egyszer következik be, akkor, amikor a readable stream elapad, másik paramétere szintén egy függvény.
  • ws.close() lezárja a writable streamet.
  • rs.pipe(ws) utasítással helyettesíthetjük az rs.on(‘data’, function(){}) utasítást, ez rövidebb, de ekkor nem tudunk side effectet létrehozni.
const fs = require('fs');

const rs = fs.createReadStream('./text.txt');
const ws = fs.createWriteStream('./copy.txt');

//rs.on('data', function (data) {
//    console.log(data);
//    ws.write(data);
//})

//helyette:
rs.pipe(ws)

rs.on('end', function () {
    console.log('Stream elapadt');
    ws.close()
})

Amennyiben nem az rs.pipe(ws) megoldással futtatjuk a fájlt, akkor a következő lesz kiírva a konzolon – a 65486 more bytes adatból látjuk, hogy buffer 216 byte méretű :

<Buffer 48 6f 73 73 7a c3 ba 20 c3 a9 76 65 6b 6b 65 6c 20 6b c3 a9 73 c5 91 62 62 2c 20 61 20 6b 69 76 c3 a9 67 7a c5 91 6f 73 7a 74 61 67 20 65 6c c5 91 74 ... 65486 more bytes>
<Buffer 7a 6e 69 20 6b 65 7a 64 74 65 6b 20 61 7a 20 c3 a1 67 79 20 65 6c c5 91 74 74 2e 20 41 7a 0d 0a 61 73 73 7a 6f 6e 79 20 74 65 6b 69 6e 74 65 74 65 20 ... 65486 more bytes>
<Buffer 61 6e 2e 20 e2 80 93 20 45 6c 20 6b 65 6c 6c 20 68 6f 67 79 20 6a c3 b6 6a 6a c3 b6 6e 2e e2 80 9c 20 4f 6c 79 61 6e 0d 0a 73 6f 6b 73 7a 6f 72 20 c3 ... 9024 more bytes>
Stream elapadt

Érdekesség: a text.txt fájl Marquez: Száz év magány c. regénye első harminc oldalát tartalmazza. Így kezdődik: >> Hosszú évekkel később <<. Ha a fenti kódsorozatot beírjuk egy WordPadbe, s egyesével végighaladva a kétjegyű hexadecimális számokon, s lenyomjuk az Alt+x billentyűt, akkor megkapjuk az ábrázolt karaktert Unicode-ban.

H o s s z à º à © v e k k e l k à © s Å 91 b b
48 6f 73 73 7a c3 ba 20 c3 a9 76 65 6b 6b 65 6c 20 6b c3 a9 73 c5 91 62 62


Ez a kódolás a hosszú magánhangzókat escape karakter használatával kódolja, ahol az escape karaktert a c3 hexadecimális szám vezeti be.

A buffernek van egy .toString() metódusa, s azzal tudjuk a buffer tartalmát átkonvertálni utf-8 kódra.

Példakód

A továbbiakban egy bővíthető, módosítható táblázathoz írunk szervert.

A fetch API esetében a request egy olvasható stream, a response pedig egy írható stream. A post method esetén a req.body tehát egy readable stream, amit chunk-onként kapunk el, a toString() metódussal átkonvertáljuk utf-8 kódolásúvá majd egy body nevű változóban stringgé fűzzük össze a chunk-okat, hiszen a req.body az a POST metódussal küldött request body-ja. Ezután a readFile metódussal beolvassuk a tagok adatait tartalmazó json fájlt, parse-oljuk, majd belepusholjuk az új object-et a tömbbe. Ezután a .writeFile() metódussal visszaírjuk az új tömböt a json fájlba (writeFile leírása).

  • fs.writeFile(fileName, data, [encoding], [callback])

file = (string) filepath of the file to write
data = (string or buffer) the data you want to write to the file
encoding = (optional string) the encoding of the data. Possible encodings are ‘ascii’, ‘utf8’, and ‘base64’. If no encoding provided, then ‘utf8’ is assumed.
callback = (optional function (err) {}) If there is no error, err === null, otherwise err contains the error message.

Így néz ki a fájl struktúra. – Alatta pedig a srcipt.js és index.js fájlok, amikor a ‘töröl’, módosít’ gombok funkciói még nincsenek létrehozva, a ‘küld’ gomb viszont működik.

Így azonban az oldal még veszélyes támadási felületet nyújt.

//script.js

document.querySelector("#load-members").addEventListener('click', fetchMembers);

async function fetchMembers() {
    const response = await fetch("/members");
    const members = await response.json();

    let content = `<h2>Tagok:</h2>
    <table class="table table-sm table-striped table-bordered">
    <thead><tr><th>nr</th><th>név</th><th>kor</th><th>város</th><th>faj</th></tr></thead>
    <tbody>`
    members.forEach((value, index) => {
        content += `
        <tr><td>${index + 1}</td><td>${value.name}</td><td>${value.age}</td><td>${value.city}</td>
        <td>${value.species}</td>
        <td><button class="btn btn-sm btn-secondary">módosít</td>
        <td><button class="btn btn-sm btn-secondary">töröl</td></td></tr>`
    })
    content += `</tbody></table>`
    document.querySelector("#members-list").innerHTML = content;
}

document.querySelector("#send-member-data").addEventListener('submit', sendNewData);

async function sendNewData(event) {
    event.preventDefault();
    const name = event.target[0].value;
    const age = event.target[1].value;
    const city = event.target[2].value;
    const species = event.target[3].value;

    const fetchInit ={
        method: 'POST',
        headers: {'content-type':'application/json'},
        body: JSON.stringify({name, age, city, species})
    }

    res = await fetch('/members', fetchInit);

    if(res.ok){
        fetchMembers();
    }else{
        alert("server error")
    }
}
//index.js

const http = require('http');
const fs = require('fs');

const server = http.createServer(function (req, res) {

    switch (true) {
        case req.url == '/' && req.method == 'GET':
            fs.readFile('./views/home.html', function (err, file) {
                res.setHeader('content-type', 'text/html;charset=utf-8');
                res.end(file)
            })
            break;
        case req.url == '/script.js' && req.method == 'GET':
            fs.readFile('./public/script.js', function (err, file) {
                res.setHeader('content-type', 'application/javascript');
                res.end(file)
            })
            break;
        case req.url == '/members' && req.method == 'GET':
            fs.readFile('members.json', function (err, file) {
                res.setHeader('content-type', 'application/json');
                res.end(file);
            });
            break;
        case req.url == '/styles' && req.method == 'GET':
            fs.readFile('public/styles.css', function (err, file) {
                res.setHeader('content-type', 'text/css');
                res.end(file);
            });
            break;
        case req.url == '/members' && req.method == 'POST':
            let body = '';
            req.on('data', function (chunk) {
                body += chunk.toString();
            })
            req.on('end', function () {
                const newMember = JSON.parse(body);

                fs.readFile('./members.json', (err, data) => {
                    const members = JSON.parse(data);
                    members.push(newMember);

                    fs.writeFile('./members.json', JSON.stringify(members), function () {
                        res.end(JSON.stringify(members))
                    })
                })
            })
            break;
        default:
            res.end("404");
    }
})

server.listen(3000)

XSS támadás – a Cross Site Scripting

Az input mezőbe bármit beírhatunk, az a json fájlban fog tárolódni. Mivel ebben a kódban még nincs validálás, ezért beírhatunk egy script-et is <script>…</script> tag-ek között az input mezőben. Az eltárolódik a json fájlban. Tegyük fel, hogy a program a json fájl tartalmát egy /list.html elérésű oldalra tölti fel .innerHTML metódussal – mondjuk egy alábbi elágazással az útválasztón:

case req.url == '/list' && req.method == 'GET':
            fs.readFile('members.json', function (err, file) {
                res.setHeader('content-type', 'text/html;charset=utf-8');
                const members = JSON.parse(file);
                let membersHTML = '<h2>Tagok:</h2><ul>';
                for (let member of members) {
                    membersHTML += `<li>${member.name} - ${member.age} - ${member.city} - ${member.species}</li>`
                }
                membersHTML += `</ul>`;
                res.end(membersHTML);
            });
            break;

Így, ha ezt az oldalt valaki meg fogja nyitni, akkor az a script le fog futni a gépén, aminek súlyos következményei lehetnek. Azaz egy rosszindulatú felhasználó feltölt így egy script-et, s az adott oldalt egy másik felhasználó megnyitja, akkor ennek a felhasználónak a gépén fut le a script, s akár érzékeny adatokat is ellophat tőle. Ez a Cross Site Scripting támadás.

Védekezés ellene a data sanitization, amire kereshetünk kódot a neten js sanitize string keresőszavakkal. Egy találat a stackoverflow.com-on:

function sanitizeString(str){ str = str.replace(/([^a-z0-9áéíóúñü_-\s\.,]|[\t\n\f\r\v\0])/gim,""); return str.trim(); }

Ez a függvény egy stringet vár bemenetként, s törli belőle a veszélyes karaktereket, s úgy adja vissza. A függvény karakterlistájára érdemes még felvenni az ‘ű’, ‘ö’ és ‘ő’ betűket. Ekkor így fog kinézni az útválasztó elágazása:

//index.js

req.on('end', function () {
                const newMember = JSON.parse(body);
                const newMemberSanitized ={
                    name:sanitizeString(newMember.name),
                    age:sanitizeString(newMember.age),
                    city:sanitizeString(newMember.city),
                    species:sanitizeString(newMember.species)
                }

                fs.readFile('./members.json', (err, data) => {
                    const members = JSON.parse(data);
                    members.push(newMemberSanitized);

                    fs.writeFile('./members.json', JSON.stringify(members), function () {
                        res.end(JSON.stringify(members))
                    })
                })
            })
            break;

//index.js fájlban még felvesszük:

function sanitizeString(str){
    str = str.replace(/([^a-z0-9áéíóúñüűöő_-\s\.,]|[\t\n\f\r\v\0])/gim,"");
    return str.trim(); }

A teljes forráskód itt: github.com