Skip to content
65 changes: 50 additions & 15 deletions index.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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);

Expand All @@ -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){

Expand All @@ -78,13 +100,15 @@ 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,
hostname: req.hostname,
ip: req.ip,
ips: req.ips,
protocol: req.protocol,
httpVersion: req.httpVersion,
query: req.query,
signedCookies: req.signedCookies,
subdomains: req.subdomains,
Expand All @@ -93,7 +117,7 @@ app.all('/{*splat}', (req, res) => {
hostname: os.hostname()
},
connection: {
servername: req.connection.servername
servername: req.socket.servername
}
};

Expand Down Expand Up @@ -204,36 +228,47 @@ 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'],

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default HTTP2, fallback HTTP1

};

//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;

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");
});
});

Expand Down
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
"node": ">=16.0.0"
},
"dependencies": {
"@httptoolkit/httpolyglot": "^3.1.0",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does some fancy header sniffing to figure out HTTP1 vs HTTP2

"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"
},
Expand Down
63 changes: 50 additions & 13 deletions tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

set -euo pipefail

curl --version
docker info

function message {
echo ""
echo "---------------------------------------------------------------"
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 </dev/urandom | base64 | tr -d '\n')
REQUEST=$(curl -s -k -H "Large-Header: $LARGE_HEADER_VALUE" https://localhost:8443/)
message " Make request with a reasonable header size."
REASONABLE_HEADER_VALUE=$(head -c 600 </dev/urandom | base64 | tr -d '\n')
REQUEST=$(curl -s -k -H "Large-Header: $REASONABLE_HEADER_VALUE" https://localhost:8443/)

if [[ "$(echo "$REQUEST" | jq -r '.headers."large-header"')" == "$LARGE_HEADER_VALUE" ]]; then
passed "Large header test passed."
if [[ "$(echo "$REQUEST" | jq -r '.headers."large-header"')" == "$REASONABLE_HEADER_VALUE" ]]; then
passed "Reasonable header test passed."
else
failed "Large header test failed."
failed "Reasonable header test failed."
echo "$REQUEST" | jq
exit 1
fi

message " Make request with a header exceeding limit."
LARGE_HEADER_VALUE=$(head -c 5000 </dev/urandom | base64 | tr -d '\n')
# Do with curl -v and look for "HTTP/1.1 431 Request Header Fields Too Large" output
REQUEST=$(curl -v -k -H "Large-Header: $LARGE_HEADER_VALUE" https://localhost:8443/ 2>&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

Expand Down
Loading