From bdd5a7c4b15022e4c08a38c01b2e844670e6ff34 Mon Sep 17 00:00:00 2001 From: mendhak Date: Sun, 6 Sep 2026 22:05:33 +0100 Subject: [PATCH 01/10] Basic HTTP2 working, but may need to add some HTTP2 specific tests --- index.js | 57 ++++++++++++++++++++++++++++++++++++----------- package-lock.json | 26 +++++++++++++++++++++ package.json | 2 ++ tests.sh | 2 +- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/index.js b/index.js index 006d49e..adf664f 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ 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 +41,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); @@ -204,27 +206,53 @@ app.all('/{*splat}', (req, res) => { }); -let httpOpts = { - maxHeaderSize: maxHeaderSize -} - -let httpsOpts = { +// let httpOpts = { +// maxHeaderSize: maxHeaderSize +// } + +// let httpsOpts = { +// key: require('fs').readFileSync(process.env.HTTPS_KEY_FILE || 'testpk.pem'), +// cert: require('fs').readFileSync(process.env.HTTPS_CERT_FILE || 'fullchain.pem'), +// maxHeaderSize: maxHeaderSize +// }; + +// //Whether to enable the client certificate feature +// if(process.env.MTLS_ENABLE){ +// httpsOpts = { +// requestCert: true, +// rejectUnauthorized: false, +// ...httpsOpts +// } +// } + +// var httpServer = http.createServer(httpOpts, app).listen(process.env.HTTP_PORT || 8080); +// var httpsServer = https.createServer(httpsOpts,app).listen(process.env.HTTPS_PORT || 8443); + +// plain text http server, http2 server (aka "h2c") +var httpServer = httpolyglot.createServer({ + http: { maxHeaderSize: maxHeaderSize } +}, 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: [ 'http/1.1', 'h2'], }; //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); +var httpsServer = httpolyglot.createServer({ + tls: tlsOpts, + http: { maxHeaderSize: maxHeaderSize } +}, 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 +260,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..67a47a7 100755 --- a/tests.sh +++ b/tests.sh @@ -68,7 +68,7 @@ if [[ -n "${GITHUB_ACTIONS:-}" ]]; then fi else echo " Local run. Build image " - docker build -t mendhak/http-https-echo:testing . + docker build --no-cache -t mendhak/http-https-echo:testing . fi From c3ebd731b3f6e060952e6fa42c254010852918e4 Mon Sep 17 00:00:00 2001 From: mendhak Date: Mon, 7 Sep 2026 06:41:42 +0100 Subject: [PATCH 02/10] HTTP/2 first --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index adf664f..a183e23 100644 --- a/index.js +++ b/index.js @@ -236,7 +236,7 @@ var httpServer = httpolyglot.createServer({ let tlsOpts = { key: require('fs').readFileSync(process.env.HTTPS_KEY_FILE || 'testpk.pem'), cert: require('fs').readFileSync(process.env.HTTPS_CERT_FILE || 'fullchain.pem'), - ALPNProtocols: [ 'http/1.1', 'h2'], + ALPNProtocols: [ 'h2', 'http/1.1'], }; //Whether to enable the client certificate feature From 1ffc718261fd3389d0504ef84ca86db91ab3e8ca Mon Sep 17 00:00:00 2001 From: mendhak Date: Mon, 7 Sep 2026 06:45:30 +0100 Subject: [PATCH 03/10] Add curl and docker version if needed for troubleshooting --- tests.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests.sh b/tests.sh index 67a47a7..797dbf8 100755 --- a/tests.sh +++ b/tests.sh @@ -2,6 +2,9 @@ set -euo pipefail +curl --version +docker info + function message { echo "" echo "---------------------------------------------------------------" From 862f480402f7fd5e2c5f258fdd5665dbce1bf426 Mon Sep 17 00:00:00 2001 From: mendhak Date: Mon, 7 Sep 2026 06:57:19 +0100 Subject: [PATCH 04/10] Add http version and url property to response --- index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/index.js b/index.js index a183e23..ffa73d7 100644 --- a/index.js +++ b/index.js @@ -80,6 +80,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, @@ -87,6 +88,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, From f8df94dc5e010928fbfa9f6f0e0de150a43d5f79 Mon Sep 17 00:00:00 2001 From: mendhak Date: Mon, 7 Sep 2026 07:07:42 +0100 Subject: [PATCH 05/10] Clean up --- index.js | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/index.js b/index.js index ffa73d7..ce9b198 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,5 @@ 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'); @@ -208,31 +206,11 @@ app.all('/{*splat}', (req, res) => { }); -// let httpOpts = { -// maxHeaderSize: maxHeaderSize -// } - -// let httpsOpts = { -// key: require('fs').readFileSync(process.env.HTTPS_KEY_FILE || 'testpk.pem'), -// cert: require('fs').readFileSync(process.env.HTTPS_CERT_FILE || 'fullchain.pem'), -// maxHeaderSize: maxHeaderSize -// }; - -// //Whether to enable the client certificate feature -// if(process.env.MTLS_ENABLE){ -// httpsOpts = { -// requestCert: true, -// rejectUnauthorized: false, -// ...httpsOpts -// } -// } - -// var httpServer = http.createServer(httpOpts, app).listen(process.env.HTTP_PORT || 8080); -// var httpsServer = https.createServer(httpsOpts,app).listen(process.env.HTTPS_PORT || 8443); // plain text http server, http2 server (aka "h2c") var httpServer = httpolyglot.createServer({ - http: { maxHeaderSize: maxHeaderSize } + http: { maxHeaderSize: maxHeaderSize }, + http2: {} // HTTP/2 in Node doesn't support max header size }, app).listen(process.env.HTTP_PORT || 8080); let tlsOpts = { @@ -250,9 +228,11 @@ if(process.env.MTLS_ENABLE){ } } +// https server, http2 server (aka "h2") var httpsServer = httpolyglot.createServer({ tls: tlsOpts, - http: { maxHeaderSize: maxHeaderSize } + http: { maxHeaderSize: maxHeaderSize }, + http2: {} // HTTP/2 in Node 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.`); From bb596000af635272a885c76d204ac5d31d94ca8b Mon Sep 17 00:00:00 2001 From: mendhak Date: Mon, 7 Sep 2026 07:22:53 +0100 Subject: [PATCH 06/10] Enforce max header size in the code itself due to http2 max header size not being configurable --- index.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/index.js b/index.js index ce9b198..2f2f629 100644 --- a/index.js +++ b/index.js @@ -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){ From 19fa586a4d1395d33dd9e332e2ceabaec5d3239d Mon Sep 17 00:00:00 2001 From: mendhak Date: Mon, 7 Sep 2026 07:23:10 +0100 Subject: [PATCH 07/10] Test for protocol negotiation and adjust for max header size --- tests.sh | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/tests.sh b/tests.sh index 797dbf8..8ef4d69 100755 --- a/tests.sh +++ b/tests.sh @@ -176,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 @@ -227,13 +263,11 @@ fi message " Make request with a header exceeding limit." LARGE_HEADER_VALUE=$(head -c 5000 &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 From 6a799ae75d7dce42f4ce4ae0ee276b47c866b892 Mon Sep 17 00:00:00 2001 From: mendhak Date: Mon, 7 Sep 2026 07:36:10 +0100 Subject: [PATCH 08/10] Using tick and cross emoji, hope this works. Also clearer language with header test --- tests.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests.sh b/tests.sh index 8ef4d69..9b17519 100755 --- a/tests.sh +++ b/tests.sh @@ -17,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() { @@ -71,7 +71,7 @@ if [[ -n "${GITHUB_ACTIONS:-}" ]]; then fi else echo " Local run. Build image " - docker build --no-cache -t mendhak/http-https-echo:testing . + docker build -t mendhak/http-https-echo:testing . fi @@ -249,14 +249,14 @@ 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 Date: Tue, 8 Sep 2026 19:10:14 +0100 Subject: [PATCH 09/10] Use req.socket.servername instead of req.connection.servername because it's deprecated now --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 2f2f629..ccf479b 100644 --- a/index.js +++ b/index.js @@ -117,7 +117,7 @@ app.all('/{*splat}', (req, res) => { hostname: os.hostname() }, connection: { - servername: req.connection.servername + servername: req.socket.servername } }; From abdad510fe2eb265336696288c19aabf356a69d7 Mon Sep 17 00:00:00 2001 From: mendhak Date: Tue, 8 Sep 2026 19:11:46 +0100 Subject: [PATCH 10/10] Clarify the http2 block comment --- index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index ccf479b..c75d5b6 100644 --- a/index.js +++ b/index.js @@ -232,7 +232,7 @@ app.all('/{*splat}', (req, res) => { // plain text http server, http2 server (aka "h2c") var httpServer = httpolyglot.createServer({ http: { maxHeaderSize: maxHeaderSize }, - http2: {} // HTTP/2 in Node doesn't support max header size + 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 = { @@ -254,7 +254,7 @@ if(process.env.MTLS_ENABLE){ var httpsServer = httpolyglot.createServer({ tls: tlsOpts, http: { maxHeaderSize: maxHeaderSize }, - http2: {} // HTTP/2 in Node doesn't support max header size + 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.`);