From 913ebccc4d6fce955c59291759ce5ba38cbd0b0b Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 8 Oct 2024 11:41:13 +0000 Subject: [PATCH 01/36] nginx: reject HTTPS requests with unexpected Host header --- files/nginx/odk.conf.template | 4 ++ test/test-nginx.js | 72 +++++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 663cb874..64969c63 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -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; diff --git a/test/test-nginx.js b/test/test-nginx.js index 3731e027..d9aedad5 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -1,3 +1,4 @@ +const { Readable } = require('stream'); const { assert } = require('chai'); describe('nginx config', () => { @@ -12,7 +13,7 @@ describe('nginx config', () => { // then assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), 'https://localhost:9000/.well-known/acme-challenge'); + assert.equal(res.headers.get('location'), 'https://odk-nginx.example.test/.well-known/acme-challenge'); }); it('well-known should serve from HTTPS', async () => { @@ -29,7 +30,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 () => { @@ -124,16 +125,24 @@ describe('nginx config', () => { // then assert.equal(body['x-forwarded-proto'], 'https'); }); + + it('should reject HTTPS requests with incorrect host header supplied', async () => { + // when + const res = await fetchHttps('/.well-known/acme-challenge', { 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) { @@ -162,3 +171,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}`); + } +} From 00b5bd7ecbf5b90b41a14eb4e6ecf035f5651c86 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 9 Oct 2024 08:05:40 +0000 Subject: [PATCH 02/36] fix test URL --- test/test-nginx.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index d9aedad5..a6181f5f 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -128,7 +128,7 @@ describe('nginx config', () => { it('should reject HTTPS requests with incorrect host header supplied', async () => { // when - const res = await fetchHttps('/.well-known/acme-challenge', { headers:{ host:'bad.example.com' } }); + const res = await fetchHttps('/', { headers:{ host:'bad.example.com' } }); // then assert.equal(res.status, 421); From eb93b15e08035116eebb48f91a7387710e952c84 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 9 Oct 2024 08:17:44 +0000 Subject: [PATCH 03/36] Add check/config for HTTP --- files/nginx/redirector.conf | 4 ++++ files/nginx/setup-odk.sh | 9 +++++---- test/run-tests.sh | 2 +- test/test-nginx.js | 10 ++++++++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 08e33bd5..48b2e8ba 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -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. diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 85520dd5..bb080c9c 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -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 @@ -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;" diff --git a/test/run-tests.sh b/test/run-tests.sh index 3833414f..5ee0cb3d 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -39,7 +39,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 diff --git a/test/test-nginx.js b/test/test-nginx.js index a6181f5f..68eca608 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -126,6 +126,16 @@ describe('nginx config', () => { 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' } }); From c232cb387b840f40f96d929834c0be4c155aef96 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 9 Oct 2024 08:31:04 +0000 Subject: [PATCH 04/36] circle-ci: try changing DOMAIN to localhost --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 547304ae..f61f620c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,7 +14,7 @@ jobs: - run: | echo 'SSL_TYPE=selfsign - DOMAIN=local + DOMAIN=localhost SYSADMIN_EMAIL=no-reply@getodk.org' > .env - run: touch ./files/allow-postgres14-upgrade From c26ee734f5c4607c32e894850efb95d41121cf16 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 9 Oct 2024 08:31:57 +0000 Subject: [PATCH 05/36] circle-ci: revert DOMAIN; force-set Host header --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f61f620c..3c419ca4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,7 +14,7 @@ jobs: - run: | echo 'SSL_TYPE=selfsign - DOMAIN=localhost + DOMAIN=local SYSADMIN_EMAIL=no-reply@getodk.org' > .env - run: touch ./files/allow-postgres14-upgrade @@ -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' \ | 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: From e1c245e435c3a3a870b7a9ecd928312360b7218e Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 15 Oct 2024 08:09:14 +0000 Subject: [PATCH 06/36] capitalise host header --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3c419ca4..01960b5e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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/ -H 'host: local' \ + appropriate/curl -4 --insecure --retry 30 --retry-delay 10 --retry-connrefused https://localhost/ -H 'Host: local' \ | 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 -H 'host: local' \ + appropriate/curl -4 --insecure --retry 20 --retry-delay 2 --retry-connrefused https://localhost/v1/projects -H 'Host: local' \ | tee /dev/tty \ | grep -q '\[\]' - run: From bea939d08501119351b804c07a3accc36cd6bd14 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 10:02:07 +0000 Subject: [PATCH 07/36] revert code changes --- files/nginx/odk.conf.template | 4 ---- files/nginx/redirector.conf | 4 ---- files/nginx/setup-odk.sh | 9 ++++----- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 64969c63..663cb874 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -2,10 +2,6 @@ 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; diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 48b2e8ba..08e33bd5 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -4,10 +4,6 @@ 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. diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index bb080c9c..85520dd5 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -25,13 +25,12 @@ 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 -envsubst '$CNAME' < /usr/share/odk/nginx/redirector.conf > /etc/nginx/conf.d/redirector.conf +cp /usr/share/odk/nginx/redirector.conf /etc/nginx/conf.d/redirector.conf + +CNAME=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \ envsubst '$SSL_TYPE $CNAME $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ < /usr/share/odk/nginx/odk.conf.template \ > /etc/nginx/conf.d/odk.conf @@ -50,7 +49,7 @@ else echo "starting nginx for upstream ssl..." else # remove letsencrypt challenge reply, but keep 80 to 443 redirection - perl -i -ne 'print if $. < 11 || $. > 18' /etc/nginx/conf.d/redirector.conf + perl -i -ne 'print if $. < 7 || $. > 14' /etc/nginx/conf.d/redirector.conf echo "starting nginx for custom ssl and self-signed certs..." fi exec nginx -g "daemon off;" From 20660c973a4e25a8c706114d72382de826933542 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:03:17 +0000 Subject: [PATCH 08/36] wip --- files/nginx/odk.conf.template | 10 +++++++++- files/nginx/redirector.conf | 12 ++++++++++-- files/nginx/setup-odk.sh | 18 +++++++++++++----- nginx.dockerfile | 10 +--------- test/run-tests.sh | 5 ++++- test/test-nginx.js | 12 +++++++++--- 6 files changed, 46 insertions(+), 21 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 663cb874..89c436f0 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -1,6 +1,14 @@ +server { + listen 443 default_server ssl; + server_name _; + + ssl_certificate /etc/nginx/ssl/nginx.default.crt; + ssl_certificate_key /etc/nginx/ssl/nginx.default.key; +} + server { listen 443 ssl; - server_name ${CNAME}; + server_name ${DOMAIN}; ssl_certificate /etc/${SSL_TYPE}/live/${CNAME}/fullchain.pem; ssl_certificate_key /etc/${SSL_TYPE}/live/${CNAME}/privkey.pem; diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 08e33bd5..33b4e9aa 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -1,8 +1,8 @@ server { + server_name ${DOMAIN}; # Listen on plain old HTTP and catch all requests so they can be redirected # to HTTPS instead. - listen 80 default_server reuseport; - listen [::]:80 default_server reuseport; + listen 80 reuseport; # Anything requesting this particular URL should be served content from # Certbot's folder so the HTTP-01 ACME challenges can be completed for the @@ -18,3 +18,11 @@ server { return 301 https://$http_host$request_uri; } } + +server { + server_name _; + # Listen on plain old HTTP and catch all requests so they can be redirected + # to HTTPS instead. + listen 80 default_server; + listen [::]:80 default_server; +} diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 85520dd5..9fd69004 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -9,6 +9,12 @@ fi envsubst < /usr/share/odk/nginx/client-config.json.template > /usr/share/nginx/html/client-config.json +# Generate self-signed keys for incorrect (catch-all) HTTP listeners +mkdir -p /etc/nginx/ssl +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -subj "/" \ + -keyout /etc/nginx/ssl/nginx.default.key \ + -out /etc/nginx/ssl/nginx.default.crt DH_PATH=/etc/dh/nginx.pem if [ "$SSL_TYPE" != "upstream" ] && [ ! -s "$DH_PATH" ]; then @@ -28,12 +34,14 @@ fi # 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 +envsubst '$DOMAIN' \ + < /usr/share/odk/nginx/redirector.conf | + tee /etc/nginx/conf.d/redirector.conf CNAME=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \ -envsubst '$SSL_TYPE $CNAME $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ - < /usr/share/odk/nginx/odk.conf.template \ - > /etc/nginx/conf.d/odk.conf +envsubst '$SSL_TYPE $DOMAIN $CNAME $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ + < /usr/share/odk/nginx/odk.conf.template | + tee /etc/nginx/conf.d/odk.conf if [ "$SSL_TYPE" = "letsencrypt" ]; then echo "starting nginx for letsencrypt..." @@ -49,7 +57,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 $. < 8 || $. > 15' /etc/nginx/conf.d/redirector.conf echo "starting nginx for custom ssl and self-signed certs..." fi exec nginx -g "daemon off;" diff --git a/nginx.dockerfile b/nginx.dockerfile index 5129ac67..44ba917b 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -1,14 +1,6 @@ FROM node:20.17.0-slim AS intermediate -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - git \ - gettext-base \ - && rm -rf /var/lib/apt/lists/* - -COPY ./ ./ -RUN files/prebuild/write-version.sh -RUN files/prebuild/build-frontend.sh +RUN mkdir -p client/dist && echo 'SNAPSHOT' > /tmp/version.txt diff --git a/test/run-tests.sh b/test/run-tests.sh index 0c09276f..0a446256 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -26,6 +26,9 @@ wait_for_http_response() { fi } +log "Stopping any running test services..." +docker_compose stop + log "Starting test services..." docker_compose up --build --detach @@ -34,7 +37,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 421 +wait_for_http_response 90 localhost:9000 404 npm run test:nginx diff --git a/test/test-nginx.js b/test/test-nginx.js index 5c9775d9..b90d58b3 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -114,18 +114,22 @@ describe('nginx config', () => { // when const res = await fetchHttp('/', { headers:{ host:'bad.example.com' } }); - console.log('res.location:', res.headers.get('location')); + console.log('res.headers:', res.headers); + console.log('res.body:', res.body); // then - assert.equal(res.status, 421); + assert.equal(res.status, 420); }); it('should reject HTTPS requests with incorrect host header supplied', async () => { // when const res = await fetchHttps('/', { headers:{ host:'bad.example.com' } }); + console.log('res.headers:', res.headers); + console.log('res.body:', res.body); + // then - assert.equal(res.status, 421); + assert.equal(res.status, 422); }); }); @@ -174,6 +178,8 @@ function fetch(url, { body, ...options }={}) { if(!options.headers) options.headers = {}; if(!options.headers.host) options.headers.host = 'odk-nginx.example.test'; + console.log('fetch()', url, 'headers:', options.headers); + return new Promise((resolve, reject) => { try { const req = getProtocolImplFrom(url).request(url, options, res => { From 4bc9b6f496ff3e0d64c2445200664a0a4f0222a8 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:04:42 +0000 Subject: [PATCH 09/36] revert nginx dockerfile --- nginx.dockerfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nginx.dockerfile b/nginx.dockerfile index 44ba917b..5129ac67 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -1,6 +1,14 @@ FROM node:20.17.0-slim AS intermediate -RUN mkdir -p client/dist && echo 'SNAPSHOT' > /tmp/version.txt +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + gettext-base \ + && rm -rf /var/lib/apt/lists/* + +COPY ./ ./ +RUN files/prebuild/write-version.sh +RUN files/prebuild/build-frontend.sh From 3627e92ed4c3cb53f3c0dec2492e7206705db82a Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:05:46 +0000 Subject: [PATCH 10/36] expand comment --- files/nginx/setup-odk.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 9fd69004..52813628 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -9,7 +9,9 @@ fi envsubst < /usr/share/odk/nginx/client-config.json.template > /usr/share/nginx/html/client-config.json -# Generate self-signed keys for incorrect (catch-all) HTTP listeners +# Generate self-signed keys for incorrect (catch-all) HTTP listeners. This cert +# should never be seen by legitimate users, so it's not a big deal that it's +# self-signed. mkdir -p /etc/nginx/ssl openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -subj "/" \ From b7ebe89123b2e608bcadc19e880edb408c86bff6 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:07:19 +0000 Subject: [PATCH 11/36] 421 --- files/nginx/odk.conf.template | 2 ++ files/nginx/redirector.conf | 2 ++ 2 files changed, 4 insertions(+) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 89c436f0..e160d62b 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -4,6 +4,8 @@ server { ssl_certificate /etc/nginx/ssl/nginx.default.crt; ssl_certificate_key /etc/nginx/ssl/nginx.default.key; + + return 421; } server { diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 33b4e9aa..52bff5a5 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -25,4 +25,6 @@ server { # to HTTPS instead. listen 80 default_server; listen [::]:80 default_server; + + return 421; } From 18dad03788a1b22196b8bc7e1622f5a0c60adeff Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:08:38 +0000 Subject: [PATCH 12/36] untee --- files/nginx/setup-odk.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 52813628..7b1e5c62 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -37,13 +37,13 @@ fi echo "writing fresh nginx templates..." # redirector.conf gets deleted if using upstream SSL so copy it back envsubst '$DOMAIN' \ - < /usr/share/odk/nginx/redirector.conf | - tee /etc/nginx/conf.d/redirector.conf + < /usr/share/odk/nginx/redirector.conf \ + > /etc/nginx/conf.d/redirector.conf CNAME=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \ envsubst '$SSL_TYPE $DOMAIN $CNAME $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ - < /usr/share/odk/nginx/odk.conf.template | - tee /etc/nginx/conf.d/odk.conf + < /usr/share/odk/nginx/odk.conf.template \ + > /etc/nginx/conf.d/odk.conf if [ "$SSL_TYPE" = "letsencrypt" ]; then echo "starting nginx for letsencrypt..." From 5bcc78611ce1b3203419799ee090506d9e7dbe63 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:09:41 +0000 Subject: [PATCH 13/36] 42102 --- test/run-tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/run-tests.sh b/test/run-tests.sh index 0a446256..5ecc5f1b 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -37,7 +37,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 404 +wait_for_http_response 90 localhost:9000 421 npm run test:nginx From 27927082b5354deb84ef7d361b8737767e0a260b Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:23:21 +0000 Subject: [PATCH 14/36] 421 --- test/test-nginx.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index b90d58b3..dc234b33 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -118,7 +118,7 @@ describe('nginx config', () => { console.log('res.body:', res.body); // then - assert.equal(res.status, 420); + assert.equal(res.status, 421); }); it('should reject HTTPS requests with incorrect host header supplied', async () => { @@ -129,7 +129,7 @@ describe('nginx config', () => { console.log('res.body:', res.body); // then - assert.equal(res.status, 422); + assert.equal(res.status, 421); }); }); From eea052d4a5c131ef6d8df2c95182d4f2360c4871 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:28:14 +0000 Subject: [PATCH 15/36] re-order more like next --- files/nginx/redirector.conf | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 52bff5a5..3aa11c68 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -1,8 +1,8 @@ server { - server_name ${DOMAIN}; # Listen on plain old HTTP and catch all requests so they can be redirected # to HTTPS instead. listen 80 reuseport; + server_name ${DOMAIN}; # Anything requesting this particular URL should be served content from # Certbot's folder so the HTTP-01 ACME challenges can be completed for the @@ -20,11 +20,9 @@ server { } server { - server_name _; - # Listen on plain old HTTP and catch all requests so they can be redirected - # to HTTPS instead. listen 80 default_server; listen [::]:80 default_server; + server_name _; return 421; } From 72db5d3f59613940d68e05aa1173184b4c3dd3ba Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:32:49 +0000 Subject: [PATCH 16/36] revert circle config change --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 01960b5e..547304ae 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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/ -H 'Host: local' \ + appropriate/curl -4 --insecure --retry 30 --retry-delay 10 --retry-connrefused https://localhost/ \ | 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 -H 'Host: local' \ + appropriate/curl -4 --insecure --retry 20 --retry-delay 2 --retry-connrefused https://localhost/v1/projects \ | tee /dev/tty \ | grep -q '\[\]' - run: From d4a27ee3590040f94608704235d7e7f436ebaf0f Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 12:36:01 +0000 Subject: [PATCH 17/36] Revert "revert circle config change" This reverts commit 72db5d3f59613940d68e05aa1173184b4c3dd3ba. --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 547304ae..01960b5e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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' \ | 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: From c6c5ce81e086b13179d87fb1c3b480898de1611c Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 16:29:51 +0000 Subject: [PATCH 18/36] remove time limit for generated key --- files/nginx/setup-odk.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 7b1e5c62..fd404ef5 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -13,7 +13,7 @@ envsubst < /usr/share/odk/nginx/client-config.json.template > /usr/share/nginx/h # should never be seen by legitimate users, so it's not a big deal that it's # self-signed. mkdir -p /etc/nginx/ssl -openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ +openssl req -x509 -nodes -newkey rsa:2048 \ -subj "/" \ -keyout /etc/nginx/ssl/nginx.default.key \ -out /etc/nginx/ssl/nginx.default.crt From bb073433ebc967978badd0f6d98af03e602d44cf Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 16:30:40 +0000 Subject: [PATCH 19/36] revert --- test/run-tests.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/run-tests.sh b/test/run-tests.sh index 5ecc5f1b..0c09276f 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -26,9 +26,6 @@ wait_for_http_response() { fi } -log "Stopping any running test services..." -docker_compose stop - log "Starting test services..." docker_compose up --build --detach From b4610366d7a39e6822d2f682c15c410f3c94806a Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 16:31:03 +0000 Subject: [PATCH 20/36] remove logging --- test/test-nginx.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index dc234b33..3868f804 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -114,9 +114,6 @@ describe('nginx config', () => { // when const res = await fetchHttp('/', { headers:{ host:'bad.example.com' } }); - console.log('res.headers:', res.headers); - console.log('res.body:', res.body); - // then assert.equal(res.status, 421); }); @@ -125,9 +122,6 @@ describe('nginx config', () => { // when const res = await fetchHttps('/', { headers:{ host:'bad.example.com' } }); - console.log('res.headers:', res.headers); - console.log('res.body:', res.body); - // then assert.equal(res.status, 421); }); From b1d5f23ef1337bf2034d536d7355ab42b03426cb Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 2 Dec 2024 16:32:15 +0000 Subject: [PATCH 21/36] remove catch-all server names --- files/nginx/odk.conf.template | 1 - files/nginx/redirector.conf | 1 - 2 files changed, 2 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index e160d62b..9804f47f 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -1,6 +1,5 @@ server { listen 443 default_server ssl; - server_name _; ssl_certificate /etc/nginx/ssl/nginx.default.crt; ssl_certificate_key /etc/nginx/ssl/nginx.default.key; diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 3aa11c68..141d12d4 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -22,7 +22,6 @@ server { server { listen 80 default_server; listen [::]:80 default_server; - server_name _; return 421; } From 13808d65156643e72d77d85d3ddfea10cedd0a2a Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 04:45:00 +0000 Subject: [PATCH 22/36] Revert "revert" This reverts commit bb073433ebc967978badd0f6d98af03e602d44cf. --- test/run-tests.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/run-tests.sh b/test/run-tests.sh index 0c09276f..5ecc5f1b 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -26,6 +26,9 @@ wait_for_http_response() { fi } +log "Stopping any running test services..." +docker_compose stop + log "Starting test services..." docker_compose up --build --detach From 0fd9b27e1df8dd821edf86c44766cba1ad99afda Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 05:20:25 +0000 Subject: [PATCH 23/36] Add test for SSL cert validity period. --- test/test-nginx.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/test-nginx.js b/test/test-nginx.js index 3868f804..bfdd9a6d 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -125,6 +125,19 @@ describe('nginx config', () => { // then assert.equal(res.status, 421); }); + + it('should serve long-lived certificate to HTTPS requests with incorrect host header', async () => { + // when + const res = await fetchHttps('/', { headers:{ host:'bad.example.com' } }); + + // then + const validUntilRaw = res.certificate.valid_to; + // Dates look like RFC-822 format - probably direct output of `openssl`. NodeJS Date.parse() + // seems to support this format. + const validUntil = new Date(validUntilRaw); + assert.isFalse(isNaN(validUntil), `Could not parse certificate's valid_to value as a date ('${validUntilRaw}')`); + assert.isAbove(validUntil.getFullYear(), 3000, 'The provided certificate expires too soon.'); + }); }); function fetchHttp(path, options) { @@ -168,6 +181,7 @@ async function resetMock(port) { // // 1. do not follow redirects // 2. allow overriding of fetch's "forbidden" headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name +// 3. allow access to server SSL certificate function fetch(url, { body, ...options }={}) { if(!options.headers) options.headers = {}; if(!options.headers.host) options.headers.host = 'odk-nginx.example.test'; @@ -201,6 +215,7 @@ function fetch(url, { body, ...options }={}) { text, json: async () => JSON.parse(await text()), headers: new Headers(res.headers), + certificate: res.socket.getPeerCertificate?.(), }); }); req.on('error', reject); From c2683ce4c62035b876c60191d871cb43a98b82e1 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 05:22:11 +0000 Subject: [PATCH 24/36] increase ssl cert validity period --- files/nginx/setup-odk.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index fd404ef5..407bba90 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -11,12 +11,13 @@ envsubst < /usr/share/odk/nginx/client-config.json.template > /usr/share/nginx/h # Generate self-signed keys for incorrect (catch-all) HTTP listeners. This cert # should never be seen by legitimate users, so it's not a big deal that it's -# self-signed. +# self-signed and won't expire for 1,000 years. mkdir -p /etc/nginx/ssl openssl req -x509 -nodes -newkey rsa:2048 \ -subj "/" \ -keyout /etc/nginx/ssl/nginx.default.key \ - -out /etc/nginx/ssl/nginx.default.crt + -out /etc/nginx/ssl/nginx.default.crt \ + -days 365000 DH_PATH=/etc/dh/nginx.pem if [ "$SSL_TYPE" != "upstream" ] && [ ! -s "$DH_PATH" ]; then From c9b376ecca61d9284e30ffebf147835727cd7081 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 05:52:11 +0000 Subject: [PATCH 25/36] rewrite test with TLS --- test/test-nginx.js | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index bfdd9a6d..89cd9f04 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -1,3 +1,4 @@ +const TLS = require('node:tls'); const { Readable } = require('stream'); const { assert } = require('chai'); @@ -126,18 +127,25 @@ describe('nginx config', () => { assert.equal(res.status, 421); }); - it('should serve long-lived certificate to HTTPS requests with incorrect host header', async () => { - // when - const res = await fetchHttps('/', { headers:{ host:'bad.example.com' } }); - - // then - const validUntilRaw = res.certificate.valid_to; - // Dates look like RFC-822 format - probably direct output of `openssl`. NodeJS Date.parse() - // seems to support this format. - const validUntil = new Date(validUntilRaw); - assert.isFalse(isNaN(validUntil), `Could not parse certificate's valid_to value as a date ('${validUntilRaw}')`); - assert.isAbove(validUntil.getFullYear(), 3000, 'The provided certificate expires too soon.'); - }); + it('should serve long-lived certificate to HTTPS requests with incorrect host header', () => new Promise((resolve, reject) => { + const socket = TLS.connect(9001, { host:'localhost', servername:'bad.example.com', rejectUnauthorized:false }, () => { + try { + const certificate = socket.getPeerCertificate(); + const validUntilRaw = certificate.valid_to; + + // Dates look like RFC-822 format - probably direct output of `openssl`. NodeJS Date.parse() + // seems to support this format. + const validUntil = new Date(validUntilRaw); + assert.isFalse(isNaN(validUntil), `Could not parse certificate's valid_to value as a date ('${validUntilRaw}')`); + assert.isAbove(validUntil.getFullYear(), 3000, 'The provided certificate expires too soon.'); + socket.end(); + } catch(err) { + socket.destroy(err); + } + }); + socket.on('end', resolve); + socket.on('error', reject); + })); }); function fetchHttp(path, options) { From 7791efea913c28fc22f032d798a18df6c79d3c92 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 05:52:37 +0000 Subject: [PATCH 26/36] little tls --- test/test-nginx.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index 89cd9f04..8559475a 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -1,4 +1,4 @@ -const TLS = require('node:tls'); +const tls = require('node:tls'); const { Readable } = require('stream'); const { assert } = require('chai'); @@ -128,7 +128,7 @@ describe('nginx config', () => { }); it('should serve long-lived certificate to HTTPS requests with incorrect host header', () => new Promise((resolve, reject) => { - const socket = TLS.connect(9001, { host:'localhost', servername:'bad.example.com', rejectUnauthorized:false }, () => { + const socket = tls.connect(9001, { host:'localhost', servername:'bad.example.com', rejectUnauthorized:false }, () => { try { const certificate = socket.getPeerCertificate(); const validUntilRaw = certificate.valid_to; From 6971cda10aa5748ad57265ea2b85e6cc5963656e Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 05:53:33 +0000 Subject: [PATCH 27/36] remove cert from fetch() --- test/test-nginx.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index 8559475a..1288bb02 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -223,7 +223,6 @@ function fetch(url, { body, ...options }={}) { text, json: async () => JSON.parse(await text()), headers: new Headers(res.headers), - certificate: res.socket.getPeerCertificate?.(), }); }); req.on('error', reject); From ba096ccde0d88ead5041c4adea9f6c7d4e1fbeec Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 05:54:10 +0000 Subject: [PATCH 28/36] remove stop --- test/run-tests.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/run-tests.sh b/test/run-tests.sh index 5ecc5f1b..0c09276f 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -26,9 +26,6 @@ wait_for_http_response() { fi } -log "Stopping any running test services..." -docker_compose stop - log "Starting test services..." docker_compose up --build --detach From cc95d47c7b7e476c07f505b50bf9029a6687ed06 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 06:25:06 +0000 Subject: [PATCH 29/36] revert magix perl --- files/nginx/setup-odk.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 407bba90..3d8a872b 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -60,7 +60,7 @@ else echo "starting nginx for upstream ssl..." else # remove letsencrypt challenge reply, but keep 80 to 443 redirection - perl -i -ne 'print if $. < 8 || $. > 15' /etc/nginx/conf.d/redirector.conf + perl -i -ne 'print if $. < 7 || $. > 14' /etc/nginx/conf.d/redirector.conf echo "starting nginx for custom ssl and self-signed certs..." fi exec nginx -g "daemon off;" From 58504953f13d1c587cc497eeefc32fa1b4aa1ded Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 06:30:05 +0000 Subject: [PATCH 30/36] rename CNAME -> CERT_DOMAIN --- files/nginx/odk.conf.template | 6 +++--- files/nginx/setup-odk.sh | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 9804f47f..0eca20e5 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -11,9 +11,9 @@ server { listen 443 ssl; server_name ${DOMAIN}; - 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; + ssl_certificate /etc/${SSL_TYPE}/live/${CERT_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/${SSL_TYPE}/live/${CERT_DOMAIN}/privkey.pem; + ssl_trusted_certificate /etc/${SSL_TYPE}/live/${CERT_DOMAIN}/fullchain.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 3d8a872b..e25e6c26 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -41,8 +41,8 @@ envsubst '$DOMAIN' \ < /usr/share/odk/nginx/redirector.conf \ > /etc/nginx/conf.d/redirector.conf -CNAME=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \ -envsubst '$SSL_TYPE $DOMAIN $CNAME $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ +CERT_DOMAIN=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \ +envsubst '$SSL_TYPE $DOMAIN $CERT_DOMAIN $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ < /usr/share/odk/nginx/odk.conf.template \ > /etc/nginx/conf.d/odk.conf From 0fca92a0ba2e922f5b464061268681726e8f42b6 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Tue, 3 Dec 2024 06:46:06 +0000 Subject: [PATCH 31/36] reorder vars to match #814 --- files/nginx/setup-odk.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index e25e6c26..29baf081 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -42,7 +42,7 @@ envsubst '$DOMAIN' \ > /etc/nginx/conf.d/redirector.conf CERT_DOMAIN=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \ -envsubst '$SSL_TYPE $DOMAIN $CERT_DOMAIN $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ +envsubst '$SSL_TYPE $CERT_DOMAIN $DOMAIN $SENTRY_ORG_SUBDOMAIN $SENTRY_KEY $SENTRY_PROJECT' \ < /usr/share/odk/nginx/odk.conf.template \ > /etc/nginx/conf.d/odk.conf From 0ea720761027b42120b615c7b92975b3139c22a9 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Sun, 8 Dec 2024 08:40:41 +0000 Subject: [PATCH 32/36] update comment more --- files/nginx/setup-odk.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 29baf081..87416c4b 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -9,9 +9,9 @@ fi envsubst < /usr/share/odk/nginx/client-config.json.template > /usr/share/nginx/html/client-config.json -# Generate self-signed keys for incorrect (catch-all) HTTP listeners. This cert -# should never be seen by legitimate users, so it's not a big deal that it's -# self-signed and won't expire for 1,000 years. +# Generate self-signed keys for the incorrect (catch-all) HTTPS listener. This +# cert should never be seen by legitimate users, so it's not a big deal that +# it's self-signed and won't expire for 1,000 years. mkdir -p /etc/nginx/ssl openssl req -x509 -nodes -newkey rsa:2048 \ -subj "/" \ From fb826af2c07b884ab782649af50d461e89f5fc26 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Sun, 8 Dec 2024 08:42:16 +0000 Subject: [PATCH 33/36] rename sfetch() fn to avoid confusion; remove logging --- test/test-nginx.js | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index 1288bb02..799e7e79 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -85,7 +85,7 @@ describe('nginx config', () => { it('should set x-forwarded-proto header to "https"', async () => { // when - const res = await fetch(`https://localhost:9001/v1/reflect-headers`); + const res = await sfetch(`https://localhost:9001/v1/reflect-headers`); // then assert.equal(res.status, 200); @@ -97,7 +97,7 @@ describe('nginx config', () => { it('should override supplied x-forwarded-proto header', async () => { // when - const res = await fetch(`https://localhost:9001/v1/reflect-headers`, { + const res = await sfetch(`https://localhost:9001/v1/reflect-headers`, { headers: { 'x-forwarded-proto': 'http', }, @@ -150,12 +150,12 @@ describe('nginx config', () => { function fetchHttp(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return fetch(`http://localhost:9000${path}`, options); + return sfetch(`http://localhost:9000${path}`, options); } function fetchHttps(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return fetch(`https://localhost:9001${path}`, options); + return sfetch(`https://localhost:9001${path}`, options); } function assertEnketoReceived(...expectedRequests) { @@ -167,7 +167,7 @@ function assertBackendReceived(...expectedRequests) { } async function assertMockHttpReceived(port, expectedRequests) { - const res = await fetch(`http://localhost:${port}/request-log`); + const res = await sfetch(`http://localhost:${port}/request-log`); assert.isTrue(res.ok); assert.deepEqual(expectedRequests, await res.json()); } @@ -181,7 +181,7 @@ function resetBackendMock() { } async function resetMock(port) { - const res = await fetch(`http://localhost:${port}/reset`); + const res = await sfetch(`http://localhost:${port}/reset`); assert.isTrue(res.ok); } @@ -190,12 +190,10 @@ async function resetMock(port) { // 1. do not follow redirects // 2. allow overriding of fetch's "forbidden" headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name // 3. allow access to server SSL certificate -function fetch(url, { body, ...options }={}) { +function sfetch(url, { body, ...options }={}) { if(!options.headers) options.headers = {}; if(!options.headers.host) options.headers.host = 'odk-nginx.example.test'; - console.log('fetch()', url, 'headers:', options.headers); - return new Promise((resolve, reject) => { try { const req = getProtocolImplFrom(url).request(url, options, res => { From a03694da42cfd3c136c4ee83dd68460720f604e6 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Sun, 8 Dec 2024 08:58:33 +0000 Subject: [PATCH 34/36] wip: add failing test: ipv6 --- test/test-nginx.js | 83 ++++++++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index 799e7e79..379a1848 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -1,3 +1,4 @@ +const http = require('node:http'); const tls = require('node:tls'); const { Readable } = require('stream'); const { assert } = require('chai'); @@ -17,6 +18,15 @@ describe('nginx config', () => { assert.equal(res.headers.get('location'), 'https://odk-nginx.example.test/'); }); + it('should forward HTTP to HTTPS (ipv6)', async () => { + // when + const res = await fetchHttp6('/'); + + // then + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), 'https://odk-nginx.example.test/'); + }); + it('should serve generated client-config.json', async () => { // when const res = await fetchHttps('/client-config.json'); @@ -153,6 +163,14 @@ function fetchHttp(path, options) { return sfetch(`http://localhost:9000${path}`, options); } +function fetchHttp6(path) { + if(!path.startsWith('/')) throw new Error('Invalid path.'); + return request(http, { + host: `::1:9000`, + path, + }); +} + function fetchHttps(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); return sfetch(`https://localhost:9001${path}`, options); @@ -194,48 +212,57 @@ function sfetch(url, { body, ...options }={}) { if(!options.headers) options.headers = {}; if(!options.headers.host) options.headers.host = 'odk-nginx.example.test'; + const protocolImpl = getProtocolImplFrom(url); + return request(protocolImpl, options, body, url); +} + +function request(protocolImpl, options, body, url) { 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), - }); - }); + const req = url === undefined ? + protocolImpl.request(options, processResult) : + protocolImpl.request(url, options, processResult); req.on('error', reject); if(body !== undefined) req.write(body); req.end(); } catch(err) { reject(err); } + + function processResult(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), + }); + }; }); } function getProtocolImplFrom(url) { const { protocol } = new URL(url); switch(protocol) { - case 'http:': return require('node:http'); + case 'http:': return http; case 'https:': return require('node:https'); default: throw new Error(`Unsupported protocol: ${protocol}`); } From c41b7cb47876ade7d3cf6c1f970dc23bfbdd56ec Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Sun, 8 Dec 2024 09:33:17 +0000 Subject: [PATCH 35/36] Passing tests --- files/nginx/redirector.conf | 1 + test/test-nginx.js | 114 +++++++++++++++++++++--------------- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 141d12d4..ba56723f 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -2,6 +2,7 @@ server { # Listen on plain old HTTP and catch all requests so they can be redirected # to HTTPS instead. listen 80 reuseport; + listen [::]:80 reuseport; server_name ${DOMAIN}; # Anything requesting this particular URL should be served content from diff --git a/test/test-nginx.js b/test/test-nginx.js index 379a1848..efdbd9d6 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -1,4 +1,3 @@ -const http = require('node:http'); const tls = require('node:tls'); const { Readable } = require('stream'); const { assert } = require('chai'); @@ -18,7 +17,7 @@ describe('nginx config', () => { assert.equal(res.headers.get('location'), 'https://odk-nginx.example.test/'); }); - it('should forward HTTP to HTTPS (ipv6)', async () => { + it('should forward HTTP to HTTPS (IPv6)', async () => { // when const res = await fetchHttp6('/'); @@ -37,6 +36,16 @@ describe('nginx config', () => { assert.equal(await res.headers.get('cache-control'), 'no-cache'); }); + it('should serve generated client-config.json (IPv6)', async () => { + // when + const res = await fetchHttps6('/client-config.json'); + + // then + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { oidcEnabled: false }); + assert.equal(await res.headers.get('cache-control'), 'no-cache'); + }); + [ [ '/index.html', /
<\/div>/ ], [ '/version.txt', /^versions:/ ], @@ -95,7 +104,7 @@ describe('nginx config', () => { it('should set x-forwarded-proto header to "https"', async () => { // when - const res = await sfetch(`https://localhost:9001/v1/reflect-headers`); + const res = await fetchHttps('/v1/reflect-headers'); // then assert.equal(res.status, 200); @@ -107,7 +116,7 @@ describe('nginx config', () => { it('should override supplied x-forwarded-proto header', async () => { // when - const res = await sfetch(`https://localhost:9001/v1/reflect-headers`, { + const res = await fetchHttps('/v1/reflect-headers', { headers: { 'x-forwarded-proto': 'http', }, @@ -129,6 +138,14 @@ describe('nginx config', () => { assert.equal(res.status, 421); }); + it('should reject HTTP requests with incorrect host header supplied (IPv6)', async () => { + // when + const res = await fetchHttp6('/', { headers:{ host:'bad.example.com' } }); + + // 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' } }); @@ -137,6 +154,14 @@ describe('nginx config', () => { assert.equal(res.status, 421); }); + it('should reject HTTPS requests with incorrect host header supplied (IPv6)', async () => { + // when + const res = await fetchHttps6('/', { headers:{ host:'bad.example.com' } }); + + // then + assert.equal(res.status, 421); + }); + it('should serve long-lived certificate to HTTPS requests with incorrect host header', () => new Promise((resolve, reject) => { const socket = tls.connect(9001, { host:'localhost', servername:'bad.example.com', rejectUnauthorized:false }, () => { try { @@ -160,20 +185,22 @@ describe('nginx config', () => { function fetchHttp(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return sfetch(`http://localhost:9000${path}`, options); + return sfetch(`http://127.0.0.1:9000${path}`, options); } -function fetchHttp6(path) { +function fetchHttp6(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return request(http, { - host: `::1:9000`, - path, - }); + return sfetch(`http://[::1]:9000${path}`, options); } function fetchHttps(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return sfetch(`https://localhost:9001${path}`, options); + return sfetch(`https://127.0.0.1:9001${path}`, options); +} + +function fetchHttps6(path, options) { + if(!path.startsWith('/')) throw new Error('Invalid path.'); + return sfetch(`https://[::1]:9001${path}`, options); } function assertEnketoReceived(...expectedRequests) { @@ -212,57 +239,48 @@ function sfetch(url, { body, ...options }={}) { if(!options.headers) options.headers = {}; if(!options.headers.host) options.headers.host = 'odk-nginx.example.test'; - const protocolImpl = getProtocolImplFrom(url); - return request(protocolImpl, options, body, url); -} - -function request(protocolImpl, options, body, url) { return new Promise((resolve, reject) => { try { - const req = url === undefined ? - protocolImpl.request(options, processResult) : - protocolImpl.request(url, options, processResult); + 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 processResult(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), - }); - }; }); } function getProtocolImplFrom(url) { const { protocol } = new URL(url); switch(protocol) { - case 'http:': return http; + case 'http:': return require('node:http'); case 'https:': return require('node:https'); default: throw new Error(`Unsupported protocol: ${protocol}`); } From d7ad2d7e4d515b04da59d61f932afab1fc078245 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Sun, 8 Dec 2024 09:33:49 +0000 Subject: [PATCH 36/36] rename sfetch() as request() --- test/test-nginx.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/test-nginx.js b/test/test-nginx.js index efdbd9d6..0c38aa6c 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -185,22 +185,22 @@ describe('nginx config', () => { function fetchHttp(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return sfetch(`http://127.0.0.1:9000${path}`, options); + return request(`http://127.0.0.1:9000${path}`, options); } function fetchHttp6(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return sfetch(`http://[::1]:9000${path}`, options); + return request(`http://[::1]:9000${path}`, options); } function fetchHttps(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return sfetch(`https://127.0.0.1:9001${path}`, options); + return request(`https://127.0.0.1:9001${path}`, options); } function fetchHttps6(path, options) { if(!path.startsWith('/')) throw new Error('Invalid path.'); - return sfetch(`https://[::1]:9001${path}`, options); + return request(`https://[::1]:9001${path}`, options); } function assertEnketoReceived(...expectedRequests) { @@ -212,7 +212,7 @@ function assertBackendReceived(...expectedRequests) { } async function assertMockHttpReceived(port, expectedRequests) { - const res = await sfetch(`http://localhost:${port}/request-log`); + const res = await request(`http://localhost:${port}/request-log`); assert.isTrue(res.ok); assert.deepEqual(expectedRequests, await res.json()); } @@ -226,7 +226,7 @@ function resetBackendMock() { } async function resetMock(port) { - const res = await sfetch(`http://localhost:${port}/reset`); + const res = await request(`http://localhost:${port}/reset`); assert.isTrue(res.ok); } @@ -235,7 +235,7 @@ async function resetMock(port) { // 1. do not follow redirects // 2. allow overriding of fetch's "forbidden" headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name // 3. allow access to server SSL certificate -function sfetch(url, { body, ...options }={}) { +function request(url, { body, ...options }={}) { if(!options.headers) options.headers = {}; if(!options.headers.host) options.headers.host = 'odk-nginx.example.test';