diff --git a/index.js b/index.js index 006d49e..c75d5b6 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ const os = require('os'); const jwt = require('jsonwebtoken'); -const http = require('http') -const https = require('https') +const http2express = require('http2-express'); +const httpolyglot = require('@httptoolkit/httpolyglot'); const morgan = require('morgan'); const express = require('express') const cookieParser = require('cookie-parser'); @@ -39,7 +39,7 @@ const metricsMiddleware = promBundle({ metricType: PROMETHEUS_METRIC_TYPE, }); -const app = express() +const app = http2express(express); app.set('json spaces', 2); app.set('trust proxy', trustProxy); @@ -53,6 +53,28 @@ if(process.env.DISABLE_REQUEST_LOGS !== 'true'){ app.use(morgan('combined')); } +// Enforce MAX_HEADER_SIZE at the application level, +// because it's not configurable for HTTP2 in Node :( +// https://github.com/nodejs/node/issues/35218 +app.use(function(req, res, next){ + let totalHeaderSize = 0; + for (const [name, value] of Object.entries(req.headers)) { + totalHeaderSize += Buffer.byteLength(name); + if (Array.isArray(value)) { + for (const v of value) { + totalHeaderSize += Buffer.byteLength(v); + } + } else { + totalHeaderSize += Buffer.byteLength(value); + } + } + if (totalHeaderSize > maxHeaderSize) { + res.status(431).end(); + return; + } + next(); +}); + app.use(function(req, res, next){ req.pipe(concat(function(data){ @@ -78,6 +100,7 @@ app.all('/{*splat}', (req, res) => { path: req.path, headers: req.headers, method: req.method, + url: req.url, body: req.body, cookies: req.cookies, fresh: req.fresh, @@ -85,6 +108,7 @@ app.all('/{*splat}', (req, res) => { ip: req.ip, ips: req.ips, protocol: req.protocol, + httpVersion: req.httpVersion, query: req.query, signedCookies: req.signedCookies, subdomains: req.subdomains, @@ -93,7 +117,7 @@ app.all('/{*splat}', (req, res) => { hostname: os.hostname() }, connection: { - servername: req.connection.servername + servername: req.socket.servername } }; @@ -204,27 +228,35 @@ app.all('/{*splat}', (req, res) => { }); -let httpOpts = { - maxHeaderSize: maxHeaderSize -} -let httpsOpts = { +// plain text http server, http2 server (aka "h2c") +var httpServer = httpolyglot.createServer({ + http: { maxHeaderSize: maxHeaderSize }, + http2: {} // Enable HTTP/2 in polyglot library, but note, it doesn't support max header size. +}, app).listen(process.env.HTTP_PORT || 8080); + +let tlsOpts = { key: require('fs').readFileSync(process.env.HTTPS_KEY_FILE || 'testpk.pem'), cert: require('fs').readFileSync(process.env.HTTPS_CERT_FILE || 'fullchain.pem'), - maxHeaderSize: maxHeaderSize + ALPNProtocols: [ 'h2', 'http/1.1'], }; //Whether to enable the client certificate feature if(process.env.MTLS_ENABLE){ - httpsOpts = { + tlsOpts = { requestCert: true, rejectUnauthorized: false, - ...httpsOpts + ...tlsOpts } } -var httpServer = http.createServer(httpOpts, app).listen(process.env.HTTP_PORT || 8080); -var httpsServer = https.createServer(httpsOpts,app).listen(process.env.HTTPS_PORT || 8443); +// https server, http2 server (aka "h2") +var httpsServer = httpolyglot.createServer({ + tls: tlsOpts, + http: { maxHeaderSize: maxHeaderSize }, + http2: {} // Enable HTTP/2 in polyglot library, but note, it doesn't support max header size. +}, app).listen(process.env.HTTPS_PORT || 8443); + console.log(`Listening on ports ${process.env.HTTP_PORT || 8080} for http, and ${process.env.HTTPS_PORT || 8443} for https.`); let calledClose = false; @@ -232,8 +264,11 @@ let calledClose = false; process.on('exit', function () { if (calledClose) return; console.log('Got exit event. Trying to stop Express server.'); - server.close(function() { - console.log("Express server closed"); + httpServer.close(function() { + console.log("HTTP server closed"); + }); + httpsServer.close(function() { + console.log("HTTPS server closed"); }); }); diff --git a/package-lock.json b/package-lock.json index f49445f..27a3ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "1.0.1", "license": "BSD-3-Clause", "dependencies": { + "@httptoolkit/httpolyglot": "^3.1.0", "concat-stream": "^2.0.0", "cookie-parser": "^1.4.6", "express": "^5.2.1", "express-prom-bundle": "^8.0.0", + "http2-express": "^1.1.1", "jsonwebtoken": "^9.0.0", "morgan": "^1.12.0" }, @@ -20,6 +22,18 @@ "node": ">=16.0.0" } }, + "node_modules/@httptoolkit/httpolyglot": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@httptoolkit/httpolyglot/-/httpolyglot-3.1.0.tgz", + "integrity": "sha512-Y+1gebmcMZMjDepn2e+9TBM4D+t3jYYbOwbXL9/PKmJEcaN/sbpv/00skN9KLXs43+BQvHTZc+5J0AnC5+9qDg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -688,6 +702,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http2-express": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http2-express/-/http2-express-1.1.1.tgz", + "integrity": "sha512-iROf3EIQdJZLNk+/LTkENZp1WB8B+IrYT+z/TPP3/RyGk6w9P9QZNDvs/lo5zAOozNuNYdHewIyWVXXAkBGQ7g==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "express": ">=4.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/package.json b/package.json index 1576fc4..b20bbbc 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ "node": ">=16.0.0" }, "dependencies": { + "@httptoolkit/httpolyglot": "^3.1.0", "concat-stream": "^2.0.0", "cookie-parser": "^1.4.6", "express": "^5.2.1", "express-prom-bundle": "^8.0.0", + "http2-express": "^1.1.1", "jsonwebtoken": "^9.0.0", "morgan": "^1.12.0" }, diff --git a/tests.sh b/tests.sh index d942fa1..9b17519 100755 --- a/tests.sh +++ b/tests.sh @@ -2,6 +2,9 @@ set -euo pipefail +curl --version +docker info + function message { echo "" echo "---------------------------------------------------------------" @@ -14,11 +17,11 @@ RED=$(echo -en '\033[01;31m') GREEN=$(echo -en '\033[01;32m') function failed { - echo "${RED}✗${1}${RESTORE}" + echo "${RED}❌ ${1}${RESTORE}" } function passed { - echo "${GREEN}✓${1}${RESTORE}" + echo "${GREEN}✅ ${1}${RESTORE}" } wait_for_ready() { @@ -173,6 +176,42 @@ else exit 1 fi +message " Check protocol negotiation: h1, h2 (TLS) and h2c (cleartext) " + +HTTP_VERSION_H1=$(curl -sk --http1.1 https://localhost:8443/ | jq -r '.httpVersion') +if [[ "$HTTP_VERSION_H1" == "1.1" ]]; then + passed "HTTP/1.1 over TLS, got back 1.1." +else + failed "HTTP/1.1 over TLS, got back $HTTP_VERSION_H1." + exit 1 +fi + +HTTP_VERSION_H2=$(curl -sk --http2 https://localhost:8443/ | jq -r '.httpVersion') +if [[ "$HTTP_VERSION_H2" == "2.0" ]]; then + passed "HTTP/2 over TLS, got back 2.0." +else + failed "HTTP/2 over TLS, got back $HTTP_VERSION_H2." + exit 1 +fi + +# Default negotiation (no --http1.1/--http2 flag): server ALPN prefers h2 +HTTP_VERSION_DEFAULT=$(curl -sk https://localhost:8443/ | jq -r '.httpVersion') +if [[ "$HTTP_VERSION_DEFAULT" == "2.0" ]]; then + passed "Default TLS negotiation, got back 2.0." +else + failed "Default TLS negotiation, got back $HTTP_VERSION_DEFAULT" + exit 1 +fi + +HTTP_VERSION_H2C=$(curl -s --http2-prior-knowledge http://localhost:8080/ | jq -r '.httpVersion') +if [[ "$HTTP_VERSION_H2C" == "2.0" ]]; then + passed "Cleartext h2c (prior knowledge), got back 2.0." +else + failed "Cleartext h2c (prior knowledge), got back $HTTP_VERSION_H2C." + exit 1 +fi + + message " Make JSON request, and test that json is in the output. " REQUEST=$(curl -s -X POST -H "Content-Type: application/json" -d '{"a":"b"}' http://localhost:8080/) if [[ "$(echo "$REQUEST" | jq -r '.json.a')" == 'b' ]]; then @@ -210,27 +249,25 @@ message " Start container with max header size " docker run -d --rm -e MAX_HEADER_SIZE=1000 --name http-echo-tests -p 8080:8080 -p 8443:8443 -t mendhak/http-https-echo:testing wait_for_ready -message " Make request with a header within limit." -LARGE_HEADER_VALUE=$(head -c 600 &1 || true) -if echo "$REQUEST" | grep -q "HTTP/1.1 431 Request Header Fields Too Large"; then +STATUS_CODE=$(curl -sk -o /dev/null -w "%{http_code}" -H "Large-Header: $LARGE_HEADER_VALUE" https://localhost:8443/) +if [[ "$STATUS_CODE" == "431" ]]; then passed "Large header test resulted in HTTP 431." else - failed "Large header test failed." - echo "$REQUEST" + failed "Large header test failed, got status $STATUS_CODE." exit 1 fi