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: diff --git a/.github/workflows/test-nginx.yml b/.github/workflows/test-nginx.yml index ee56a757..cd1349c8 100644 --- a/.github/workflows/test-nginx.yml +++ b/.github/workflows/test-nginx.yml @@ -16,7 +16,7 @@ jobs: submodules: recursive - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 22.12.0 - run: cd test && npm i - run: cd test && ./run-tests.sh diff --git a/client b/client index 99b11a8c..6ba4ef11 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 99b11a8cc7d30dbc03376f97ab345dc0d18c2a98 +Subproject commit 6ba4ef1183d2081e1da6f9921806c03df8483f97 diff --git a/docker-compose.yml b/docker-compose.yml index 1d70fdfc..97b7ac65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,7 +108,7 @@ services: options: max-file: "30" pyxform: - image: 'ghcr.io/getodk/pyxform-http:v2.1.1' + image: 'ghcr.io/getodk/pyxform-http:v3.0.0' restart: always secrets: volumes: diff --git a/enketo.dockerfile b/enketo.dockerfile index c4b03466..efd47403 100644 --- a/enketo.dockerfile +++ b/enketo.dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/enketo/enketo:7.4.0 +FROM ghcr.io/enketo/enketo:7.5.0 ENV ENKETO_SRC_DIR=/srv/src/enketo/packages/enketo-express WORKDIR ${ENKETO_SRC_DIR} diff --git a/files/nginx/common-headers.conf b/files/nginx/common-headers.conf index 7661bb01..7fa6eefa 100644 --- a/files/nginx/common-headers.conf +++ b/files/nginx/common-headers.conf @@ -6,7 +6,7 @@ # They are included here to ease interpretation of violation reports. # # N.B. a separate CSP is defined for Enketo in odk.conf.template -add_header Content-Security-Policy-Report-Only "default-src 'none'; connect-src 'self'; font-src 'self'; frame-src 'self' https://getodk.github.io/central/news.html; img-src *; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; report-uri /csp-report"; +add_header Content-Security-Policy-Report-Only "default-src 'none'; connect-src 'self'; font-src 'self'; frame-src 'self' https://getodk.github.io/central/news.html; img-src * data:; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; report-uri /csp-report"; # If changing these headers, please apply the same updates to enketo # location(s) in odk.conf.template diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 663cb874..8f268ae4 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -1,10 +1,19 @@ +server { + listen 443 default_server ssl; + + ssl_certificate /etc/nginx/ssl/nginx.default.crt; + ssl_certificate_key /etc/nginx/ssl/nginx.default.key; + + return 421; +} + 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; - 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; @@ -22,7 +31,7 @@ server { gzip_vary on; gzip_min_length 1280; gzip_http_version 1.1; - gzip_types text/plain text/css application/json application/x-javascript text/xml text/csv; + gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml text/csv; location = /robots.txt { add_header Content-Type text/plain; @@ -36,7 +45,7 @@ server { # More lax CSP for enketo-express: # Google Maps API: https://developers.google.com/maps/documentation/javascript/content-security-policy - add_header Content-Security-Policy-Report-Only "default-src 'none'; connect-src 'self' blob: https://maps.googleapis.com/maps/ https://maps.google.com/ https://maps.gstatic.com/mapfiles/ https://fonts.gstatic.com/ https://fonts.googleapis.com/; font-src 'self' https://fonts.gstatic.com/; frame-src 'none'; img-src data: blob: jr: 'self' https://maps.google.com/maps/ https://maps.gstatic.com/mapfiles/ https://maps.googleapis.com/maps/; manifest-src 'none'; media-src blob: jr: 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' https://maps.googleapis.com/maps/api/js/ https://maps.google.com/maps/ https://maps.google.com/maps-api-v3/api/js/; style-src 'unsafe-inline' 'self' https://fonts.googleapis.com/css; style-src-attr 'none'; report-uri /csp-report"; + add_header Content-Security-Policy-Report-Only "default-src 'none'; connect-src 'self' blob: https://maps.googleapis.com/maps/ https://maps.google.com/ https://maps.gstatic.com/mapfiles/ https://fonts.gstatic.com/ https://fonts.googleapis.com/; font-src 'self' https://fonts.gstatic.com/; frame-src 'none'; img-src data: blob: jr: 'self' https://maps.google.com/maps/ https://maps.gstatic.com/mapfiles/ https://maps.googleapis.com/maps/; manifest-src 'none'; media-src blob: jr: 'self'; object-src 'none'; script-src 'unsafe-inline' 'self' https://maps.googleapis.com/maps/api/js/ https://maps.google.com/maps/ https://maps.google.com/maps-api-v3/api/js/; style-src 'unsafe-inline' 'self' https://fonts.googleapis.com/css; style-src-attr 'unsafe-inline'; report-uri /csp-report"; # # Rules set to 'none' here would fallback to default-src if excluded. # They are included here to ease interpretation of violation reports. diff --git a/files/nginx/redirector.conf b/files/nginx/redirector.conf index 08e33bd5..ba56723f 100644 --- a/files/nginx/redirector.conf +++ b/files/nginx/redirector.conf @@ -1,8 +1,9 @@ server { # 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; + 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 @@ -18,3 +19,10 @@ server { return 301 https://$http_host$request_uri; } } + +server { + listen 80 default_server; + listen [::]:80 default_server; + + return 421; +} diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 85520dd5..87416c4b 100644 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -9,6 +9,15 @@ fi envsubst < /usr/share/odk/nginx/client-config.json.template > /usr/share/nginx/html/client-config.json +# 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 "/" \ + -keyout /etc/nginx/ssl/nginx.default.key \ + -out /etc/nginx/ssl/nginx.default.crt \ + -days 365000 DH_PATH=/etc/dh/nginx.pem if [ "$SSL_TYPE" != "upstream" ] && [ ! -s "$DH_PATH" ]; then @@ -28,10 +37,12 @@ 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 \ + > /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' \ +CERT_DOMAIN=$( [ "$SSL_TYPE" = "customssl" ] && echo "local" || echo "$DOMAIN") \ +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 diff --git a/files/prebuild/build-frontend.sh b/files/prebuild/build-frontend.sh index 6122c321..699d7d09 100755 --- a/files/prebuild/build-frontend.sh +++ b/files/prebuild/build-frontend.sh @@ -1,4 +1,18 @@ #!/bin/bash -eu + cd client -npm clean-install --no-audit --fund=false --update-notifier=false -npm run build + +if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then + echo "[build-frontend] Skipping frontend build." + + # Create minimal fake frontend to allow tests to pass: + mkdir -p dist + echo > dist/blank.html + echo > dist/favicon.ico + echo > dist/index.html '
' + + exit +else + npm clean-install --no-audit --fund=false --update-notifier=false + npm run build +fi diff --git a/nginx.dockerfile b/nginx.dockerfile index 5129ac67..fb66d63f 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-slim AS intermediate +FROM node:22.12.0-slim AS intermediate RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -8,6 +8,8 @@ RUN apt-get update \ COPY ./ ./ RUN files/prebuild/write-version.sh + +ARG SKIP_FRONTEND_BUILD RUN files/prebuild/build-frontend.sh diff --git a/secrets.dockerfile b/secrets.dockerfile index aef60b73..0a53585a 100644 --- a/secrets.dockerfile +++ b/secrets.dockerfile @@ -1,3 +1,3 @@ -FROM node:20.17.0-slim +FROM node:22.12.0-slim COPY files/enketo/generate-secrets.sh ./ diff --git a/server b/server index 63ca7881..92f1186a 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 63ca7881f6e6eb0b5c9051bf64448d802720f100 +Subproject commit 92f1186a3257393ba46ea8ede3dc75803eb15c4d diff --git a/service.dockerfile b/service.dockerfile index a2ede997..7b947419 100644 --- a/service.dockerfile +++ b/service.dockerfile @@ -1,4 +1,4 @@ -ARG node_version=20.17.0 +ARG node_version=22.12.0 @@ -54,7 +54,7 @@ RUN apt-get update \ postgresql-client-14 \ netcat-traditional \ && rm -rf /var/lib/apt/lists/* \ - && npm clean-install --omit=dev --legacy-peer-deps --no-audit \ + && npm clean-install --omit=dev --no-audit \ --fund=false --update-notifier=false COPY server/ ./ diff --git a/test/mock-http-server/package-lock.json b/test/mock-http-server/package-lock.json index 75660bbe..7f68446c 100644 --- a/test/mock-http-server/package-lock.json +++ b/test/mock-http-server/package-lock.json @@ -95,9 +95,9 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -194,16 +194,16 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -217,7 +217,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -232,6 +232,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/finalhandler": { @@ -485,9 +489,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/proxy-addr": { "version": "2.0.7", diff --git a/test/nginx.test.docker-compose.yml b/test/nginx.test.docker-compose.yml index f7af4ace..4a863054 100644 --- a/test/nginx.test.docker-compose.yml +++ b/test/nginx.test.docker-compose.yml @@ -17,6 +17,8 @@ services: build: context: .. dockerfile: nginx.dockerfile + args: + SKIP_FRONTEND_BUILD: true depends_on: - service - enketo diff --git a/test/run-tests.sh b/test/run-tests.sh index f9ee7828..0c09276f 100755 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -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 diff --git a/test/test-nginx.js b/test/test-nginx.js index 2125adc7..a367d7f5 100644 --- a/test/test-nginx.js +++ b/test/test-nginx.js @@ -1,3 +1,5 @@ +const tls = require('node:tls'); +const { Readable } = require('stream'); const { assert } = require('chai'); describe('nginx config', () => { @@ -12,7 +14,16 @@ 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 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 () => { @@ -25,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', /