node-http-server – Rev 7

Subversion Repositories:
Rev:
#!/usr/bin/env node

///////////////////////////////////////////////////////////////////////////
//    Copyright (C) 2017 Wizardry and Steamworks - License: GNU GPLv3    //
///////////////////////////////////////////////////////////////////////////

// Import packages.
const auth = require("http-auth");
const https = require('https');
const http = require('http');
const path = require('path');
const fs = require('fs');
const mime = require('mime');
const url = require('url');
const moment = require('moment');
const winston = require('winston');
const yargs = require('yargs');
const forge = require('node-forge');
const dns = require('dns');

// Get command-line arguments.
const argv = yargs
    .version()
    .option('root', {
        alias: 'd',
        describe: 'Path to the document root',
        demandOption: true
    })
    .help()
    .argv

// Configuration file.
const config = require(
    path
    .resolve(__dirname, 'config')
);

// Check for path traversal.
function isRooted(userPath, rootPath, separator) {
    userPath = userPath.split(separator).filter(Boolean);
    rootPath = rootPath.split(separator).filter(Boolean);
    return userPath.length >= rootPath.length &&
        rootPath.every((e, i) => e === userPath[i]);
}

// Generate certificates on the fly using incremental serials.
function generateCertificates(name, domain, size) {
    // Generate 1024-bit key-pair.
    const keys = forge
        .pki
        .rsa
        .generateKeyPair(size);
    // Create self-signed certificate.
    const cert = forge
        .pki
        .createCertificate();
    cert.serialNumber = moment().format('x');
    cert.publicKey = keys.publicKey;
    cert
        .validity
        .notBefore = moment().toDate();
    cert
        .validity
        .notAfter
        .setFullYear(
            cert
            .validity
            .notBefore
            .getFullYear() + 1
        );
    cert.setSubject([{
        name: 'commonName',
        value: domain
    }, {
        name: 'organizationName',
        value: name
    }]);
    cert.setIssuer([{
        name: 'commonName',
        value: domain
    }, {
        name: 'organizationName',
        value: name
    }]);

    // Self-sign certificate.
    cert.sign(
        keys.privateKey,
        forge
        .md
        .sha256
        .create()
    );

    // Return PEM-format keys and certificates.
    return {
        privateKey: forge
            .pki
            .privateKeyToPem(
                keys
                .privateKey
            ),
        publicKey: forge
            .pki
            .publicKeyToPem(
                keys
                .publicKey
            ),
        certificate: forge
            .pki
            .certificateToPem(cert)
    };
}

// Create various logging mechanisms.
const log = new winston.Logger({
    transports: [
        new winston.transports.File({
            level: 'info',
            filename: path.resolve(__dirname, config.log.file),
            handleExceptions: true,
            json: false,
            maxsize: 1048576, // 1MiB.
            maxFiles: 10, // Ten rotations.
            colorize: false,
            timestamp: () => moment().format('YYYYMMDDTHHmmss')
        }),
        new winston.transports.Console({
            level: 'info',
            handleExceptions: true,
            json: false,
            colorize: true,
            timestamp: () => moment().format('YYYYMMDDTHHmmss')
        })
    ],
    exitOnError: false
});

function handleClient(request, response, documentRoot) {
    const requestAddress = request.socket.address();
    const requestedURL = url.parse(request.url, true);

    log.info('Client: ' +
        requestAddress.address + ':' +
        requestAddress.port +
        ' accessing: ' +
        requestedURL.pathname
    );

    const trimmedPath = requestedURL
        .pathname
        .split('/')
        .filter(Boolean)
        .join('/');
    const filesystemPath = trimmedPath === '/' ?
        path.join(documentRoot, trimmedPath) :
        path.resolve(documentRoot, trimmedPath);

    if (!isRooted(filesystemPath, documentRoot, path.sep)) {
        log.warn('Attempted path traversal: ' +
            requestAddress.address + ':' +
            requestAddress.port +
            ' requesting: ' +
            requestedURL.pathname
        );
        response.statusCode = 403;
        response.end();
        return;
    }

    fs.stat(filesystemPath, (error, stats) => {
        // Document does not exist.
        if (error) {
            response.statusCode = 404;
            response.end();
            return;
        }

        switch (stats.isDirectory()) {
            case true: // Directory is requested so provide directory indexes.
                const documentRoot = path.resolve(filesystemPath, config.site.index);
                fs.stat(documentRoot, (error, stats) => {
                    if (error) {
                        fs.readdir(filesystemPath, (error, paths) => {
                            if (error) {
                                log.warn('Could not list directory: ' + filesystemPath);
                                response.statusCode = 500;
                                response.end();
                                return;
                            }
                            log.info('Directory listing requested for: ' + filesystemPath);
                            response.statusCode = 200;
                            response.write(JSON.stringify(paths));
                            response.end();
                        });

                        return;
                    }

                    fs.access(filesystemPath, fs.constants.R_OK, (error) => {
                        if (error) {
                            log.warn('The server was unable to access the filesystem path: ' + filesystemPath);
                            response.statusCode = 403;
                            response.end();
                            return;
                        }

                        // Set MIME content type.
                        response.setHeader('Content-Type', mime.lookup(documentRoot));

                        var readStream = fs.createReadStream(documentRoot)
                            .on('open', () => {
                                response.statusCode = 200;
                                readStream.pipe(response);
                            })
                            .on('error', () => {
                                response.statusCode = 500;
                                response.end();
                            });

                    });

                });
                break;
            default: // Browser requesting file.
                // Check if the file is accessible.
                fs.access(filesystemPath, fs.constants.R_OK, (error) => {
                    if (error) {
                        response.statusCode = 403;
                        response.end();
                        return;
                    }

                    response.setHeader('Content-Type', mime.lookup(filesystemPath));

                    var readStream = fs.createReadStream(filesystemPath)
                        .on('open', () => {
                            response.statusCode = 200;
                            readStream.pipe(response);
                        })
                        .on('error', () => {
                            response.statusCode = 500;
                            response.end();
                        });

                });
                break;
        }
    });
}

fs.realpath(argv.root, (error, documentRoot) => {
    if (error) {
        log.error('Could not find document root: ' + argv.root);
        process.exit(1);
    }

    // Create digest authentication.
    const authentication = auth.digest({
        realm: config.auth.realm,
        file: path.resolve(__dirname, config.auth.digest)
    });

    // Start HTTP server.
    http.createServer(
        // authentication,
        (request, response) =>
        handleClient(request, response, documentRoot)
    ).listen(config.net.port, config.net.address, () => {
        log.info('HTTP Server is listening on: ' +
            config.net.address +
            ' and port: ' +
            config.net.port +
            ' whilst serving files from: ' +
            documentRoot
        );
    });

    // Start HTTPs server if enabled.
    config.ssl.enable && (() => {
        // Generate certificates for HTTPs.
        const certs = generateCertificates(
            config.site.name,
            config.net.address,
            config.ssl.privateKeySize
        );

        https.createServer(
            // authentication,
            {
                key: certs.privateKey,
                cert: certs.certificate,
            },
            (request, response) =>
            handleClient(request, response, documentRoot)
        ).listen(config.ssl.port, config.ssl.address, () => {
            // Print information on server startup.
            log.info('HTTPs Server is listening on: ' +
                config.net.address +
                ' and port: ' +
                config.net.port +
                ' whilst serving files from: ' +
                documentRoot
            );
        });
    })();

});