diff --git a/README.md b/README.md index ce6bcf36..171def9a 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,47 @@ -[![GitHub Workflow Status (master)](https://img.shields.io/github/actions/workflow/status/http-party/http-server/node.js.yml?style=flat-square&branch=master)](https://github.com/http-party/http-server/actions) -[![npm](https://img.shields.io/npm/v/http-server.svg?style=flat-square)](https://www.npmjs.com/package/http-server) [![homebrew](https://img.shields.io/homebrew/v/http-server?style=flat-square)](https://formulae.brew.sh/formula/http-server) [![npm downloads](https://img.shields.io/npm/dm/http-server?color=blue&label=npm%20downloads&style=flat-square)](https://www.npmjs.com/package/http-server) -[![license](https://img.shields.io/github/license/http-party/http-server.svg?style=flat-square)](https://github.com/http-party/http-server/blob/master/LICENSE) - -# http-server: a simple static HTTP server - -`http-server` is a simple, zero-configuration command-line static HTTP server. It is powerful enough for production usage, but it's simple and hackable enough to be used for testing, local development and learning. - -![Example of running http-server](https://github.com/http-party/http-server/raw/master/screenshots/public.png) +# Secure HTTP Server Lite -## Installation: +## Overview -#### Running on-demand: +This project is a lightweight reimplementation of core functionalities from http-server, with additional security enhancements. -Using `npx` you can run the script without installing it first: +## Features - npx http-server [path] [options] +* Static file serving +* Safe path resolution +* Basic MIME type support -#### Globally via `npm` +## Security Enhancements - npm install --global http-server +* Path traversal protection +* Secure HTTP headers +* Access logging for audit -This will install `http-server` globally so that it may be run from the command line anywhere. +## Motivation -#### Globally via Homebrew +Existing lightweight HTTP servers often lack built-in security protections. This project aims to improve security and auditability while keeping the implementation minimal. - brew install http-server - -#### As a dependency in your `npm` package: +## Usage - npm install http-server - -#### Using Docker - -Note: a public image is not provided currently, but you can build one yourself -with the provided Dockerfile. - -1. Create an image - ``` - docker build -t my-image . - ``` -2. Run a container - ``` - docker run -p 8080:8080 -v "${pwd}:/public" my-image - ``` - In the example above we're serving the directory `./` (working directory). - If you wanted to serve `./test` you'd replace `${pwd}` with `${pwd}/test`. - -## Usage: - - http-server [path] [options] - -`[path]` defaults to `./public` if the folder exists, and `./` otherwise. - -*Now you can visit http://localhost:8080 to view your server* - -**Note:** Caching is on by default. Add `-c-1` as an option to disable caching. - -## Available Options: - -| Command | Description | Defaults | -| ------------- |-------------|-------------| -|`-p` or `--port` |Port to use. Use `-p 0` to look for an open port, starting at 8080. It will also read from `process.env.PORT`. |8080 | -|`-a` |Address to use |0.0.0.0| -|`--base-dir` | Base path to serve files from | `/` | -|`-d` |Show directory listings |`true` | -|`-dir-overrides-404` | Whether `-d` should override magic `404.html` | `false` -|`-i` | Display autoIndex | `true` | -|`-g` or `--gzip` |When enabled it will serve `./public/some-file.js.gz` in place of `./public/some-file.js` when a gzipped version of the file exists and the request accepts gzip encoding. If brotli is also enabled, it will try to serve brotli first.|`false`| -|`-b` or `--brotli`|When enabled it will serve `./public/some-file.js.br` in place of `./public/some-file.js` when a brotli compressed version of the file exists and the request accepts `br` encoding. If gzip is also enabled, it will try to serve brotli first. |`false`| -|`-e` or `--ext` |Default file extension if none supplied |`html` | -|`-s` or `--silent` |Suppress log messages from output | | -|`--coop` |Enable COOP via the `Cross-Origin-Opener-Policy` header | | -|`--cors` |Enable CORS via the `Access-Control-Allow-Origin` header | | -|`--private-network-access` |Enable Private Network Access via the `Access-Control-Allow-Private-Network` header | | -|`--cors` | Enable CORS via the `Access-Control-Allow-Origin: *` header. Optionally provide comma-separated values to add to `Access-Control-Allow-Headers` | | -|`-H` or `--header` |Add an extra response header (can be used several times) | | -|`-o [path]` |Open browser window after starting the server. Optionally provide a URL path to open. e.g.: -o /other/dir/ | | -|`-c` |Set cache time (in seconds) for cache-control max-age header, e.g. `-c10` for 10 seconds. To disable caching, use `-c-1`.|`3600` | -|`-t` |Connection timeout in seconds, e.g. `-t60` for 1 minute. To disable timeout, use `-t0`.|`120` | -|`-T` or `--title` |Custom title suffix for the terminal window. The title will be "http-server PORT [TITLE]".| | -|`-U` or `--utc` |Use UTC time format in log messages.| | -|`--log-ip` |Enable logging of the client's IP address |`false` | -|`-P` or `--proxy` |Proxies all requests which can't be resolved locally to the given url. e.g.: -P http://someurl.com | | -|`--proxy-options` |Pass proxy [options](https://github.com/http-party/node-http-proxy#options) using nested dotted objects. e.g.: --proxy-options.secure false | | -|`--proxy-config` |Pass in `.json` configuration file or stringified JSON. e.g.: `./path/to/config.json` | | -|`--proxy-all` |Forward every request to the proxy target instead of serving local files|`false`| -|`--proxy-options` |Pass proxy [options](https://github.com/http-party/node-http-proxy#options) using nested dotted objects. e.g.: --proxy-options.secure false | -|`--user` or `--username` |Username for basic authentication | | -|`--password` |Password for basic authentication | | -|`-S`, `--tls` or `--ssl` |Enable secure request serving with TLS/SSL (HTTPS)|`false`| -|`-C` or `--cert` |Path to ssl cert file |`cert.pem` | -|`-K` or `--key` |Path to ssl key file |`key.pem` | -|`-r` or `--robots` | Automatically provide a /robots.txt (The content of which defaults to `User-agent: *\nDisallow: /`) | `false` | -|`--no-dotfiles` |Do not show dotfiles| | -|`--mimetypes` |Path to a .types file for custom mimetype definition| | -|`--hide-permissions` |Do not show file permissions| | -|`--allowed-hosts` |Comma-separated list of hosts allowed to access the server. e.g.: `--allowed-hosts localhost,example.com`| | -|`-h` or `--help` |Print this list and exit. | | -|`-v` or `--version`|Print the version and exit. | | -| `--no-panic` | Don't print error stack in the console, put it in a log file | `false`| - -## Magic Files - -- `index.html` will be served as the default file to any directory requests. -- `404.html` will be served if a file is not found. This can be used for Single-Page App (SPA) hosting to serve the entry page. - -## Catch-all redirect - -To implement a catch-all redirect, use the index page itself as the proxy with: - -``` -http-server --proxy http://localhost:8080? +```bash +node bin/http-server ./public ``` -Note the `?` at the end of the proxy URL. Thanks to [@houston3](https://github.com/houston3) for this clever hack! - -## TLS/SSL - -First, you need to make sure that [openssl](https://github.com/openssl/openssl) is installed correctly, and you have `key.pem` and `cert.pem` files. You can generate them using this command: - -``` sh -openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem -``` - -You will be prompted with a few questions after entering the command. Use `127.0.0.1` as value for `Common name` if you want to be able to install the certificate in your OS's root certificate store or browser so that it is trusted. - -This generates a cert-key pair and it will be valid for 3650 days (about 10 years). - -Then you need to run the server with `-S` for enabling SSL and `-C` for your certificate file. - -``` sh -http-server -S -C cert.pem -``` - -If you wish to use a passphrase with your private key you can include one in the openssl command via the -passout parameter (using password of foobar) - - -e.g. -`openssl req -newkey rsa:2048 -passout pass:foobar -keyout key.pem -x509 -days 365 -out cert.pem` - -For security reasons, the passphrase will only be read from the `NODE_HTTP_SERVER_SSL_PASSPHRASE` environment variable. - - -This is what should be output if successful: - -``` sh -Starting up http-server, serving ./ through https - -http-server settings: -COOP: disabled -CORS: disabled -Cache: 3600 seconds -Connection Timeout: 120 seconds -Directory Listings: visible -AutoIndex: visible -Serve GZIP Files: false -Serve Brotli Files: false -Default File Extension: none - -Available on: - https://127.0.0.1:8080 - https://192.168.1.101:8080 - https://192.168.1.104:8080 -Hit CTRL-C to stop the server -``` +### Programmatic usage -# Development +```js +const { createServer } = require('./lib/http-server'); -Checkout this repository locally, then: +const server = createServer({ + root: './public', + securityHeaders: true, + accessLog: true, +}); -```sh -$ npm i -$ npm start +server.listen(8080); ``` -*Now you can visit http://localhost:8080 to view your server* +## Future Work -You should see the turtle image in the screenshot above hosted at that URL. See -the `./public` folder for demo content. +* Add HTTPS support +* Add rate limiting +* Improve MIME detection diff --git a/git b/git new file mode 100644 index 00000000..e69de29b diff --git a/lib/core/index.js b/lib/core/index.js index e386c541..df491528 100644 --- a/lib/core/index.js +++ b/lib/core/index.js @@ -4,6 +4,19 @@ const path = require('path'); const fs = require('fs'); + +// Resolve a requested path and ensure it stays under the configured root. +function safeResolvePath(root, requestPath) { + const rootResolved = path.resolve(root); + const relativeRequestPath = String(requestPath || '').replace(/^[/\\]+/, ''); + const resolved = path.resolve(rootResolved, relativeRequestPath); + + if (!resolved.startsWith(rootResolved + path.sep) && resolved !== rootResolved) { + return null; + } + + return resolved; +} const url = require('url'); const { Readable } = require('stream'); const buffer = require('buffer'); @@ -24,6 +37,10 @@ function decodePathname(pathname) { const normalized = path.normalize(pieces.map((rawPiece) => { const piece = decodeURIComponent(rawPiece); + if (piece.includes('\0')) { + throw new Error('Invalid null byte character'); + } + if (process.platform === 'win32' && /\\/.test(piece)) { throw new Error('Invalid forward slash character'); } @@ -36,7 +53,6 @@ function decodePathname(pathname) { const nonUrlSafeCharsRgx = /[\x00-\x1F\x20\x7F-\uFFFF]+/g; function ensureUriEncoded(text) { - return text return String(text).replace(nonUrlSafeCharsRgx, encodeURIComponent); } @@ -184,12 +200,15 @@ module.exports = function createMiddleware(_dir, _options) { return; } - file = path.normalize( - path.join( - root, - path.relative(path.join('/', opts.baseDir), pathname) - ) - ); + const relativePath = path.relative(path.join('/', opts.baseDir), pathname); + file = safeResolvePath(root, relativePath); + + if (!file) { + status[403](res, next); + return; + } + + file = path.normalize(file); // determine compressed forms if they were to exist, make sure to handle pre-compressed files, i.e. files with .br/.gz extension. we will serve them "as-is" gzippedFile = `${file}.gz`; brotliFile = `${file}.br`; @@ -208,13 +227,6 @@ module.exports = function createMiddleware(_dir, _options) { return; } - // TODO: This check is broken, which causes the 403 on the - // expected 404. - if (file.slice(0, root.length) !== root) { - status[403](res, next); - return; - } - if (req.method && (req.method !== 'GET' && req.method !== 'HEAD')) { status[405](res, next); return; diff --git a/lib/http-server.js b/lib/http-server.js index 76a12f97..45f1c0df 100644 --- a/lib/http-server.js +++ b/lib/http-server.js @@ -23,6 +23,12 @@ exports.createServer = function (options) { return new HttpServer(options); }; +function stopRequest(res, statusCode, message) { + res.statusCode = statusCode; + res.setHeader('content-type', 'text/plain; charset=UTF-8'); + res.end(message); +} + /** * Constructor function for the HttpServer object * which is responsible for serving static files along @@ -61,6 +67,32 @@ function HttpServer(options) { this.headers = options.headers || {}; this.headers['Accept-Ranges'] = 'bytes'; + + + + + + + + + + const securityHeadersEnabled = options.securityHeaders === true || options.securityHeaders === 'true'; + if (securityHeadersEnabled) { + if (typeof this.headers['X-Content-Type-Options'] === 'undefined') { + this.headers['X-Content-Type-Options'] = 'nosniff'; + if(false){ + + } + } + if (typeof this.headers['X-Frame-Options'] === 'undefined') { + this.headers['X-Frame-Options'] = 'DENY'; + } + if (typeof this.headers['Referrer-Policy'] === 'undefined') { + this.headers['Referrer-Policy'] = 'no-referrer'; + } + } + + this.cache = ( // eslint-disable-next-line no-nested-ternary options.cache === undefined ? 3600 : @@ -86,6 +118,103 @@ function HttpServer(options) { (this.ext === 'html' ? 'text/html' : 'application/octet-stream'); var before = options.before ? options.before.slice() : []; + const maxUrlLength = Number.isFinite(Number(options.maxUrlLength)) + ? Math.max(1, Number(options.maxUrlLength)) + : 2048; + const maxHeaderBytes = Number.isFinite(Number(options.maxHeaderBytes)) + ? Math.max(256, Number(options.maxHeaderBytes)) + : 8192; + const maxRequestBodyBytes = Number.isFinite(Number(options.maxRequestBodyBytes)) + ? Math.max(0, Number(options.maxRequestBodyBytes)) + : 1048576; + + before.unshift(function (req, res) { + const method = String(req.method || '').toUpperCase(); + const requestUrl = String(req.url || ''); + const hostHeader = req.headers ? req.headers.host : undefined; + + if (requestUrl.includes('\0')) { + stopRequest(res, 400, 'Malformed URL'); + return; + } + + if (requestUrl.length > maxUrlLength) { + stopRequest(res, 414, 'URI too long'); + return; + } + + if (req.httpVersionMajor >= 1 && req.httpVersionMinor >= 1 && !hostHeader) { + stopRequest(res, 400, 'Host header required'); + return; + } + + if (typeof hostHeader === 'string') { + if (hostHeader.includes(',') || /\s/.test(hostHeader)) { + stopRequest(res, 400, 'Invalid Host header'); + return; + } + if (!/^[A-Za-z0-9.\-:\[\]]+$/.test(hostHeader)) { + stopRequest(res, 400, 'Invalid Host header'); + return; + } + } + + if (req.headers && req.headers['content-length'] && req.headers['transfer-encoding']) { + stopRequest(res, 400, 'Conflicting Content-Length and Transfer-Encoding'); + return; + } + + if (req.headers && req.headers['content-length']) { + const contentLength = Number(req.headers['content-length']); + if (!Number.isFinite(contentLength) || contentLength < 0) { + stopRequest(res, 400, 'Invalid Content-Length'); + return; + } + if (contentLength > maxRequestBodyBytes) { + stopRequest(res, 413, 'Payload too large'); + return; + } + } + + if (req.headers) { + for (const [name, value] of Object.entries(req.headers)) { + const headerName = String(name || ''); + const headerValue = Array.isArray(value) ? value.join(',') : String(value || ''); + + if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(headerName)) { + stopRequest(res, 400, 'Invalid header name'); + return; + } + if (/[\0\r\n]/.test(headerValue)) { + stopRequest(res, 400, 'Invalid header value'); + return; + } + if (headerValue.length > maxHeaderBytes) { + stopRequest(res, 431, 'Request header fields too large'); + return; + } + } + } + + if (method === 'TRACE' || method === 'TRACK' || method === 'CONNECT') { + stopRequest(res, 405, 'Method not allowed'); + return; + } + res.emit('next'); + }); + + const accessLogEnabled = options.accessLog === true || options.accessLog === 'true'; + if (accessLogEnabled) { + before.push(function (req, res) { + var startedAt = Date.now(); + res.once('finish', function () { + var durationMs = Date.now() - startedAt; + var remoteAddress = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '-'; + console.log('[access]', remoteAddress, req.method, req.url, res.statusCode, durationMs + 'ms'); + }); + res.emit('next'); + }); + } if (options.logFn) { before.push(function (req, res) { diff --git a/package-lock.json b/package-lock.json index 19e759c5..cb115c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,11 @@ "bin": { "http-server": "bin/http-server" }, + + + + + "devDependencies": { "eol": "^0.9.1", "eslint": "^4.19.1",