Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nginx: reject requests with unexpected Host header #747

Open
wants to merge 8 commits into
base: next
Choose a base branch
from
Open
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
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ jobs:
docker compose up -d
CONTAINER_NAME=$(docker inspect -f '{{.Name}}' $(docker compose ps -q nginx) | cut -c2-)
docker run --network container:$CONTAINER_NAME \
appropriate/curl -4 --insecure --retry 30 --retry-delay 10 --retry-connrefused https://localhost/ \
appropriate/curl -4 --insecure --retry 30 --retry-delay 10 --retry-connrefused https://localhost/ -H 'Host: local' \
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved
| tee /dev/tty \
| grep -q 'ODK Central'
docker run --network container:$CONTAINER_NAME \
appropriate/curl -4 --insecure --retry 20 --retry-delay 2 --retry-connrefused https://localhost/v1/projects \
appropriate/curl -4 --insecure --retry 20 --retry-delay 2 --retry-connrefused https://localhost/v1/projects -H 'Host: local' \
| tee /dev/tty \
| grep -q '\[\]'
- run:
Expand Down
4 changes: 4 additions & 0 deletions files/nginx/odk.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ server {
listen 443 ssl;
server_name ${CNAME};

if ($http_host != ${CNAME}) {
return 421;
}

ssl_certificate /etc/${SSL_TYPE}/live/${CNAME}/fullchain.pem;
ssl_certificate_key /etc/${SSL_TYPE}/live/${CNAME}/privkey.pem;
ssl_trusted_certificate /etc/${SSL_TYPE}/live/${CNAME}/fullchain.pem;
Expand Down
4 changes: 4 additions & 0 deletions files/nginx/redirector.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ server {
listen 80 default_server reuseport;
listen [::]:80 default_server reuseport;

if ($http_host != ${CNAME}) {
return 421;
}

# Anything requesting this particular URL should be served content from
# Certbot's folder so the HTTP-01 ACME challenges can be completed for the
# HTTPS certificates.
Expand Down
9 changes: 5 additions & 4 deletions files/nginx/setup-odk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ if [ "$SSL_TYPE" = "selfsign" ] && [ ! -s "$SELFSIGN_PATH/privkey.pem" ]; then
-days 3650 -nodes -sha256
fi

CNAME="$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN")"
export CNAME

# start from fresh templates in case ssl type has changed
echo "writing fresh nginx templates..."
# redirector.conf gets deleted if using upstream SSL so copy it back
cp /usr/share/odk/nginx/redirector.conf /etc/nginx/conf.d/redirector.conf

CNAME=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \
envsubst '$CNAME' < /usr/share/odk/nginx/redirector.conf > /etc/nginx/conf.d/redirector.conf
envsubst '$SSL_TYPE $CNAME $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \
< /usr/share/odk/nginx/odk.conf.template \
> /etc/nginx/conf.d/odk.conf
Expand All @@ -49,7 +50,7 @@ else
echo "starting nginx for upstream ssl..."
else
# remove letsencrypt challenge reply, but keep 80 to 443 redirection
perl -i -ne 'print if $. < 7 || $. > 14' /etc/nginx/conf.d/redirector.conf
perl -i -ne 'print if $. < 11 || $. > 18' /etc/nginx/conf.d/redirector.conf
echo "starting nginx for custom ssl and self-signed certs..."
fi
exec nginx -g "daemon off;"
Expand Down
2 changes: 1 addition & 1 deletion test/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ wait_for_http_response 5 localhost:8383/health 200
log "Waiting for mock enketo..."
wait_for_http_response 5 localhost:8005/health 200
log "Waiting for nginx..."
wait_for_http_response 90 localhost:9000 301
wait_for_http_response 90 localhost:9000 421

npm run test:nginx

Expand Down
80 changes: 77 additions & 3 deletions test/test-nginx.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { Readable } = require('stream');
const { assert } = require('chai');

describe('nginx config', () => {
Expand All @@ -12,7 +13,7 @@ describe('nginx config', () => {

// then
assert.equal(res.status, 301);
assert.equal(res.headers.get('location'), 'https://localhost:9000/');
assert.equal(res.headers.get('location'), 'https://odk-nginx.example.test/');
});

it('should serve generated client-config.json', async () => {
Expand Down Expand Up @@ -108,16 +109,34 @@ describe('nginx config', () => {
// then
assert.equal(body['x-forwarded-proto'], 'https');
});

it('should reject HTTP requests with incorrect host header supplied', async () => {
// when
const res = await fetchHttp('/', { headers:{ host:'bad.example.com' } });

console.log('res.location:', res.headers.get('location'));

// then
assert.equal(res.status, 421);
});

it('should reject HTTPS requests with incorrect host header supplied', async () => {
// when
const res = await fetchHttps('/', { headers:{ host:'bad.example.com' } });

// then
assert.equal(res.status, 421);
});
});

function fetchHttp(path, options) {
if(!path.startsWith('/')) throw new Error('Invalid path.');
return fetch(`http://localhost:9000${path}`, { redirect:'manual', ...options });
return fetch(`http://localhost:9000${path}`, options);
}

function fetchHttps(path, options) {
if(!path.startsWith('/')) throw new Error('Invalid path.');
return fetch(`https://localhost:9001${path}`, { redirect:'manual', ...options });
return fetch(`https://localhost:9001${path}`, options);
}

function assertEnketoReceived(...expectedRequests) {
Expand Down Expand Up @@ -146,3 +165,58 @@ async function resetMock(port) {
const res = await fetch(`http://localhost:${port}/reset`);
assert.isTrue(res.ok);
}

// Similar to fetch() but:
//
// 1. do not follow redirects
// 2. allow overriding of fetch's "forbidden" headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
function fetch(url, { body, ...options }={}) {
if(!options.headers) options.headers = {};
if(!options.headers.host) options.headers.host = 'odk-nginx.example.test';

return new Promise((resolve, reject) => {
try {
const req = getProtocolImplFrom(url).request(url, options, res => {
res.on('error', reject);

const body = new Readable({ _read: () => {} });
res.on('error', err => body.destroy(err));
res.on('data', data => body.push(data));
res.on('end', () => body.push(null));

const text = () => new Promise((resolve, reject) => {
const chunks = [];
body.on('error', reject);
body.on('data', data => chunks.push(data))
body.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});

const status = res.statusCode;

resolve({
status,
ok: status >= 200 && status < 300,
statusText: res.statusText,
body,
text,
json: async () => JSON.parse(await text()),
headers: new Headers(res.headers),
});
});
req.on('error', reject);
if(body !== undefined) req.write(body);
req.end();
} catch(err) {
reject(err);
}
});
}

function getProtocolImplFrom(url) {
const { protocol } = new URL(url);
switch(protocol) {
case 'http:': return require('node:http');
case 'https:': return require('node:https');
default: throw new Error(`Unsupported protocol: ${protocol}`);
}
}