Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This image is executed as non root by default and is fully compliant with Kubern

- [Basic Usage](#basic-usage)
- [Choose your ports](#choose-your-ports)
- [HTTP/2 support (h2 and h2c)](#http2-support-h2-and-h2c)
- [Use your own certificates](#use-your-own-certificates)
- [Trust additional proxy IPs](#trust-additional-proxy-ips)
- [Decode JWT header](#decode-jwt-header)
Expand Down Expand Up @@ -117,6 +118,39 @@ Now make your request with `Authentication: eyJ...` header (it should also work

And in the output you should see a `jwt` section.


## HTTP/1 and HTTP/2

The server can serve HTTP/2 in addition to HTTP/1.1. The default is HTTP/2 over TLS and HTTP/1.1 over cleartext.
The negotiated version is returned in the `"httpVersion"` field of the response.

HTTP/2 over TLS:

```bash
$ curl -sk https://localhost:8443/ | jq '.httpVersion'
"2.0"

$ curl -sk --http1.1 https://localhost:8443/ | jq '.httpVersion'
"1.1"

$ curl -sk --http2 https://localhost:8443/ | jq '.httpVersion'
"2.0"
```

HTTP/2 over cleartext (h2c) only works with the prior knowledge flag.

```bash
$ curl -sk http://localhost:8080/ | jq '.httpVersion'
"1.1"

$ curl -sk --http1.1 http://localhost:8080/ | jq '.httpVersion'
"1.1"

$ curl -sk --http2-prior-knowledge http://localhost:8080/ | jq '.httpVersion'
"2.0"
```


## Disable ExpressJS log lines

In the log output set the environment variable `DISABLE_REQUEST_LOGS` to true, to disable the specific ExpressJS request log lines. The ones like `::ffff:172.17.0.1 - - [03/Jan/2022:21:31:51 +0000] "GET /xyz HTTP/1.1" 200 423 "-" "curl/7.68.0"`. The JSON output will still appear.
Expand Down Expand Up @@ -294,6 +328,8 @@ By default, the headers in the response body are lowercased. To attempt to prese
docker run -e PRESERVE_HEADER_CASE=true -p 8080:8080 -p 8443:8443 --rm -t mendhak/http-https-echo:41
```

> **Note:** This only has an effect over HTTP/1.1. HTTP/2 requires all header names be lowercase.

## Override the response body with a file

To override the response body with a file, set the environment variable `OVERRIDE_RESPONSE_BODY_FILE_PATH` to a file path.
Expand Down
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
65 changes: 51 additions & 14 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 Expand Up @@ -669,7 +706,7 @@ message " Start container with PRESERVE_HEADER_CASE enabled "
docker run -d -e PRESERVE_HEADER_CASE=true --rm --name http-echo-tests -p 8080:8080 -p 8443:8443 -t mendhak/http-https-echo:testing
wait_for_ready

HEADER_CASE_CHECK=$(curl -s -H "prEseRVe-CaSE: A1b2C3" -H 'x-a-b: 999' -H 'X-a-B: 13' localhost:8080 | jq -r '.headers."prEseRVe-CaSE"')
HEADER_CASE_CHECK=$(curl -s --http1.1 -H "prEseRVe-CaSE: A1b2C3" -H 'x-a-b: 999' -H 'X-a-B: 13' localhost:8080 | jq -r '.headers."prEseRVe-CaSE"')
if [[ "$HEADER_CASE_CHECK" == "A1b2C3" ]]
then
passed "PRESERVE_HEADER_CASE enabled"
Expand Down
Loading