From 6c764ea4b000658b00ae587c25bd00269e85b4dd Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:08:19 +0300 Subject: [PATCH 01/59] ci/test-nginx: show mock-sentry logs (#1816) --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e91da08db..656db878a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,8 @@ jobs: run: cd test/nginx && docker compose -f nginx.test.docker-compose.yml logs --no-log-prefix service - if: always() run: cd test/nginx && docker compose -f nginx.test.docker-compose.yml logs --no-log-prefix enketo + - if: always() + run: cd test/nginx && docker compose -f nginx.test.docker-compose.yml logs --no-log-prefix sentry-mock test-secrets: timeout-minutes: 2 runs-on: ubuntu-latest From 0379dcf4a703f02e9298de1cd885fc8ba5c35fc6 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:15:33 +0300 Subject: [PATCH 02/59] ci/ghcr: update deprecated docker action versions (#1821) Closes #1818 --- .github/workflows/ghcr.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml index 1a935ac22..6350eeb7f 100644 --- a/.github/workflows/ghcr.yml +++ b/.github/workflows/ghcr.yml @@ -29,7 +29,7 @@ jobs: submodules: recursive - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -40,18 +40,18 @@ jobs: - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/central-${{ matrix.image }} - name: Set up QEMU emulator for multi-arch images - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build and push ${{ matrix.image }} Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: file: ${{ matrix.image }}.dockerfile context: . From ae72624ed6f638d1b582bef48789c24bce05a954 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:28:12 +0300 Subject: [PATCH 03/59] test/nginx/csp: rename backend-strict policy (#1823) Previously called `disallow-all`; rename to `backend-strict` to better reflect its usage rather than current implementation details. --- test/nginx/src/mocha/nginx.spec.js | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 494b59652..aa0f93104 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -41,6 +41,22 @@ const allowGoogleTranslate = ({ 'connect-src':connectSrc, 'img-src':imgSrc, ...o }; const contentSecurityPolicies = { + 'backend-strict': { + block: { + 'default-src': 'NOTE:FROM-BACKEND:block', + 'form-action': 'NOTE:FROM-BACKEND:block', + 'frame-ancestors': 'NOTE:FROM-BACKEND:block', + }, + reportOnly: { + 'default-src': [ + reportSample, + none, + ], + 'form-action': none, + 'frame-ancestors': none, + 'report-uri': '/csp-report', + }, + }, 'backend-unmodified': { block: { 'default-src': 'NOTE:FROM-BACKEND:block', @@ -104,22 +120,6 @@ const contentSecurityPolicies = { 'report-uri': '/csp-report', }), }, - 'disallow-all': { - block: { - 'default-src': 'NOTE:FROM-BACKEND:block', - 'form-action': 'NOTE:FROM-BACKEND:block', - 'frame-ancestors': 'NOTE:FROM-BACKEND:block', - }, - reportOnly: { - 'default-src': [ - reportSample, - none, - ], - 'form-action': none, - 'frame-ancestors': none, - 'report-uri': '/csp-report', - }, - }, enketo: { block: { 'default-src': 'NOTE:FROM-BACKEND:block', @@ -652,7 +652,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); assert.equal(await res.text(), 'OK'); - assertSecurityHeaders(res, { csp:'disallow-all' }); + assertSecurityHeaders(res, { csp:'backend-strict' }); // and await assertBackendReceived( { method:'GET', path:'/v1/some/central-backend/path' }, @@ -674,7 +674,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward const res = await apiFetch('/v1/reflect-headers'); // then assert.equal(res.status, 200); - assertSecurityHeaders(res, { csp:'disallow-all' }); + assertSecurityHeaders(res, { csp:'backend-strict' }); // when const body = await res.json(); @@ -692,7 +692,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); // and - assertSecurityHeaders(res, { csp:'disallow-all' }); + assertSecurityHeaders(res, { csp:'backend-strict' }); // when const body = await res.json(); From 963b9dcc9c0972881d0641643a47e994c5e82518 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:32:44 +0300 Subject: [PATCH 04/59] ci: only publish containers if tests passed (#1820) Closes #1819 --- .github/workflows/ghcr.yml | 61 ---------------------- .github/workflows/{test.yml => main.yml} | 64 +++++++++++++++++++++++- 2 files changed, 63 insertions(+), 62 deletions(-) delete mode 100644 .github/workflows/ghcr.yml rename .github/workflows/{test.yml => main.yml} (59%) diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml deleted file mode 100644 index 6350eeb7f..000000000 --- a/.github/workflows/ghcr.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: GHCR - -on: - workflow_dispatch: - push: - branches: [master] - tags: ["v*.*.*"] - -env: - REGISTRY: ghcr.io - -jobs: - build-push-image: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - strategy: - matrix: - image: [nginx, service] - - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - fetch-tags: true - submodules: recursive - - - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Show Docker Context - run: ./test/check-docker-context.sh --report - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/central-${{ matrix.image }} - - - name: Set up QEMU emulator for multi-arch images - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Build and push ${{ matrix.image }} Docker image - uses: docker/build-push-action@v7 - with: - file: ${{ matrix.image }}.dockerfile - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: 'linux/amd64,linux/arm64' diff --git a/.github/workflows/test.yml b/.github/workflows/main.yml similarity index 59% rename from .github/workflows/test.yml rename to .github/workflows/main.yml index 656db878a..d15cef75b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/main.yml @@ -1,8 +1,15 @@ -name: Test +name: Test, Build, Publish on: push: pull_request: + workflow_dispatch: + inputs: + publish_image: + description: 'Publish image to registry?' + required: true + type: boolean + default: false jobs: test-misc: # quick, simple checks @@ -85,3 +92,58 @@ jobs: - run: ./test/test-images.sh - if: always() run: docker compose logs + build-push-image: + if: | + (github.event_name == 'workflow_dispatch' && inputs.publish_image == true) || + (github.event_name != 'workflow_dispatch' && ( + github.ref == 'refs/heads/master' || + startsWith(github.ref, 'refs/tags/v') + )) + needs: + - test-images + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + image: [nginx, service] + env: + REGISTRY: ghcr.io + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + submodules: recursive + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Show Docker Context + run: ./test/check-docker-context.sh --report + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/central-${{ matrix.image }} + + - name: Set up QEMU emulator for multi-arch images + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and push ${{ matrix.image }} Docker image + uses: docker/build-push-action@v7 + with: + file: ${{ matrix.image }}.dockerfile + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: 'linux/amd64,linux/arm64' From 94aa90443695fd6736a29926eca9e728c15f83bf Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:20:22 +0300 Subject: [PATCH 05/59] test/nginx/csp: remove duplicate test (#1860) Looks like a merge error. --- test/nginx/src/mocha/nginx.spec.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index aa0f93104..a80b15f31 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -259,10 +259,6 @@ describe('Content-Security-Policy definitions', () => { const policy = policies[headerType]; if(!policy) continue; - it(`should have required directives: ${requiredDirectives}`, () => { - assert.containsAllKeys(policy, requiredDirectives); - }); - describe(`header: ${headerNames[headerType]}`, () => { it(`should have required directives: ${requiredDirectives}`, () => { assert.containsAllKeys(policy, requiredDirectives); From 1431cb12ddac8b5835cd3d95d702d358827e650c Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:22:22 +0300 Subject: [PATCH 06/59] nginx/csp: blank.html: allow form-action 'self' (#1857) Closes https://github.com/getodk/central/issues/1856 --- files/nginx/odk.conf.template | 2 +- test/nginx/src/mocha/nginx.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 81418c14c..4c7139952 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -193,7 +193,7 @@ server { root /usr/share/nginx/html; try_files /blank.html =404; - add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; form-action 'none'; frame-ancestors 'self'; img-src 'self' https://translate.google.com; report-uri /csp-report" always; + add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; form-action 'self'; frame-ancestors 'self'; img-src 'self' https://translate.google.com; report-uri /csp-report" always; include /usr/share/odk/nginx/common-headers.conf; } location = /blank.html { diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index a80b15f31..3ce456b02 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -75,7 +75,7 @@ const contentSecurityPolicies = { reportSample, none, ], - 'form-action': none, + 'form-action': self, // allow decrypted zip downloads from central-frontend 'frame-ancestors': self, 'img-src': self, // allow favicon.ico 'report-uri': '/csp-report', From e6df530a4f16ac170854debda78b6149f417d319 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:35:58 +0300 Subject: [PATCH 07/59] nginx/csp: enforce policy for blank.html (#1858) Switch from Content-Security-Policy-Report-Only to Content-Security-Policy. --- files/nginx/odk.conf.template | 2 +- test/nginx/src/mocha/nginx.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 4c7139952..9e3a7528b 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -193,7 +193,7 @@ server { root /usr/share/nginx/html; try_files /blank.html =404; - add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; form-action 'self'; frame-ancestors 'self'; img-src 'self' https://translate.google.com; report-uri /csp-report" always; + add_header Content-Security-Policy "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; form-action 'self'; frame-ancestors 'self'; img-src 'self' https://translate.google.com; report-uri /csp-report" always; include /usr/share/odk/nginx/common-headers.conf; } location = /blank.html { diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 3ce456b02..821affec6 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -70,7 +70,7 @@ const contentSecurityPolicies = { }, }, 'blank-html': { - reportOnly: allowGoogleTranslate({ + block: allowGoogleTranslate({ 'default-src': [ reportSample, none, From 9122bb2e1cabdd09dd0fd1cf7cc037d1e1ec9214 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:33:03 +0300 Subject: [PATCH 08/59] csp: allow data: URLs in worker-src for web-forms (#1776) Closes #1775 --- files/nginx/odk.conf.template | 2 +- test/nginx/src/mocha/nginx.spec.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 9e3a7528b..0ad82bcbd 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -91,7 +91,7 @@ map $arg_st $redirect_single_prefix { map $request_uri $central_frontend_csp { # Web Forms CSP for /f/... and /projects/.../forms/... routes ~^/(?:f/[^/]+(?:/.*)?|projects/\d+/forms/[^/]+/(?:(?:draft/)?(?:preview|submissions/new(?:/offline)?)|submissions/[^/]+/edit)(?:/)?)(?:\?.*)?$ - "default-src 'report-sample' 'none'; connect-src 'self' https:; font-src 'self' data:; form-action 'self'; frame-ancestors 'self'; frame-src 'self' https://getodk.github.io/central/; img-src blob: data: https:; manifest-src 'self'; media-src blob:; object-src 'none'; script-src 'report-sample' 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; worker-src 'report-sample' blob:; report-uri /csp-report"; + "default-src 'report-sample' 'none'; connect-src 'self' https:; font-src 'self' data:; form-action 'self'; frame-ancestors 'self'; frame-src 'self' https://getodk.github.io/central/; img-src blob: data: https:; manifest-src 'self'; media-src blob:; object-src 'none'; script-src 'report-sample' 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; worker-src 'report-sample' blob: data:; report-uri /csp-report"; default "default-src 'report-sample' 'none'; connect-src 'self' https://translate.google.com https://translate.googleapis.com; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'self' https://getodk.github.io/central/; img-src data: https:; manifest-src 'self'; media-src 'none'; object-src 'none'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; style-src-attr 'unsafe-inline'; worker-src 'report-sample' blob:; report-uri /csp-report"; diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 821affec6..09881d304 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -223,6 +223,7 @@ const contentSecurityPolicies = { 'worker-src': [ reportSample, 'blob:', + 'data:', ], 'report-uri': '/csp-report', }), From 711c6ad9e8b836e5afc062e34c1edac3095de125 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:07:32 +0300 Subject: [PATCH 09/59] nginx/csp: tighten favicon allowance for blank.html (#1855) In line with #1854. --- files/nginx/odk.conf.template | 2 +- test/nginx/src/mocha/nginx.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 0ad82bcbd..07d527255 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -193,7 +193,7 @@ server { root /usr/share/nginx/html; try_files /blank.html =404; - add_header Content-Security-Policy "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; form-action 'self'; frame-ancestors 'self'; img-src 'self' https://translate.google.com; report-uri /csp-report" always; + add_header Content-Security-Policy "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; form-action 'self'; frame-ancestors 'self'; img-src http://${DOMAIN}/favicon.ico https://translate.google.com; report-uri /csp-report" always; include /usr/share/odk/nginx/common-headers.conf; } location = /blank.html { diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 09881d304..8c3a8250f 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -77,7 +77,7 @@ const contentSecurityPolicies = { ], 'form-action': self, // allow decrypted zip downloads from central-frontend 'frame-ancestors': self, - 'img-src': self, // allow favicon.ico + 'img-src': 'http://odk-nginx.example.test/favicon.ico', // http: scheme permits secure upgrade to https:// 'report-uri': '/csp-report', }), }, From c9b359a87aa37c3f5f026cdb1ed70cdb60290f03 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:14:43 +0300 Subject: [PATCH 10/59] nginx/csp: allow favicons for backend requests (#1854) Closes #1851 --- files/nginx/odk.conf.template | 2 +- test/nginx/src/mocha/nginx.spec.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 07d527255..c1a4f4ab1 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -183,7 +183,7 @@ server { location ~ ^/v\d { proxy_hide_header Content-Security-Policy-Report-Only; - add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; form-action 'none'; frame-ancestors 'none'; report-uri /csp-report" always; + add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; form-action 'none'; frame-ancestors 'none'; img-src http://${DOMAIN}/favicon.ico; report-uri /csp-report" always; include /usr/share/odk/nginx/common-headers.conf; include /usr/share/odk/nginx/backend.conf; diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 8c3a8250f..5e9664d08 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -54,6 +54,7 @@ const contentSecurityPolicies = { ], 'form-action': none, 'frame-ancestors': none, + 'img-src': 'http://odk-nginx.example.test/favicon.ico', // http: scheme permits secure upgrade to https:// 'report-uri': '/csp-report', }, }, From af64f1da31d04ae68b4e01aed45646f88468be7b Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 5 May 2026 15:27:52 +0300 Subject: [PATCH 11/59] nginx/csp: enforce policy for central-backend (#1859) Switch from Content-Security-Policy-Report-Only to Content-Security-Policy. --- files/nginx/backend.conf | 8 --- files/nginx/odk.conf.template | 25 +++++--- files/nginx/setup-odk.sh | 2 +- nginx.dockerfile | 1 - test/nginx/mock-http-server/index.js | 22 +++---- test/nginx/src/mocha/nginx.spec.js | 90 +++++++++++++--------------- 6 files changed, 70 insertions(+), 78 deletions(-) delete mode 100644 files/nginx/backend.conf diff --git a/files/nginx/backend.conf b/files/nginx/backend.conf deleted file mode 100644 index a79eaa2c6..000000000 --- a/files/nginx/backend.conf +++ /dev/null @@ -1,8 +0,0 @@ -proxy_set_header X-Forwarded-Proto $scheme; -proxy_pass http://service:8383; -proxy_redirect off; - -# buffer requests, but not responses, so streaming out works. -proxy_request_buffering on; -proxy_buffering off; -proxy_read_timeout 2m; diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index c1a4f4ab1..412a1ea9c 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -97,6 +97,12 @@ map $request_uri $central_frontend_csp { "default-src 'report-sample' 'none'; connect-src 'self' https://translate.google.com https://translate.googleapis.com; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'self' https://getodk.github.io/central/; img-src data: https:; manifest-src 'self'; media-src 'none'; object-src 'none'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; style-src-attr 'unsafe-inline'; worker-src 'report-sample' blob:; report-uri /csp-report"; } +map $upstream_http_content_security_policy $central_backend_csp { + # pass through any Content-Security-Policy received from upstream services (central-backend, enketo) + "" "default-src 'report-sample' 'none'; form-action 'none'; frame-ancestors 'none'; img-src http://${DOMAIN}/favicon.ico; report-uri /csp-report"; + default $upstream_http_content_security_policy; +} + server { listen 443 ssl; http2 on; @@ -176,17 +182,20 @@ server { } # End of Enketo Configuration. - location ~ ^/v\d+/oidc/callback$ { - include /usr/share/odk/nginx/common-headers.conf; - include /usr/share/odk/nginx/backend.conf; - } - location ~ ^/v\d { - proxy_hide_header Content-Security-Policy-Report-Only; - add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; form-action 'none'; frame-ancestors 'none'; img-src http://${DOMAIN}/favicon.ico; report-uri /csp-report" always; + proxy_hide_header Content-Security-Policy; + add_header Content-Security-Policy $central_backend_csp always; include /usr/share/odk/nginx/common-headers.conf; - include /usr/share/odk/nginx/backend.conf; + + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://service:8383; + proxy_redirect off; + + # buffer requests, but not responses, so streaming out works. + proxy_request_buffering on; + proxy_buffering off; + proxy_read_timeout 2m; } location @blank.html { diff --git a/files/nginx/setup-odk.sh b/files/nginx/setup-odk.sh index 4518b60e9..04f8ae1a7 100755 --- a/files/nginx/setup-odk.sh +++ b/files/nginx/setup-odk.sh @@ -59,7 +59,7 @@ else # strip out all ssl_* directives perl -i -ne 's/listen 443.*/listen 80;/; print if ! /\bssl_/' /etc/nginx/conf.d/odk.conf # force https because we expect SSL upstream - perl -i -pe 's/X-Forwarded-Proto \$scheme/X-Forwarded-Proto https/;' /usr/share/odk/nginx/backend.conf + perl -i -pe 's/X-Forwarded-Proto \$scheme/X-Forwarded-Proto https/;' /etc/nginx/conf.d/odk.conf echo "starting nginx for upstream ssl..." else # remove letsencrypt challenge reply, but keep 80 to 443 redirection diff --git a/nginx.dockerfile b/nginx.dockerfile index 0e35f0211..d2520a8c1 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -32,7 +32,6 @@ COPY files/nginx/setup-odk.sh \ /scripts/ COPY files/nginx/redirector.conf /usr/share/odk/nginx/ -COPY files/nginx/backend.conf /usr/share/odk/nginx/ COPY files/nginx/common-headers.conf /usr/share/odk/nginx/ COPY files/nginx/robots.txt /usr/share/nginx/html COPY --from=intermediate client/dist/ /usr/share/nginx/html diff --git a/test/nginx/mock-http-server/index.js b/test/nginx/mock-http-server/index.js index 10af334d8..ab4f1518a 100644 --- a/test/nginx/mock-http-server/index.js +++ b/test/nginx/mock-http-server/index.js @@ -9,16 +9,6 @@ const app = express(); app.use((req, res, next) => { console.log(new Date(), req.method, req.originalUrl); - - // always set CSP header to detect (or allow) leaks from backend through to the client - const topLevelDirectives = [ - 'default-src', - 'form-action', - 'frame-ancestors', - ]; - res.set('Content-Security-Policy', topLevelDirectives.map(d => `${d} NOTE:FROM-BACKEND:block`) .join('; ')); - res.set('Content-Security-Policy-Report-Only', topLevelDirectives.map(d => `${d} NOTE:FROM-BACKEND:reportOnly`).join('; ')); - next(); }); @@ -26,6 +16,10 @@ app.use((req, res, next) => { app.use('/-/', (req, res, next) => { res.set('Vary', 'Accept-Encoding'); res.set('Cache-Control', 'public, max-age=0'); + + // Set both CSP headers from enketo. Eventually nginx should be confident to override both. + res.set('Content-Security-Policy', `NOTE:FROM-BACKEND:block`); + res.set('Content-Security-Policy-Report-Only', `NOTE:FROM-BACKEND:reportOnly`); next(); }); @@ -45,6 +39,14 @@ app.get('/v1/projects', (_, res) => { res.send('OK'); }); +app.get('/v1/oidc/callback', (req, res) => { + // This endpoint is 100% responsible for its own headers. Set both, and test they both get through. + res.set('Content-Security-Policy', `NOTE:FROM-BACKEND:block`); + res.set('Content-Security-Policy-Report-Only', `NOTE:FROM-BACKEND:reportOnly`); + + res.send('OK'); +}); + [ 'delete', 'get', diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 5e9664d08..b36ef1f1c 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -43,11 +43,6 @@ const allowGoogleTranslate = ({ 'connect-src':connectSrc, 'img-src':imgSrc, ...o const contentSecurityPolicies = { 'backend-strict': { block: { - 'default-src': 'NOTE:FROM-BACKEND:block', - 'form-action': 'NOTE:FROM-BACKEND:block', - 'frame-ancestors': 'NOTE:FROM-BACKEND:block', - }, - reportOnly: { 'default-src': [ reportSample, none, @@ -59,16 +54,8 @@ const contentSecurityPolicies = { }, }, 'backend-unmodified': { - block: { - 'default-src': 'NOTE:FROM-BACKEND:block', - 'form-action': 'NOTE:FROM-BACKEND:block', - 'frame-ancestors': 'NOTE:FROM-BACKEND:block', - }, - reportOnly: { - 'default-src': 'NOTE:FROM-BACKEND:reportOnly', - 'form-action': 'NOTE:FROM-BACKEND:reportOnly', - 'frame-ancestors': 'NOTE:FROM-BACKEND:reportOnly', - }, + block: 'NOTE:FROM-BACKEND:block', + reportOnly: 'NOTE:FROM-BACKEND:reportOnly', }, 'blank-html': { block: allowGoogleTranslate({ @@ -122,11 +109,7 @@ const contentSecurityPolicies = { }), }, enketo: { - block: { - 'default-src': 'NOTE:FROM-BACKEND:block', - 'form-action': 'NOTE:FROM-BACKEND:block', - 'frame-ancestors': 'NOTE:FROM-BACKEND:block', - }, + block: 'NOTE:FROM-BACKEND:block', reportOnly: allowGoogleTranslate({ 'default-src': [ reportSample, @@ -262,36 +245,39 @@ describe('Content-Security-Policy definitions', () => { if(!policy) continue; describe(`header: ${headerNames[headerType]}`, () => { - it(`should have required directives: ${requiredDirectives}`, () => { - assert.containsAllKeys(policy, requiredDirectives); - }); - - Object.entries(policy) - .map (([ key, directive ]) => [ key, asArray(directive) ]) - .filter (([ key, directive ]) => !(directive.length === 1 && directive[0] === `NOTE:FROM-BACKEND:${headerType}`)) // eslint-disable-line no-unused-vars - .forEach(([ key, directive ]) => { - describe(`directive: ${key}`, () => { - if(supportsReportSample.includes(key)) { - if(key.startsWith('style-src') && directive.includes(`'unsafe-inline'`)) { - // For style-* directives, report-sample will only provide a sample of inline violations. - it(`should not include 'report-sample' in directive '${key}' when 'unsafe-inline' is allowed`, () => { - // expect - assert.notInclude(directive, "'report-sample'"); - }); + if(typeof policy === 'string') { + if(!policy.startsWith('NOTE:FROM-BACKEND:')) throw new Error(`Unexpected policy string: '${policy}'`); + } else { + it(`should have required directives: ${requiredDirectives}`, () => { + assert.containsAllKeys(policy, requiredDirectives); + }); + + Object.entries(policy) + .map (([ key, directive ]) => [ key, asArray(directive) ]) + .forEach(([ key, directive ]) => { + describe(`directive: ${key}`, () => { + if(supportsReportSample.includes(key)) { + if(key.startsWith('style-src') && directive.includes(`'unsafe-inline'`)) { + // For style-* directives, report-sample will only provide a sample of inline violations. + it(`should not include 'report-sample' in directive '${key}' when 'unsafe-inline' is allowed`, () => { + // expect + assert.notInclude(directive, "'report-sample'"); + }); + } else { + it(`should include 'report-sample' in directive '${key}'`, () => { + // expect + assert.include(directive, "'report-sample'"); + }); + } } else { - it(`should include 'report-sample' in directive '${key}'`, () => { + it(`should not include 'report-sample' in directive '${key}'`, () => { // expect - assert.include(directive, "'report-sample'"); + assert.notInclude(directive, "'report-sample'"); }); } - } else { - it(`should not include 'report-sample' in directive '${key}'`, () => { - // expect - assert.notInclude(directive, "'report-sample'"); - }); - } + }); }); - }); + } }); } }); @@ -1182,9 +1168,13 @@ function assertSecurityHeaders(res, { csp }) { function assertCsp(actual, expected) { if(!expected) return assert.isNull(actual); - assert.deepEqualInAnyOrder( - actual?.split('; '), - Object.entries(expected) - .map(([ k, v ]) => `${k} ${asArray(v).join(' ')}`), - ); + if(typeof expected === 'string') { + assert.equal(actual, expected); + } else { + assert.deepEqualInAnyOrder( + actual?.split('; '), + Object.entries(expected) + .map(([ k, v ]) => `${k} ${asArray(v).join(' ')}`), + ); + } } From 625eb988aecde0a2b30f69847a21aba3b86b7cb1 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 7 May 2026 12:26:04 +0300 Subject: [PATCH 12/59] nginx: fix escaping in /fonts/ matcher (#1863) --- files/nginx/odk.conf.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 412a1ea9c..5902bc5fe 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -40,7 +40,7 @@ map "$request_method::$uri$is_args$args" $cache_strategy { # enketo ~^(GET|HEAD)::/-(/x)?/css/ "revalidate"; - ~^(GET|HEAD)::/-(/x)?/fonts/.*?v= "immutable"; + ~^(GET|HEAD)::/-(/x)?/fonts/.*\?v= "immutable"; ~^(GET|HEAD)::/-(/x)?/fonts/ "revalidate"; ~^(GET|HEAD)::/-(/x)?/images/ "revalidate"; ~^(GET|HEAD)::/-(/x)?/js/build/chunks/ "immutable"; From 97b32513fb749653776423206505863fd30e5142 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sat, 9 May 2026 10:17:03 +0300 Subject: [PATCH 13/59] ci: simplify checkout (#1890) It looks like the checkout code from the `test-images` job was copy/pasted elsewhere. Simplifying this config should speed up git checkout in affected jobs. --- .github/workflows/main.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d15cef75b..32871ba75 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,20 +33,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - with: - fetch-depth: 0 - fetch-tags: true - submodules: recursive - run: cd test/envsub && ./run-tests.sh test-nginx: timeout-minutes: 4 runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - with: - fetch-depth: 0 - fetch-tags: true - submodules: recursive - uses: actions/setup-node@v5 with: node-version: 24.14.1 From fe411c8c2001a19f4334095ecf84dd0dc1279940 Mon Sep 17 00:00:00 2001 From: Sadiq Khoja Date: Thu, 14 May 2026 11:41:29 -0400 Subject: [PATCH 14/59] Fixes: expected docker context is increased due to WF merge --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32871ba75..40b4d1f00 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -80,7 +80,7 @@ jobs: fetch-depth: 0 fetch-tags: true submodules: recursive - - run: ./test/check-docker-context.sh --min-size 50000 --max-size 60000 --min-count 1500 --max-count 1700 + - run: ./test/check-docker-context.sh --min-size 50000 --max-size 100000 --min-count 1500 --max-count 1700 - run: ./test/test-images.sh - if: always() run: docker compose logs From 1a9c7f979e17be453d8dc94a37d2b333f64d0ea9 Mon Sep 17 00:00:00 2001 From: Sadiq Khoja Date: Wed, 13 May 2026 16:24:07 -0400 Subject: [PATCH 15/59] Fixes: install openssl in service container --- service.dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/service.dockerfile b/service.dockerfile index 6dfd2fa5f..72139df47 100644 --- a/service.dockerfile +++ b/service.dockerfile @@ -52,6 +52,7 @@ RUN apt-get update \ procps \ postgresql-client-14 \ netcat-traditional \ + openssl \ && rm -rf /var/lib/apt/lists/* \ && npm clean-install --omit=dev --no-audit \ --fund=false --update-notifier=false From e4c7b83f17d222742b6ecd6ca61d1a4ddcb4f6ce Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Mon, 25 May 2026 16:16:04 +0300 Subject: [PATCH 16/59] service: move DB_SSL check back to runtime (#1889) The `DB_SSL` env var was made illegal in https://github.com/getodk/central/pull/1647. The check was then moved from runtime to build time in https://github.com/getodk/central/pull/1671. Checking at build-time allows for faster failure and clearer feedback to sysadmins who are upgrading, and previously depended on this env var. However, the downside is that if container images are pre-built centrally, this check will be skipped. With this commit, the check will move to container startup. However, it will now be skipped if the container is started with a non-standard CMD/command/COMMAND. --- .github/workflows/main.yml | 13 ++++ docker-compose.yml | 3 +- files/service/scripts/start-odk.sh | 18 +++++ service.dockerfile | 3 +- test/package.json | 3 +- test/service.spec.js | 118 +++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 test/service.spec.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 40b4d1f00..32ac23ddb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -66,6 +66,18 @@ jobs: steps: - uses: actions/checkout@v5 - run: ./test/test-secrets.sh + test-service: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + submodules: true + - uses: actions/setup-node@v5 + with: + node-version: 24.14.1 + - run: cd test/nginx && npm clean-install + - run: cd test/nginx && npm run test:service test-images: timeout-minutes: 10 needs: @@ -73,6 +85,7 @@ jobs: - test-envsub - test-nginx - test-secrets + - test-service runs-on: ubuntu-latest # TODO matrix to run on all expected versions? steps: - uses: actions/checkout@v5 diff --git a/docker-compose.yml b/docker-compose.yml index ca14ec3f1..627eeea1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,8 +38,6 @@ services: service: build: context: . - args: - DB_SSL: ${DB_SSL:-} # So that we can error out at build time if this is defined with a value of "true" (no longer supported from 2026.1). dockerfile: service.dockerfile depends_on: - secrets @@ -68,6 +66,7 @@ services: - PGPASSWORD=${PGPASSWORD-${DB_PASSWORD:-odk}} - PGAPPNAME=${PGAPPNAME-odkcentral} # End of libpq connection env var preparation. + - DB_SSL=${DB_SSL:-null} - DB_POOL_SIZE=${DB_POOL_SIZE:-10} - EMAIL_FROM=${EMAIL_FROM:-no-reply@$DOMAIN} - EMAIL_HOST=${EMAIL_HOST:-mail} diff --git a/files/service/scripts/start-odk.sh b/files/service/scripts/start-odk.sh index 3ba53e51f..fa1272ab3 100755 --- a/files/service/scripts/start-odk.sh +++ b/files/service/scripts/start-odk.sh @@ -2,6 +2,24 @@ set -o pipefail shopt -s inherit_errexit +# Check for illegal DB_SSL environment variable. +if ! [[ "${DB_SSL-}" = null ]]; then + echo "!!!" + echo "!!! You have the DB_SSL variable defined (in your .env file, probably)." + echo "!!! This variable is no longer supported from Central 2026.1 onwards." + echo "!!! There is a new way of configuring SSL for your database, please see:" + echo "!!!" + echo "!!! https://docs.getodk.org/central-install-digital-ocean/#using-a-custom-database-server" + echo "!!!" + echo "!!! Please refer to the Central 2026.1.0 release notes for more information on this change." + echo "!!!" + echo "!!! ODK Central backend will not start until this issue is resolved." + echo "!!!" + sleep 60 # reduce resource waste from quick restart and instant failure + exit 1 +fi +unset DB_SSL + # Serialize (as a raw env block) the environment set up by docker, for later # availability to processes running with a reset environment (such as cronjobs). # See https://github.com/getodk/central/issues/1747 . diff --git a/service.dockerfile b/service.dockerfile index 72139df47..c7e8bf8f1 100644 --- a/service.dockerfile +++ b/service.dockerfile @@ -15,8 +15,7 @@ RUN echo "deb http://apt.postgresql.org/pub/repos/apt/ $(grep -oP 'VERSION_CODEN && curl https://www.postgresql.org/media/keys/ACCC4CF8.asc \ | gpg --dearmor > /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg -ARG DB_SSL -RUN [ -z "${DB_SSL}" ] || (/bin/echo -e '\n\n\n\n\nYou have the DB_SSL variable defined (in your .env file, probably).\nThis variable is no longer supported from Central 2026.1 onwards.\nThere is a new way of configuring SSL for your database, please see:\n\nhttps://docs.getodk.org/central-install-digital-ocean/#using-a-custom-database-server\n\nPlease refer to the Central 2026.1.0 release notes for more information on this change.\n\n\n\n\n'; exit 13) + FROM node:${node_version}-slim AS intermediate RUN apt-get update \ diff --git a/test/package.json b/test/package.json index ca0e510d8..a5699a40c 100644 --- a/test/package.json +++ b/test/package.json @@ -6,7 +6,8 @@ "test:nginx": "npm run test:nginx:mocha && npm run test:nginx:playwright", "test:nginx:mocha": "NODE_TLS_REJECT_UNAUTHORIZED=0 mocha ./nginx/src/mocha", "test:nginx:playwright": "NODE_TLS_REJECT_UNAUTHORIZED=0 playwright test", - "test": "npm run lint && npm run test:github-actions && npm run test:nginx" + "test:service": "mocha ./service.spec.js", + "test": "npm run lint && npm run test:github-actions && npm run test:nginx && npm run test:service" }, "dependencies": { "@playwright/test": "^1.58.2", diff --git a/test/service.spec.js b/test/service.spec.js new file mode 100644 index 000000000..9a08fc486 --- /dev/null +++ b/test/service.spec.js @@ -0,0 +1,118 @@ +const { execSync, spawn } = require('node:child_process'); + +const { assert } = require('chai'); + +const logPrefix =`[${__filename.split('/').at(-1)}]`; +const log = (...args) => console.log(logPrefix, ...args); + +describe('service image', () => { + describe('DB_SSL handling', () => { + // Pre-8e05cf3b2e8cbaa2effae2b1d3213fe23f0545cb, odk-central-backend checked + // DB_SSL like so: + // + // if (ssl != null && ssl !== true) + // return Problem.internal.invalidDatabaseConfig({ reason: 'If ssl is specified, its value can only be true.' }); + // + // This was passed into config in the getodk/central repo pre-d10cccb34bb3abd893563bbe26ed5160df66b972 + // via: + // + // docker-compose.yml: - DB_SSL=${DB_SSL:-null} + // files/service/config.json.template: "ssl": ${DB_SSL}, + + const generatingServiceConfig = 'generating local service configuration..'; + const unresolvedIssue = '!!! ODK Central backend will not start until this issue is resolved.'; + const finishedMarkers = [ + unresolvedIssue, + generatingServiceConfig, + ]; + + const runService = (...args) => new Promise(resolve => { + const stdcombi = []; + + const process = spawn('docker', [ 'compose', 'run', ...args, 'service' ], { cwd:'..' }); + + const appendOutput = data => { + const str = data.toString(); + stdcombi.push(str); + + // Allow short-circuiting of tests - execution time varies + // wildly depending if docker images are pre-built or not. + const lines = str.split('\n'); + if(lines.some(line => finishedMarkers.includes(line))) process.kill(); + }; + process.stdout.on('data', appendOutput); + process.stderr.on('data', appendOutput); + + const timer = setTimeout(() => { process.kill(); }, 9_000); + + process.on('close', (code, signal) => { + clearTimeout(timer); + const asLines = datas => datas.join('').split('\n'); + resolve({ code, signal, stdcombi:asLines(stdcombi) }); + }); + }); + + before(function () { + this.timeout(120_000); + + const exec = cmd => execSync(cmd, { cwd:'..', stdio:['ignore', 'inherit', 'inherit'] }); + + log('Building "service" docker image...'); + exec('touch .env'); + exec('docker compose pull --include-deps service'); + exec('docker compose build --with-dependencies service'); + log('"service" docker image built OK.'); + }); + + [ + '', // e.g. if passed via `docker compose run --env DB_SSL=` + 'true', + 'false', + ].forEach(badVal => { + it(`should fail to start if DB_SSL=${badVal}`, async function() { + this.timeout(10_000); + + // when + const { stdcombi } = await runService('--env', `DB_SSL=${badVal}`); + + // then + assertIncludes(stdcombi, unresolvedIssue); + assertNotIncludes(stdcombi, generatingServiceConfig); + }); + }); + + [ + 'null', + ].forEach(goodVal => { + it(`should start OK if DB_SSL=${goodVal}`, async function() { + this.timeout(10_000); + + // when + const { stdcombi } = await runService('--env', `DB_SSL=${goodVal}`); + + // then + assertIncludes(stdcombi, generatingServiceConfig); + assertNotIncludes(stdcombi, unresolvedIssue); + }); + }); + + it('should start OK if DB_SSL is not set', async function() { + this.timeout(10_000); + + // when + const { stdcombi } = await runService(); + + // then + assertIncludes(stdcombi, generatingServiceConfig); + assertNotIncludes(stdcombi, unresolvedIssue); + }); + }); +}); + +function assertIncludes(stdcombi, expectedLine) { + assert.include(stdcombi, expectedLine, `Could not find line '${expectedLine}' in stdcombi:\n${stdcombi.join('\n')}`); +} + +function assertNotIncludes(stdcombi, expectedLine) { + assert.notInclude(stdcombi, expectedLine, `Found unexpected line '${expectedLine}' in stdcombi:\n${stdcombi.join('\n')}`); +} From 95717b1b6e9cff638070a5415e74f9f2ddf30285 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 26 May 2026 10:55:42 +0300 Subject: [PATCH 17/59] nginx: enable Content Security Policies (#1909) Switch all headers from `Content-Security-Policy-Report-Only` to `Content-Security-Policy`. --- files/nginx/odk.conf.template | 7 ++++--- test/nginx/src/mocha/nginx.spec.js | 7 +++---- test/nginx/src/playwright/csp.spec.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 5902bc5fe..1e4dab06b 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -120,7 +120,7 @@ server { server_tokens off; - add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; img-src https://translate.google.com; report-uri /csp-report" always; + add_header Content-Security-Policy "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; img-src https://translate.google.com; report-uri /csp-report" always; include /usr/share/odk/nginx/common-headers.conf; client_max_body_size 100m; @@ -175,8 +175,9 @@ server { # More lax CSP for enketo-express: # Google Maps API: https://developers.google.com/maps/documentation/javascript/content-security-policy # Use 'none' per directive instead of falling back to default-src to make CSP violation reports more specific + proxy_hide_header Content-Security-Policy; proxy_hide_header Content-Security-Policy-Report-Only; - add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; connect-src 'self' blob: https://maps.googleapis.com/ https://maps.google.com/ https://maps.gstatic.com/mapfiles/ https://fonts.gstatic.com/ https://fonts.googleapis.com/ https://translate.google.com https://translate.googleapis.com; font-src 'self' https://fonts.gstatic.com/; form-action 'self'; frame-ancestors 'self'; frame-src 'none'; img-src data: blob: jr: 'self' https://maps.google.com/maps/ https://maps.gstatic.com/mapfiles/ https://maps.googleapis.com/maps/ https://tile.openstreetmap.org/ https://translate.google.com; manifest-src 'none'; media-src blob: jr: 'self'; object-src 'none'; script-src 'report-sample' '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" always; + add_header Content-Security-Policy "default-src 'report-sample' 'none'; connect-src 'self' blob: https://maps.googleapis.com/ https://maps.google.com/ https://maps.gstatic.com/mapfiles/ https://fonts.gstatic.com/ https://fonts.googleapis.com/ https://translate.google.com https://translate.googleapis.com; font-src 'self' https://fonts.gstatic.com/; form-action 'self'; frame-ancestors 'self'; frame-src 'none'; img-src data: blob: jr: 'self' https://maps.google.com/maps/ https://maps.gstatic.com/mapfiles/ https://maps.googleapis.com/maps/ https://tile.openstreetmap.org/ https://translate.google.com; manifest-src 'none'; media-src blob: jr: 'self'; object-src 'none'; script-src 'report-sample' '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" always; include /usr/share/odk/nginx/common-headers.conf; } @@ -213,7 +214,7 @@ server { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; - add_header Content-Security-Policy-Report-Only "$central_frontend_csp" always; + add_header Content-Security-Policy "$central_frontend_csp" always; include /usr/share/odk/nginx/common-headers.conf; } diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index b36ef1f1c..affefe6fe 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -70,7 +70,7 @@ const contentSecurityPolicies = { }), }, 'central-frontend': { - reportOnly: allowGoogleTranslate({ + block: allowGoogleTranslate({ 'default-src': [ reportSample, none, @@ -109,8 +109,7 @@ const contentSecurityPolicies = { }), }, enketo: { - block: 'NOTE:FROM-BACKEND:block', - reportOnly: allowGoogleTranslate({ + block: allowGoogleTranslate({ 'default-src': [ reportSample, none, @@ -166,7 +165,7 @@ const contentSecurityPolicies = { }), }, 'web-forms': { - reportOnly: allowGoogleTranslate({ + block: allowGoogleTranslate({ 'default-src': [ reportSample, none, diff --git a/test/nginx/src/playwright/csp.spec.js b/test/nginx/src/playwright/csp.spec.js index cf58eda30..c0f768e52 100644 --- a/test/nginx/src/playwright/csp.spec.js +++ b/test/nginx/src/playwright/csp.spec.js @@ -37,7 +37,7 @@ test('catches style-src-elem violation samples', async ({ page }) => { 'violated-directive': 'style-src-elem', 'effective-directive': 'style-src-elem', 'original-policy': `default-src 'report-sample' 'none'; connect-src 'self' https://translate.google.com https://translate.googleapis.com; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'self' https://getodk.github.io/central/; img-src data: https:; manifest-src 'self'; media-src 'none'; object-src 'none'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; style-src-attr 'unsafe-inline'; worker-src 'report-sample' blob:; report-uri /csp-report`, - 'disposition': 'report', + 'disposition': 'enforce', 'blocked-uri': 'inline', 'line-number': 5, 'column-number': 19, From a4e5b06264bc31e4195c8e3f8363b3b6aebd7e39 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:11:27 +0300 Subject: [PATCH 18/59] test/nginx: fix comment typo (#1938) --- test/nginx/src/lib.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/nginx/src/lib.js b/test/nginx/src/lib.js index 470b03bc4..efc8af20c 100644 --- a/test/nginx/src/lib.js +++ b/test/nginx/src/lib.js @@ -33,7 +33,7 @@ async function resetSentryMock() { // This function makes DIRECT requests to sentry-mock. IRL these requests // would be performed by nginx when a client POSTs to /csp-report. This -// function is for used in test setup/assertions, except when confirming the +// function is for use in test setup/assertions, except when confirming the // behaviour of the mock Sentry implementation. function requestSentryMock(opts) { // servername: SNI extension value - https://nodejs.org/api/https.html#new-agentoptions From 48b7478fdcb51266387b4fd932de72cd54cb63a8 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:18:25 +0300 Subject: [PATCH 19/59] test/nginx/docker-compose: restrict open ports to local machine (#1927) Restrict TCP ports to the local machine. This will prevent exposing these dev services on the local network or wider. --- test/nginx/nginx.test.docker-compose.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/nginx/nginx.test.docker-compose.yml b/test/nginx/nginx.test.docker-compose.yml index fe2f910f0..cc2d8ae40 100644 --- a/test/nginx/nginx.test.docker-compose.yml +++ b/test/nginx/nginx.test.docker-compose.yml @@ -3,14 +3,14 @@ services: build: dockerfile: mock-http-service.dockerfile ports: - - "8005:8005" + - 127.0.0.1:8005:8005 environment: - PORT=8005 service: build: dockerfile: mock-http-service.dockerfile ports: - - "8383:8383" + - 127.0.0.1:8383:8383 environment: - PORT=8383 sentry-mock: @@ -18,6 +18,7 @@ services: dockerfile: mock-sentry.dockerfile ports: # Sentry port is not currently configurable in nginx config, so use default HTTPS port + # FIXME in line with other .ports, make this port binding more restrictive - "443:443" environment: - MODE=https @@ -30,8 +31,10 @@ services: environment: - SSL_TYPE=selfsign ports: - - "9000:80" - - "9001:443" + - "127.0.0.1:9000:80" + - "[::1]:9000:80" + - "127.0.0.1:9001:443" + - "[::1]:9001:443" nginx-ssl-upstream: extends: file: lib.docker-compose.yml @@ -39,5 +42,7 @@ services: environment: - SSL_TYPE=upstream ports: - - "10000:80" - - "10001:443" + - "127.0.0.1:10000:80" + - "[::1]:10000:80" + - "127.0.0.1:10001:443" + - "[::1]:10001:443" From fc457ec71ac435fac3d2db6ccadd974c7977aa95 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:16:11 +0300 Subject: [PATCH 20/59] nginx: test stream interruption (#1939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests pass with nginx: ✅ `1.29.7` Tests fail with nginx: ☠️ `1.29.5` ☠️ `1.29.6` Closes #1736 --- test/nginx/mock-http-server/index.js | 11 +++++++++++ test/nginx/mock-sentry/index.js | 5 +++++ test/nginx/src/mocha/nginx.spec.js | 17 +++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/test/nginx/mock-http-server/index.js b/test/nginx/mock-http-server/index.js index ab4f1518a..9967f678a 100644 --- a/test/nginx/mock-http-server/index.js +++ b/test/nginx/mock-http-server/index.js @@ -47,6 +47,17 @@ app.get('/v1/oidc/callback', (req, res) => { res.send('OK'); }); +app.get('/v1/broken-stream', (req, res) => { + res.status(200); + res.write('beginning stream...', () => { + // Write has now flushed from NodeJS. Give it a chance to flush + // from lower-level network buffer. + setTimeout(() => { + res.socket.destroy(); + }, 50); + }); +}); + [ 'delete', 'get', diff --git a/test/nginx/mock-sentry/index.js b/test/nginx/mock-sentry/index.js index 09ba4629d..82a38c80a 100644 --- a/test/nginx/mock-sentry/index.js +++ b/test/nginx/mock-sentry/index.js @@ -1,4 +1,5 @@ const { execSync } = require('node:child_process'); +const crypto = require('node:crypto'); const { readFileSync } = require('node:fs'); const { createServer } = require('node:https'); const { createSecureContext } = require('node:tls'); @@ -102,6 +103,10 @@ const server = (() => { } cb(null, createSecureContext(goodCreds)); }, + // Disable TLS session-ticket resumption to force nginx to perform + // a full TLS handshake, thus exercising SNICallback consistently. + // See: https://nodejs.org/api/crypto.html#openssl-options:~:text=SSL_OP_NO_TICKET + secureOptions: crypto.constants.SSL_OP_NO_TICKET, }; return createServer(opts, app); diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index affefe6fe..1cdb9ab28 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -642,6 +642,23 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward ); }); + it('should detect backend stream breakages', async () => { + // when + const res = await apiFetch('/v1/broken-stream'); + // then + assert.equal(res.status, 200); + + try { + // when + await res.text(); + + assert.fail('response should have been aborted'); + } catch(err) { + // then + if(err.code !== 'ECONNRESET') throw err; + } + }); + it('/oidc/callback should serve Content-Security-Policy from backend', async () => { // when const res = await apiFetch('/v1/oidc/callback'); From c18251719ed0394777f509660fc96e03fb057d60 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:06:03 +0300 Subject: [PATCH 21/59] dev/docker-compose: restrict open ports to local machine (#1925) Restrict TCP ports to the local machine. This will prevent exposing these dev services on the local network or wider. --- docker-compose.dev.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 42dce29f7..13537b151 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,7 +11,7 @@ services: profiles: - central ports: - - 5432:5432 + - 127.0.0.1:5432:5432 environment: POSTGRES_USER: jubilant POSTGRES_PASSWORD: jubilant @@ -39,7 +39,7 @@ services: profiles: - central ports: - - 5001:80 + - 127.0.0.1:5001:80 secrets: profiles: - central @@ -61,16 +61,16 @@ services: extra_hosts: - "${DOMAIN}:host-gateway" ports: - - 8005:8005 + - 127.0.0.1:8005:8005 enketo_redis_main: profiles: - central ports: - - 6379:6379 + - 127.0.0.1:6379:6379 enketo_redis_cache: profiles: - central ports: - - 6380:6380 + - 127.0.0.1:6380:6380 volumes: dev_secrets: From 18b9667a02a7273ea55ccf5177ad22544e22ec5c Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:30:17 +0300 Subject: [PATCH 22/59] nginx: reject form previews with unexpected query params (#1947) --- files/nginx/odk.conf.template | 4 ++ test/nginx/src/mocha/nginx.spec.js | 94 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 1e4dab06b..896505dd6 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -165,6 +165,10 @@ server { # For that iframe to work, we'll need another path prefix (enketo-passthrough) under which we can # reach Enketo — this one will not be intercepted. location ~ ^/(?:-|enketo-passthrough)(?:/|$) { + if ($args ~* "(^|&|;)(x|%78|%58)?(f|%66|%46)(o|%6f|%4f)(r|%72|%52)(m|%6d|%4d)(=[^;&]*)?(&|;|$)" ) { + return 400; + } + rewrite ^/enketo-passthrough(/.*)?$ /-$1 break; proxy_pass http://enketo:8005; proxy_redirect off; diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 1cdb9ab28..e3f6b4a1e 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -609,6 +609,100 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward }); }); + describe('enketo query param filtering', () => { + [ + '/-/preview', + '/-/preview/', + '/enketo-passthrough/preview', + '/enketo-passthrough/preview/', + ].forEach(pathRoot => { + [ + 'form', + 'form=', + 'form=123', + 'form&a=1', + 'form=&a=1', + 'form=123&a=1', + 'a=1&form', + 'a=1&form=', + 'a=1&form=123', + 'a=1&form&b=2', + 'a=1&form=&b=2', + 'a=1&form=123&b=2', + 'a=1&form&form=123', + 'a=1&form=123&form=123', + + 'xform', + 'xform=', + 'xform=123', + 'xform&a=1', + 'xform=&a=1', + 'xform=123&a=1', + 'a=1&xform', + 'a=1&xform=', + 'a=1&xform=123', + 'a=1&xform&b=2', + 'a=1&xform=&b=2', + 'a=1&xform=123&b=2', + 'a=1&xform&xform=123', + 'a=1&xform=123&xform=123', + + 'form=1&xform=1&form=2&xform=2&form=3&xform=3&form=4&xform=4&form=5&xform=5&form=6&xform=6&', + + '%66orm=123', + + 'XFORM=123', + + 'form;a=1', + 'form=;a=1', + 'form=123;a=1', + 'a=1;form', + 'a=1;form=', + 'a=1;form=123', + 'a=1;form;b=2', + 'a=1;form=;b=2', + 'a=1;form=123;b=2', + 'a=1;form;form=123', + 'a=1;form=123;form=123', + ].forEach(queryString => { + const path = pathRoot + '?' + queryString; + + it(`should reject path ${path}`, async () => { + // when + const res = await apiFetch(path); + + // then + assert.equal(res.status, 400); + // and + await assertEnketoReceivedNoRequests(); + }); + }); + + [ + 'platform=mac', + 'form_id=99', + 'uniform=true', + 'a=1&deform=false&b=2', + 'information=detailed', + 'performance=good', + ].forEach(queryString => { + const path = pathRoot + '?' + queryString; + + it(`should NOT reject path ${path}`, async () => { + // when + const res = await apiFetch(path); + + // then + assert.equal(res.status, 200); + // and + await assertEnketoReceived( + { method:'GET', path:'/-/preview' + (pathRoot.endsWith('/') ? '/' : '') + '?' + queryString }, + ); + }); + }); + }); + }); + describe('blank.html', () => { [ '/blank.html', From 007fd5c2afb20578946b5cc0a45a182050eac985 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sat, 6 Jun 2026 06:59:31 +0300 Subject: [PATCH 23/59] test: disable case-sensitive routing for express servers (#1953) --- test/nginx/mock-http-server/index.js | 1 + test/nginx/mock-sentry/index.js | 1 + 2 files changed, 2 insertions(+) diff --git a/test/nginx/mock-http-server/index.js b/test/nginx/mock-http-server/index.js index 9967f678a..5db83d03f 100644 --- a/test/nginx/mock-http-server/index.js +++ b/test/nginx/mock-http-server/index.js @@ -6,6 +6,7 @@ const log = (...args) => console.log('[mock-http-server]', ...args); const requests = []; const app = express(); +app.set('case sensitive routing', true); app.use((req, res, next) => { console.log(new Date(), req.method, req.originalUrl); diff --git a/test/nginx/mock-sentry/index.js b/test/nginx/mock-sentry/index.js index 82a38c80a..2e342f377 100644 --- a/test/nginx/mock-sentry/index.js +++ b/test/nginx/mock-sentry/index.js @@ -18,6 +18,7 @@ const logErrorEvent = error => { }; const app = express(); +app.set('case sensitive routing', true); app.use(express.json({ type: [ 'application/json', From 28206d5b23491a66c2e4daf1507bddd95cc223c3 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:29:31 +0300 Subject: [PATCH 24/59] ci: increase docker-context file size expecations (#1948) Increase the lower and upper bounds to: * take account of larger contexts in forked repositories, and * tighten the acceptable margin --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32ac23ddb..9c954f614 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,7 +93,7 @@ jobs: fetch-depth: 0 fetch-tags: true submodules: recursive - - run: ./test/check-docker-context.sh --min-size 50000 --max-size 100000 --min-count 1500 --max-count 1700 + - run: ./test/check-docker-context.sh --min-size 90000 --max-size 110000 --min-count 1500 --max-count 1700 - run: ./test/test-images.sh - if: always() run: docker compose logs From 0c07aaa283b8c1c525e4cff9b202eadd60065e46 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:07:19 +0300 Subject: [PATCH 25/59] test/nginx: extract request() and add testing (#1955) --- test/.eslintrc.js | 1 + test/nginx/src/mocha/nginx.spec.js | 57 +- test/nginx/src/mocha/request.js | 58 ++ test/nginx/src/mocha/request.spec.js | 73 +++ test/package-lock.json | 832 +++++++++++++++++++++++++++ test/package.json | 1 + 6 files changed, 966 insertions(+), 56 deletions(-) create mode 100644 test/nginx/src/mocha/request.js create mode 100644 test/nginx/src/mocha/request.spec.js diff --git a/test/.eslintrc.js b/test/.eslintrc.js index 096480f4a..c8b73fc62 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -20,6 +20,7 @@ module.exports = { 'no-trailing-spaces': 'error', 'no-undef-init': 'error', 'no-unused-expressions': 'error', + 'no-unused-vars': ['error', { "ignoreRestSiblings":true } ], 'semi': [ 'error', 'always' ], }, }; diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index e3f6b4a1e..a7fba3ca1 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -1,5 +1,4 @@ const tls = require('node:tls'); -const { Readable } = require('stream'); const { assert, @@ -7,6 +6,7 @@ const { requestSentryMock, resetSentryMock, } = require('../lib'); +const request = require('./request'); const none = `'none'`; const reportSample = `'report-sample'`; @@ -1177,61 +1177,6 @@ async function resetMock(port) { 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 request(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}`); - } -} - function assertCacheStrategyApplied(res, expectedCacheStrategy) { switch (expectedCacheStrategy) { case 'immutable': diff --git a/test/nginx/src/mocha/request.js b/test/nginx/src/mocha/request.js new file mode 100644 index 000000000..4941c8e30 --- /dev/null +++ b/test/nginx/src/mocha/request.js @@ -0,0 +1,58 @@ +const { Readable } = require('node:stream'); + +module.exports = request; + +// 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 request(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}`); + } +} diff --git a/test/nginx/src/mocha/request.spec.js b/test/nginx/src/mocha/request.spec.js new file mode 100644 index 000000000..cc4929af1 --- /dev/null +++ b/test/nginx/src/mocha/request.spec.js @@ -0,0 +1,73 @@ +const express = require('express'); + +const { + assert, +} = require('../lib'); + +const request = require('./request'); + +describe('request()', () => { + let port, server; + const requestsReceived = []; + + beforeEach(() => new Promise((resolve, reject) => { + requestsReceived.length = 0; + + const app = express(); + app.use((req, res, next) => { + const { method, path, headers } = req; + requestsReceived.push({ method, path, headers }); + next(); + }); + app.get('/redirect-302', (req, res) => { + res.redirect('http://example.test/redirected'); + }); + app.all('*', (req, res) => { + res.send('OK'); + }); + + server = app.listen(0, '127.0.0.1'); + server.on('error', reject); + server.on('listening', () => { + port = server.address().port; + resolve(); + }); + })); + + afterEach(() => { + server?.close(); + }); + + it('should not follow redirects', async () => { + // when + const res = await request(`http://127.0.0.1:${port}/redirect-302`); + + // then + assert.equal(res.status, 302); + assert.equal(res.headers.get('location'), 'http://example.test/redirected'); + assert.deepEqual(stripHeaders(requestsReceived), [ + { method:'GET', path:'/redirect-302' }, + ]); + }); + + it('should allow setting Host header', async () => { + // given + const headers = { + 'host': 'not-a-host', // FIXME also test with other cases, e.g. "Host" or "HOST" + }; + + // when + const res = await request(`http://127.0.0.1:${port}/`, { headers }); + + // then + assert.equal(res.status, 200); + assert.deepEqual(stripHeaders(requestsReceived), [ + { method:'GET', path:'/' }, + ]); + assert.equal(requestsReceived[0].headers['host'], 'not-a-host'); + }); +}); + +function stripHeaders(arr) { + return arr.map(({ headers, ...others }) => others); +} diff --git a/test/package-lock.json b/test/package-lock.json index b0500f086..39b27c148 100644 --- a/test/package-lock.json +++ b/test/package-lock.json @@ -11,6 +11,7 @@ "deep-equal-in-any-order": "^2.1.0", "eslint": "^9.28.0", "eslint-plugin-no-only-tests": "^3.3.0", + "express": "^4.22.2", "mocha": "^11.7.5", "yaml": "^2.8.2" } @@ -257,6 +258,19 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -323,6 +337,12 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -336,6 +356,45 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -351,6 +410,44 @@ "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -517,6 +614,42 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -581,6 +714,25 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -590,18 +742,77 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -611,6 +822,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -770,6 +987,76 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -796,6 +1083,39 @@ "node": ">=16.0.0" } }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -854,6 +1174,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -868,6 +1206,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -877,6 +1224,43 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -944,6 +1328,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -952,6 +1348,30 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -960,6 +1380,38 @@ "he": "bin/he" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -991,6 +1443,21 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1174,6 +1641,75 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -1280,6 +1816,39 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1341,6 +1910,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -1373,6 +1951,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", @@ -1425,6 +2009,19 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -1433,6 +2030,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1442,6 +2054,30 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1492,6 +2128,51 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -1501,6 +2182,27 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1520,6 +2222,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1541,6 +2315,15 @@ "lodash": "^4.17.21" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -1659,6 +2442,15 @@ "node": ">=8" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -1670,6 +2462,28 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -1678,6 +2492,24 @@ "punycode": "^2.1.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/test/package.json b/test/package.json index a5699a40c..d5b3414e6 100644 --- a/test/package.json +++ b/test/package.json @@ -15,6 +15,7 @@ "deep-equal-in-any-order": "^2.1.0", "eslint": "^9.28.0", "eslint-plugin-no-only-tests": "^3.3.0", + "express": "^4.22.2", "mocha": "^11.7.5", "yaml": "^2.8.2" }, From 40868cef269e328f84ce463411c202886cdef39f Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:09:14 +0300 Subject: [PATCH 26/59] ci: read node version from volta declaration (#1958) One less place to keep up-to-date. --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9c954f614..eb1e6fe61 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: - node-version: 24.14.1 + node-version-file: test/package.json - run: cd test/nginx && npm clean-install - run: cd test/nginx && ./setup-tests.sh - run: cd test/nginx && npm run test:nginx:mocha @@ -75,7 +75,7 @@ jobs: submodules: true - uses: actions/setup-node@v5 with: - node-version: 24.14.1 + node-version-file: test/package.json - run: cd test/nginx && npm clean-install - run: cd test/nginx && npm run test:service test-images: From f098936c2ed5e45c9f6bcddfa05a07f72f038bd0 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:10:06 +0300 Subject: [PATCH 27/59] test: make ExpressJS host-bindings explicit (#1954) --- test/nginx/mock-http-server/index.js | 2 +- test/nginx/mock-sentry/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/nginx/mock-http-server/index.js b/test/nginx/mock-http-server/index.js index 5db83d03f..54131e54e 100644 --- a/test/nginx/mock-http-server/index.js +++ b/test/nginx/mock-http-server/index.js @@ -71,6 +71,6 @@ app.get('/v1/broken-stream', (req, res) => { res.send('OK'); })); -app.listen(port, () => { +app.listen(port, '0.0.0.0', () => { log(`Listening on port: ${port}`); }); diff --git a/test/nginx/mock-sentry/index.js b/test/nginx/mock-sentry/index.js index 2e342f377..645ee8e13 100644 --- a/test/nginx/mock-sentry/index.js +++ b/test/nginx/mock-sentry/index.js @@ -113,7 +113,7 @@ const server = (() => { return createServer(opts, app); })(); -server.listen(port, () => { +server.listen(port, '0.0.0.0', () => { log(`Listening with HTTPS on port: ${port}`); }); From 4ec096d6be7bf40f253f366d91a94f2e583fb91a Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:48:54 +0300 Subject: [PATCH 28/59] test/nginx: prevent request() from normalising paths (#1949) This will be useful for testing nginx config at some point, and very misleading if trying to test path normalisation differences if request() collapses path traversals. --- test/nginx/src/mocha/request.js | 24 +++++++++++++++++++++++- test/nginx/src/mocha/request.spec.js | 19 +++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/test/nginx/src/mocha/request.js b/test/nginx/src/mocha/request.js index 4941c8e30..509a8eff3 100644 --- a/test/nginx/src/mocha/request.js +++ b/test/nginx/src/mocha/request.js @@ -1,3 +1,4 @@ +const { isIPv6 } = require('node:net'); const { Readable } = require('node:stream'); module.exports = request; @@ -12,7 +13,7 @@ function request(url, { body, ...options }={}) { return new Promise((resolve, reject) => { try { - const req = getProtocolImplFrom(url).request(url, options, res => { + const req = getProtocolImplFrom(url).request({ ...options, ...preserve(url) }, res => { res.on('error', reject); const body = new Readable({ read:() => {} }); @@ -56,3 +57,24 @@ function getProtocolImplFrom(url) { default: throw new Error(`Unsupported protocol: ${protocol}`); } } + +/** + * Prevent URL path normalisation. + * @see https://nodejs.org/api/http.html#httprequesturl-options-callback + * @see https://nodejs.org/api/url.html#new-urlinput-base + */ +function preserve(urlString) { + const url = new URL(urlString); + if(url.username || url.password) throw new Error('Basic auth creds not yet supported.'); + + const host = safeIpv6(url.hostname); + const port = url.port; + const path = urlString.replace(/^http(s?):\/\/[^/]*/, '') || '/'; + + return { host, port, path }; +} + +function safeIpv6(hostname) { + const maybeV6 = hostname.replace(/^\[(.*)\]$/, (_, $1) => $1); + return isIPv6(maybeV6) ? maybeV6 : hostname; +} diff --git a/test/nginx/src/mocha/request.spec.js b/test/nginx/src/mocha/request.spec.js index cc4929af1..bcb0a075c 100644 --- a/test/nginx/src/mocha/request.spec.js +++ b/test/nginx/src/mocha/request.spec.js @@ -15,8 +15,8 @@ describe('request()', () => { const app = express(); app.use((req, res, next) => { - const { method, path, headers } = req; - requestsReceived.push({ method, path, headers }); + const { method, originalUrl:target, headers } = req; + requestsReceived.push({ method, target, headers }); next(); }); app.get('/redirect-302', (req, res) => { @@ -46,7 +46,7 @@ describe('request()', () => { assert.equal(res.status, 302); assert.equal(res.headers.get('location'), 'http://example.test/redirected'); assert.deepEqual(stripHeaders(requestsReceived), [ - { method:'GET', path:'/redirect-302' }, + { method:'GET', target:'/redirect-302' }, ]); }); @@ -62,10 +62,21 @@ describe('request()', () => { // then assert.equal(res.status, 200); assert.deepEqual(stripHeaders(requestsReceived), [ - { method:'GET', path:'/' }, + { method:'GET', target:'/' }, ]); assert.equal(requestsReceived[0].headers['host'], 'not-a-host'); }); + + it('should not normalise URLs', async () => { + // when + const res = await request(`http://127.0.0.1:${port}/a/../b.html?x=1`); + + // then + assert.equal(res.status, 200); + assert.deepEqual(stripHeaders(requestsReceived), [ + { method:'GET', target:'/a/../b.html?x=1' }, + ]); + }); }); function stripHeaders(arr) { From 0db562e77839b1f7e367b15816572d9ccf91224c Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:22:01 +0300 Subject: [PATCH 29/59] test/nginx: tidy up (#1941) * more specific param name * remove duplicate comment markers --- test/nginx/src/mocha/nginx.spec.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index a7fba3ca1..0b0340d17 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -592,19 +592,19 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward '/-/api', '/-/preview', '/-/edit/enketoid', - ].forEach(request => { - it(`should not redirect ${request} to central-frontend`, async () => { + ].forEach(path => { + it(`should not redirect ${path} to central-frontend`, async () => { // when - const res = await apiFetch(request); + const res = await apiFetch(path); // then assert.equal(res.status, 200); assert.equal(await res.text(), 'OK'); assertSecurityHeaders(res, { csp:'enketo' }); - // // and + // and await assertEnketoReceived( - { method:'GET', path: request }, + { method:'GET', path }, ); }); }); From 7f7d948e07c3cdd0442b251a850a0c328081a02f Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:47:41 +0300 Subject: [PATCH 30/59] nginx: specify nginx version (#1937) Closes #1936 --- nginx.dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nginx.dockerfile b/nginx.dockerfile index d2520a8c1..5c2cadf2d 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -13,9 +13,14 @@ RUN files/prebuild/build-frontend.sh -# when upgrading, look for upstream changes to redirector.conf -# also, confirm setup-odk.sh strips out HTTP-01 ACME challenge location -FROM jonasal/nginx-certbot:6.1.0 +# When upgrading: +# +# 1. Use full-length tag, including nginx version. See: +# * https://github.com/JonasAlfredsson/docker-nginx-certbot/blob/master/docs/dockerhub_tags.md +# * https://hub.docker.com/r/jonasal/nginx-certbot/tags +# 2. Look for upstream changes to redirector.conf +# 3. Confirm setup-odk.sh strips out HTTP-01 ACME challenge location. +FROM jonasal/nginx-certbot:6.1.0-nginx1.29.7 EXPOSE 80 EXPOSE 443 From c20f3ed8eb9918b601bd08c73ad7d2698a359574 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:38:38 +0300 Subject: [PATCH 31/59] test/check-docker-context: ignore .git directory (#1967) Closes #1810 --- .github/workflows/main.yml | 2 +- test/check-docker-context.sh | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eb1e6fe61..b07c1196c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,7 +93,7 @@ jobs: fetch-depth: 0 fetch-tags: true submodules: recursive - - run: ./test/check-docker-context.sh --min-size 90000 --max-size 110000 --min-count 1500 --max-count 1700 + - run: ./test/check-docker-context.sh --min-size 15000 --max-size 25000 --min-count 1400 --max-count 1500 - run: ./test/test-images.sh - if: always() run: docker compose logs diff --git a/test/check-docker-context.sh b/test/check-docker-context.sh index 4bb64831f..759e8f6d8 100755 --- a/test/check-docker-context.sh +++ b/test/check-docker-context.sh @@ -59,6 +59,13 @@ docker \ FROM busybox COPY . /build-context WORKDIR /build-context + +# Ignore .git files - ideally they wouldn't be included in the Docker +# context, but while they still are they cloud these checks because +# their count and size can vary between branches/forks/PRs. +# See: https://github.com/getodk/central/issues/1810 +RUN rm -rf ./.git/ + RUN find . -type f RUN du -s . EOF From 96cf4edf189d5b1c9fa9ee8dfe4ec4d22384f0fe Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:46:27 +0300 Subject: [PATCH 32/59] test: disable extended query parser for express servers (#1964) sn> --- test/nginx/mock-http-server/index.js | 1 + test/nginx/mock-sentry/index.js | 1 + 2 files changed, 2 insertions(+) diff --git a/test/nginx/mock-http-server/index.js b/test/nginx/mock-http-server/index.js index 54131e54e..e060af017 100644 --- a/test/nginx/mock-http-server/index.js +++ b/test/nginx/mock-http-server/index.js @@ -7,6 +7,7 @@ const requests = []; const app = express(); app.set('case sensitive routing', true); +app.set('query parser', 'simple'); app.use((req, res, next) => { console.log(new Date(), req.method, req.originalUrl); diff --git a/test/nginx/mock-sentry/index.js b/test/nginx/mock-sentry/index.js index 645ee8e13..c119c595d 100644 --- a/test/nginx/mock-sentry/index.js +++ b/test/nginx/mock-sentry/index.js @@ -19,6 +19,7 @@ const logErrorEvent = error => { const app = express(); app.set('case sensitive routing', true); +app.set('query parser', 'simple'); app.use(express.json({ type: [ 'application/json', From ce0366536c7b5c5b8d43642815b57fb21e1275ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Fri, 12 Jun 2026 12:02:56 -0700 Subject: [PATCH 33/59] Upgrade Enketo to 7.6.2 (#1965) --- enketo.dockerfile | 2 +- files/enketo/config.json.template | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/enketo.dockerfile b/enketo.dockerfile index b01eb9784..7e4ff492b 100644 --- a/enketo.dockerfile +++ b/enketo.dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/enketo/enketo:7.5.1 +FROM ghcr.io/enketo/enketo:7.6.2 ENV ENKETO_SRC_DIR=/srv/src/enketo/packages/enketo-express WORKDIR ${ENKETO_SRC_DIR} diff --git a/files/enketo/config.json.template b/files/enketo/config.json.template index fc3c5bde7..791daf2ae 100644 --- a/files/enketo/config.json.template +++ b/files/enketo/config.json.template @@ -35,5 +35,6 @@ "email": "support@getodk.org" }, "text field character limit": 1000000, - "exclude non-relevant": true + "exclude non-relevant": true, + "hide powered by": true } From c91fe3d7aff6a304c92ffbf3c1a1d2584db71e3c Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Mon, 15 Jun 2026 09:27:24 +1200 Subject: [PATCH 34/59] fix(web-forms#535): separate enketo and web-forms into their own vue app --- files/nginx/odk.conf.template | 20 ++++++++++++++++++++ nginx.dockerfile | 2 ++ 2 files changed, 22 insertions(+) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 896505dd6..2b210082e 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -187,6 +187,7 @@ server { } # End of Enketo Configuration. + # central-backend location ~ ^/v\d { proxy_hide_header Content-Security-Policy; add_header Content-Security-Policy $central_backend_csp always; @@ -214,6 +215,25 @@ server { try_files $uri @blank.html; } + # Handle requests for the Forms app + # The first part of the regex matches public link paths and Enketo style paths, eg: + # - /f/sCTIfjC5LrUto4yVXRYJkNKzP7e53vo?st=8T2mGshoNDbmWCUV7yd6qsYeojHrJSxih6EO3tJq5zVbJFHOx6ZLhif!$RUdmCHT + # The second part matches the restful paths, eg: + # - /projects/123/forms/abc + # - /projects/123/forms/abc/draft + # - /projects/123/forms/abc/preview + # - /projects/123/forms/abc/submissions/new + # - /projects/123/forms/abc/submissions/{id}/edit + location ~ ^/f/.+|/projects/\d+/forms/[^/]+(?:/draft)?(?:/preview|/submissions/new(?:/offline)?/?|/submissions/[^/]+/edit)/?$ { + root /usr/share/nginx/html; + try_files $uri $uri/ /apps/forms/index.html; + + add_header Content-Security-Policy-Report-Only "$central_frontend_csp" always; + + include /usr/share/odk/nginx/common-headers.conf; + } + + # central-frontend location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; diff --git a/nginx.dockerfile b/nginx.dockerfile index 5c2cadf2d..eedfedff1 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -1,5 +1,7 @@ FROM node:24.14.1-slim AS intermediate +ENV NODE_OPTIONS="--max-old-space-size=4096" + RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ From 1f0cf1e41dbb1c524f4524f95896a97f0195675f Mon Sep 17 00:00:00 2001 From: Gareth Bowen Date: Mon, 15 Jun 2026 20:06:31 +1200 Subject: [PATCH 35/59] fix: reduce runtime memory usage in nginx service --- nginx.dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nginx.dockerfile b/nginx.dockerfile index eedfedff1..28e127243 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -1,7 +1,5 @@ FROM node:24.14.1-slim AS intermediate -ENV NODE_OPTIONS="--max-old-space-size=4096" - RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ @@ -10,6 +8,7 @@ RUN apt-get update \ COPY ./ ./ RUN files/prebuild/write-version.sh +ARG NODE_OPTIONS="--max-old-space-size=4096" ARG SKIP_FRONTEND_BUILD RUN files/prebuild/build-frontend.sh From 6f8fe885b06e76482952e87a29f75b677976d90f Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:48:18 +0300 Subject: [PATCH 36/59] nginx/web-forms: assert correct HTML is served (#1980) --- files/prebuild/build-frontend.sh | 3 ++- test/nginx/src/mocha/nginx.spec.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/files/prebuild/build-frontend.sh b/files/prebuild/build-frontend.sh index 2e35ab736..5e4150524 100755 --- a/files/prebuild/build-frontend.sh +++ b/files/prebuild/build-frontend.sh @@ -8,7 +8,7 @@ if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then echo "[build-frontend] Skipping frontend build." # Create minimal fake frontend to allow tests to pass: - mkdir dist dist/assets dist/fonts + mkdir dist dist/assets dist/fonts dist/apps dist/apps/forms echo > dist/blank.html echo > dist/index.html '
' echo > dist/android-chrome-192x192.png @@ -17,6 +17,7 @@ if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then echo > dist/favicon-16x16.png echo > dist/favicon-32x32.png echo > dist/favicon.ico + echo > dist/apps/forms/index.html '
' echo > dist/site.webmanifest echo > dist/assets/actor-link-CHKNLRJ6.js diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 0b0340d17..b7b23d3d0 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -832,7 +832,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); - assert.equal(await res.text(), '
\n'); + assert.equal(await res.text(), '
\n'); assertSecurityHeaders(res, { csp:'web-forms' }); }); }); From 27cc8ef5735b0ba10d25fc8bd54acdfa5a950b24 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:11:41 +0300 Subject: [PATCH 37/59] nginx: narrow scope of NodeJS max-old-space-size (#1993) --- files/prebuild/build-frontend.sh | 2 +- nginx.dockerfile | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/files/prebuild/build-frontend.sh b/files/prebuild/build-frontend.sh index 5e4150524..8eaf14001 100755 --- a/files/prebuild/build-frontend.sh +++ b/files/prebuild/build-frontend.sh @@ -43,5 +43,5 @@ if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then exit else npm clean-install --no-audit --fund=false --update-notifier=false - npm run build + NODE_OPTIONS="--max-old-space-size=4096" npm run build fi diff --git a/nginx.dockerfile b/nginx.dockerfile index 28e127243..5c2cadf2d 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -8,7 +8,6 @@ RUN apt-get update \ COPY ./ ./ RUN files/prebuild/write-version.sh -ARG NODE_OPTIONS="--max-old-space-size=4096" ARG SKIP_FRONTEND_BUILD RUN files/prebuild/build-frontend.sh From 4007043b43a8a7a4fb5935013580a0f52609f292 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:19:10 +0300 Subject: [PATCH 38/59] build/nginx: reduce gc heap space allocation (#1994) Reduces max-old-space-size allocation by 2GB (50%). On dev.getodk.cloud this currently: * fails with 1024 * passes with 1536 This suggests decreasing to 2048 is: 1. a good saving, and 2. fairly safe --- files/prebuild/build-frontend.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/prebuild/build-frontend.sh b/files/prebuild/build-frontend.sh index 8eaf14001..90f29ff2d 100755 --- a/files/prebuild/build-frontend.sh +++ b/files/prebuild/build-frontend.sh @@ -43,5 +43,5 @@ if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then exit else npm clean-install --no-audit --fund=false --update-notifier=false - NODE_OPTIONS="--max-old-space-size=4096" npm run build + NODE_OPTIONS="--max-old-space-size=2048" npm run build fi From ddaf82fc39010c50de47010fa6fbc4fdf92a0c33 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:20:22 +0300 Subject: [PATCH 39/59] service: remove volume: data/transfer (#1992) Use no longer encouraged. Closes #1894 --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 627eeea1d..e2d453d7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,6 @@ services: - enketo volumes: - secrets:/etc/secrets - - /data/transfer:/data/transfer env_file: - ".env" environment: From 6c70ffb4dd11008fe4baef35f868d3e201a22ab7 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:20:42 +0300 Subject: [PATCH 40/59] docker-compose: remove dangling volume: transfer (#1973) Introduced in fe34e41c5d87744340384fe7fe4552182aa24e9d, it looks like this volume was never referenced. Ref #1894 --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e2d453d7c..b19516d45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -165,7 +165,6 @@ services: restart: always volumes: secrets: - transfer: postgres14: enketo_redis_main: enketo_redis_cache: From e67a2fa9d939c6e032fbad554d7ffa45160782cf Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:22:34 +0300 Subject: [PATCH 41/59] check-docker-context: use custom builder explicitly (#1990) Instead of changing the global builder default, just use the `docker_context_checker` custom builder for the specific job it was written for. This is helpful for dev machines so that other docker jobs are not affected by this script. --- test/check-docker-context.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/check-docker-context.sh b/test/check-docker-context.sh index 759e8f6d8..1ed9e7465 100755 --- a/test/check-docker-context.sh +++ b/test/check-docker-context.sh @@ -47,13 +47,13 @@ docker buildx rm docker_context_checker || true docker buildx create --name docker_context_checker \ --driver-opt env.BUILDKIT_STEP_LOG_MAX_SIZE=-1 \ --driver-opt env.BUILDKIT_STEP_LOG_MAX_SPEED=-1 -docker buildx use docker_context_checker log "Building docker image..." iidfile="$(mktemp)" ( docker \ buildx build --load \ + --builder docker_context_checker \ --iidfile "$iidfile" \ --no-cache --progress plain --file - . 2>&1 < Date: Mon, 15 Jun 2026 19:40:54 +0300 Subject: [PATCH 42/59] test/nginx: remove comment re: web-forms paths (#1977) The linked PR is very long, and doesn't give clear guidance/explanation of what's going on. Ultimately this test should be the source of truth for the paths which serve web-forms content, and more context can be added here if required. --- test/nginx/src/mocha/nginx.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index b7b23d3d0..c47dfc8ce 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -795,7 +795,6 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward }); describe('web-forms Content-Security-Policy special handling', () => { - // See https://github.com/getodk/central/pull/1467 for relevant paths [ '/projects/1/forms/some_xml_form_id/submissions/new', '/projects/1/forms/some_xml_form_id/submissions/new/', From 8ecbaa7d8dab8853d0e75020455e7ac038c98f71 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:05:40 +0300 Subject: [PATCH 43/59] nginx: remove misleading web-forms config (#1982) Related: #1978 --- files/nginx/odk.conf.template | 5 ----- 1 file changed, 5 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 2b210082e..7f67bcf16 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -225,12 +225,7 @@ server { # - /projects/123/forms/abc/submissions/new # - /projects/123/forms/abc/submissions/{id}/edit location ~ ^/f/.+|/projects/\d+/forms/[^/]+(?:/draft)?(?:/preview|/submissions/new(?:/offline)?/?|/submissions/[^/]+/edit)/?$ { - root /usr/share/nginx/html; try_files $uri $uri/ /apps/forms/index.html; - - add_header Content-Security-Policy-Report-Only "$central_frontend_csp" always; - - include /usr/share/odk/nginx/common-headers.conf; } # central-frontend From 3a437ac55013112fdfa0cbe44ad7311726115849 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:14:46 +0300 Subject: [PATCH 44/59] nginx/web-forms: simplify try_files directive (#1983) --- files/nginx/odk.conf.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 7f67bcf16..5bed17320 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -225,7 +225,7 @@ server { # - /projects/123/forms/abc/submissions/new # - /projects/123/forms/abc/submissions/{id}/edit location ~ ^/f/.+|/projects/\d+/forms/[^/]+(?:/draft)?(?:/preview|/submissions/new(?:/offline)?/?|/submissions/[^/]+/edit)/?$ { - try_files $uri $uri/ /apps/forms/index.html; + try_files $uri /apps/forms/index.html; } # central-frontend From 1288db9ca1e71bcc0efaabf0f5e69db62fb70b0e Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:12:46 +0300 Subject: [PATCH 45/59] nginx: merge duplicate web-forms regexes (#1984) Closes #1975 --- files/nginx/odk.conf.template | 34 ++++++++++++++---------------- files/prebuild/build-frontend.sh | 4 ++-- test/nginx/src/mocha/nginx.spec.js | 16 +++++++------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 5bed17320..4a5551c7b 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -85,12 +85,23 @@ map $arg_st $redirect_single_prefix { default "/new${is_args}${args}${qp_deliminator}single=true"; } -# Use 'none' per directive instead of falling back to default-src to make CSP violation reports more specific # Note: using $request_uri here remains safe while percent-encodings are not # normalised in frontend URLs. Tracked at https://github.com/getodk/central/issues/1532 -map $request_uri $central_frontend_csp { - # Web Forms CSP for /f/... and /projects/.../forms/... routes +map $request_uri $spa_name { + # form routes: approximately /f/... and /projects/.../forms/... ~^/(?:f/[^/]+(?:/.*)?|projects/\d+/forms/[^/]+/(?:(?:draft/)?(?:preview|submissions/new(?:/offline)?)|submissions/[^/]+/edit)(?:/)?)(?:\?.*)?$ + form-wrapper; + default + root-app; +} +map $spa_name $spa_html { + form-wrapper "/apps/forms/index.html"; + default "/index.html"; +} +map $spa_name $spa_csp { + # Use 'none' per directive instead of falling back to default-src to make CSP violation reports more specific + + form-wrapper "default-src 'report-sample' 'none'; connect-src 'self' https:; font-src 'self' data:; form-action 'self'; frame-ancestors 'self'; frame-src 'self' https://getodk.github.io/central/; img-src blob: data: https:; manifest-src 'self'; media-src blob:; object-src 'none'; script-src 'report-sample' 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; worker-src 'report-sample' blob: data:; report-uri /csp-report"; default @@ -215,25 +226,12 @@ server { try_files $uri @blank.html; } - # Handle requests for the Forms app - # The first part of the regex matches public link paths and Enketo style paths, eg: - # - /f/sCTIfjC5LrUto4yVXRYJkNKzP7e53vo?st=8T2mGshoNDbmWCUV7yd6qsYeojHrJSxih6EO3tJq5zVbJFHOx6ZLhif!$RUdmCHT - # The second part matches the restful paths, eg: - # - /projects/123/forms/abc - # - /projects/123/forms/abc/draft - # - /projects/123/forms/abc/preview - # - /projects/123/forms/abc/submissions/new - # - /projects/123/forms/abc/submissions/{id}/edit - location ~ ^/f/.+|/projects/\d+/forms/[^/]+(?:/draft)?(?:/preview|/submissions/new(?:/offline)?/?|/submissions/[^/]+/edit)/?$ { - try_files $uri /apps/forms/index.html; - } - # central-frontend location / { root /usr/share/nginx/html; - try_files $uri $uri/ /index.html; + try_files $uri $uri/ $spa_html; - add_header Content-Security-Policy "$central_frontend_csp" always; + add_header Content-Security-Policy "$spa_csp" always; include /usr/share/odk/nginx/common-headers.conf; } diff --git a/files/prebuild/build-frontend.sh b/files/prebuild/build-frontend.sh index 90f29ff2d..92f918683 100755 --- a/files/prebuild/build-frontend.sh +++ b/files/prebuild/build-frontend.sh @@ -10,14 +10,14 @@ if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then # Create minimal fake frontend to allow tests to pass: mkdir dist dist/assets dist/fonts dist/apps dist/apps/forms echo > dist/blank.html - echo > dist/index.html '
' + echo > dist/index.html '
' echo > dist/android-chrome-192x192.png echo > dist/android-chrome-512x512.png echo > dist/apple-touch-icon.png echo > dist/favicon-16x16.png echo > dist/favicon-32x32.png echo > dist/favicon.ico - echo > dist/apps/forms/index.html '
' + echo > dist/apps/forms/index.html '
' echo > dist/site.webmanifest echo > dist/assets/actor-link-CHKNLRJ6.js diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index c47dfc8ce..dc3791d78 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -164,7 +164,7 @@ const contentSecurityPolicies = { 'report-uri': '/csp-report', }), }, - 'web-forms': { + 'form-wrapper': { // web-forms, and the enketo iframe owner block: allowGoogleTranslate({ 'default-src': [ reportSample, @@ -181,7 +181,7 @@ const contentSecurityPolicies = { 'form-action': self, 'frame-ancestors': self, 'frame-src': [ - self, // web-forms pages also host /enketo-passthrough/ URLs via iframes + self, // web-forms wrapper pages also host /enketo-passthrough/ URLs via iframes centralNotifications, ], 'img-src': [ @@ -467,7 +467,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward }); [ - [ '/index.html', 'text/html', /
<\/div>/ ], + [ '/index.html', 'text/html', /
<\/div>/ ], [ '/version.txt', 'text/plain', /^versions:/ ], [ '/android-chrome-192x192.png', 'image/png', /^\n$/ ], [ '/android-chrome-512x512.png', 'image/png', /^\n$/ ], @@ -523,7 +523,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); - assert.equal(await res.text(), '
\n'); + assert.equal(await res.text(), '
\n'); assertSecurityHeaders(res, { csp:'central-frontend' }); // and @@ -572,7 +572,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward expected: `f/${enketoId}?st=${sessionToken}&single=false` }, ]; enketoRedirectTestData.forEach(t => { - it('should redirect old enketo links to central-frontend; ' + t.description, async () => { + it('should redirect old enketo links to forms-wrapper; ' + t.description, async () => { // when const res = await apiFetch(t.request); @@ -831,8 +831,8 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); - assert.equal(await res.text(), '
\n'); - assertSecurityHeaders(res, { csp:'web-forms' }); + assert.equal(await res.text(), '
\n'); + assertSecurityHeaders(res, { csp:'form-wrapper' }); }); }); @@ -875,7 +875,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); - assert.equal(await res.text(), '
\n'); + assert.equal(await res.text(), '
\n'); assertSecurityHeaders(res, { csp:'central-frontend' }); }); }); From 8967ba04637b2a31839a9d03c6b3d13839f5d339 Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:50:05 +0300 Subject: [PATCH 46/59] test/nginx: add more fake webforms paths (#1985) --- test/nginx/src/mocha/nginx.spec.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index dc3791d78..4c5f8a418 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -868,6 +868,10 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // all /f/* should be valid '/f', '/f/', + + // look like web-forms paths, but have unexpected prefixes + '/bypass/projects/123/forms/myform/preview', + '/static/assets/projects/5/forms/form1/submissions/new', ].forEach(path => { it(`should serve standard frontend Content Security Policy for fake webforms path: ${path}`, async () => { // when From 883431cc88100a4e6a6b91ea625c324acf692309 Mon Sep 17 00:00:00 2001 From: Jennifer Q <66472237+latin-panda@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:05:33 +0800 Subject: [PATCH 47/59] chore: pass frontend Sentry DSN (#2005) --- .env.template | 3 +++ README.md | 10 ++++++++++ docker-compose.yml | 1 + files/nginx/client-config.json.template | 3 ++- test/nginx/lib.docker-compose.yml | 1 + test/nginx/src/mocha/nginx.spec.js | 4 ++-- 6 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.env.template b/.env.template index 7ae48d260..39d6eb46f 100644 --- a/.env.template +++ b/.env.template @@ -43,6 +43,9 @@ HTTPS_PORT=443 # SENTRY_PROJECT= # SENTRY_TRACE_RATE= +# Optional: configure frontend error reporting +# ODK_CENTRAL_FRONTEND_SENTRY_DSN= + # Optional: configure S3-compatible storage for binary files # S3_SERVER= # S3_ACCESS_KEY= diff --git a/README.md b/README.md index 0185092aa..6d6bedb06 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ This repository serves administrative functions, but it also contains the Docker To learn how to run such a stack in production, please take a look at [our DigitalOcean installation guide](https://docs.getodk.org/central-install-digital-ocean/). +### Sentry (optional) + +To enable frontend error reporting and performance monitoring via Sentry, set `ODK_CENTRAL_FRONTEND_SENTRY_DSN` in your `.env` file (see `.env.template`) and restart: + +```sh +docker compose up -d +``` + +Deployments that omit this variable are unaffected — Sentry will remain disabled. + ## Node.js version We aim to use the latest [active LTS version of Node.js](https://github.com/nodejs/release/blob/main/README.md#release-schedule). This means that we generally update the major Node version used across all Central components once a year. Each time we do a Central release, we update to the latest version within the active LTS line. Node updates are done near the end of the release cycle but before regression testing. diff --git a/docker-compose.yml b/docker-compose.yml index b19516d45..af682abbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -106,6 +106,7 @@ services: - SENTRY_KEY=${SENTRY_KEY:-3cf75f54983e473da6bd07daddf0d2ee} - SENTRY_PROJECT=${SENTRY_PROJECT:-1298632} - OIDC_ENABLED=${OIDC_ENABLED:-false} + - ODK_CENTRAL_FRONTEND_SENTRY_DSN=${ODK_CENTRAL_FRONTEND_SENTRY_DSN:-} volumes: - ./files/local/customssl/:/etc/customssl/live/local/:ro - ./files/nginx/odk.conf.template:/usr/share/odk/nginx/odk.conf.template:ro diff --git a/files/nginx/client-config.json.template b/files/nginx/client-config.json.template index f1965a498..8f65de621 100644 --- a/files/nginx/client-config.json.template +++ b/files/nginx/client-config.json.template @@ -1,3 +1,4 @@ { - "oidcEnabled": ${OIDC_ENABLED} + "oidcEnabled": ${OIDC_ENABLED}, + "sentryDsn": "${ODK_CENTRAL_FRONTEND_SENTRY_DSN}" } diff --git a/test/nginx/lib.docker-compose.yml b/test/nginx/lib.docker-compose.yml index 9b792b5e4..7bcb917df 100644 --- a/test/nginx/lib.docker-compose.yml +++ b/test/nginx/lib.docker-compose.yml @@ -17,6 +17,7 @@ services: - SENTRY_ORG_SUBDOMAIN=o-fake-dsn - SENTRY_PROJECT=example-sentry-project - OIDC_ENABLED=false + - ODK_CENTRAL_FRONTEND_SENTRY_DSN=https://fake-dsn.fake-sentry volumes: - ../../files/nginx/odk.conf.template:/usr/share/odk/nginx/odk.conf.template:ro - ../../files/nginx/client-config.json.template:/usr/share/odk/nginx/client-config.json.template:ro diff --git a/test/nginx/src/mocha/nginx.spec.js b/test/nginx/src/mocha/nginx.spec.js index 4c5f8a418..32e7b7067 100644 --- a/test/nginx/src/mocha/nginx.spec.js +++ b/test/nginx/src/mocha/nginx.spec.js @@ -442,7 +442,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); - assert.deepEqual(await res.json(), { oidcEnabled: false }); + assert.deepEqual(await res.json(), { oidcEnabled: false, sentryDsn: 'https://fake-dsn.fake-sentry' }); assertSecurityHeaders(res, { csp:'central-frontend' }); }); @@ -452,7 +452,7 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward // then assert.equal(res.status, 200); - assert.deepEqual(await res.json(), { oidcEnabled: false }); + assert.deepEqual(await res.json(), { oidcEnabled: false, sentryDsn: 'https://fake-dsn.fake-sentry' }); assertSecurityHeaders(res, { csp:'central-frontend' }); }); From 8b8a0382da2a6d3359baad5380f46eecd595e5ec Mon Sep 17 00:00:00 2001 From: Alex Anderson <191496+alxndrsn@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:23:56 +0300 Subject: [PATCH 48/59] client: replace submodule with specific release (#1998) Closes #1996 --- .github/workflows/main.yml | 23 +++++++++++++-- .gitignore | 1 + .gitmodules | 3 -- client | 1 - docker-compose.yml | 3 ++ files/prebuild/build-frontend.sh | 48 +++++++++++++++++++++++++++---- files/prebuild/write-version.sh | 44 +++++++++++++++++++++++++--- nginx.dockerfile | 10 +++++-- test/check-submodules.sh | 13 --------- test/nginx/lib.docker-compose.yml | 3 +- test/test-images.sh | 15 ++++++++-- 11 files changed, 129 insertions(+), 35 deletions(-) delete mode 160000 client diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b07c1196c..2aac7df2f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,8 +93,27 @@ jobs: fetch-depth: 0 fetch-tags: true submodules: recursive - - run: ./test/check-docker-context.sh --min-size 15000 --max-size 25000 --min-count 1400 --max-count 1500 - - run: ./test/test-images.sh + + - run: ./test/check-docker-context.sh --min-size 5000 --max-size 10000 --min-count 600 --max-count 700 + + - name: Extract FRONTEND_VERSION + run: | + touch .env + echo "FRONTEND_VERSION=$( + docker compose config --format json | jq -r .services.nginx.build.args.FRONTEND_VERSION + )" >> "$GITHUB_ENV" + + - run: FRONTEND_BUILD_MODE=fetch ./test/test-images.sh + + # Check out the current frontend version referenced by docker-compose, as it should build OK. + - run: | + git clone \ + --depth 1 \ + --branch "$FRONTEND_VERSION" \ + https://github.com/getodk/central-frontend.git \ + client + - run: FRONTEND_BUILD_MODE=source ./test/test-images.sh + - if: always() run: docker compose logs build-push-image: diff --git a/.gitignore b/.gitignore index 6a1c90412..5bc6407ef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.swp *.swo /.env +/client/ /docker-compose.override.yml /version.txt diff --git a/.gitmodules b/.gitmodules index 349e0096e..935bc61c7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "server"] path = server url = https://github.com/getodk/central-backend.git -[submodule "client"] - path = client - url = https://github.com/getodk/central-frontend.git diff --git a/client b/client deleted file mode 160000 index 56b6bbdb4..000000000 --- a/client +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 56b6bbdb4cac59aa850e3ddd1cd19ab9f2be1af3 diff --git a/docker-compose.yml b/docker-compose.yml index af682abbe..6fd5a6f7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,6 +95,9 @@ services: build: context: . dockerfile: nginx.dockerfile + args: + FRONTEND_BUILD_MODE: ${FRONTEND_BUILD_MODE:-fetch} + FRONTEND_VERSION: v2026.2.0-beta.0 depends_on: - service - enketo diff --git a/files/prebuild/build-frontend.sh b/files/prebuild/build-frontend.sh index 92f918683..46b5bf70e 100755 --- a/files/prebuild/build-frontend.sh +++ b/files/prebuild/build-frontend.sh @@ -2,10 +2,10 @@ set -o pipefail shopt -s inherit_errexit -cd client +log() { echo >&2 "[build-frontend] $*"; } -if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then - echo "[build-frontend] Skipping frontend build." +if [[ $FRONTEND_BUILD_MODE = test ]]; then + log "Building mock frontend..." # Create minimal fake frontend to allow tests to pass: mkdir dist dist/assets dist/fonts dist/apps dist/apps/forms @@ -39,9 +39,47 @@ if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then } generateFile 10k-file.txt 10240 +elif [[ $FRONTEND_BUILD_MODE = source ]]; then + log "Building frontend from source..." + + if ! [[ -f client/package.json ]]; then + log "!!!" + log "!!! No frontend repository found at ./client" + log "!!!" + log "!!! Make sure this directory is present, or change FRONTEND_BUILD_MODE." + log "!!!" + exit 1 + fi + + cd client - exit -else npm clean-install --no-audit --fund=false --update-notifier=false NODE_OPTIONS="--max-old-space-size=2048" npm run build + + mv dist .. +elif [[ $FRONTEND_BUILD_MODE = fetch ]]; then + log "Fetching pre-built frontend..." + + filename="dist-$FRONTEND_VERSION.tar.gz" + + artifactUrl="https://github.com/getodk/central-frontend/releases/download/$FRONTEND_VERSION/$filename" + log "Fetching release artifact from $artifactUrl ..." + curl \ + --connect-timeout 60 \ + --fail \ + --retry 5 \ + --retry-delay 10 \ + --retry-all-errors \ + --location "$artifactUrl" \ + --output "$filename" + + log "Extracting dist bundle..." + tar --extract --file "$filename" +else + log "!!!" + log "!!! Unrecognised FRONTEND_BUILD_MODE: '$FRONTEND_BUILD_MODE'" + log "!!!" + exit 1 fi + +log "Completed OK." diff --git a/files/prebuild/write-version.sh b/files/prebuild/write-version.sh index a61ca8fd4..4f24b71dc 100755 --- a/files/prebuild/write-version.sh +++ b/files/prebuild/write-version.sh @@ -2,11 +2,47 @@ set -o pipefail shopt -s inherit_errexit +log() { + echo >&2 "[write-version] $*" +} + +print_version() { + printf ' %s %s (%s)\n' "$1" "$2" "$3" +} + +git_version() { + local path="$1" + pushd "$path" >/dev/null + commit="$(git rev-parse HEAD)" + tag="$(git describe --tags --always)" + print_version "$commit" "$path" "$tag" + popd >/dev/null +} + { echo "versions:" echo "$(git rev-parse HEAD) ($(git describe --tags --always))" - git submodule foreach --quiet --recursive \ - "commit=\$(git rev-parse HEAD); \ - tag=\$(git describe --tags --always); \ - printf ' %s %s (%s)\n' \"\$commit\" \"\$path\" \"\$tag\"" + + if [[ "$FRONTEND_BUILD_MODE" = fetch ]] || [[ "$FRONTEND_BUILD_MODE" = test ]]; then + print_version 0000000000000000000000000000000000000000 client "$FRONTEND_VERSION" + elif [[ "$FRONTEND_BUILD_MODE" = source ]]; then + if ! [[ -d ./client/.git ]]; then + log "!!!" + log "!!! No frontend git repository found at ./client/.git" + log "!!!" + log "!!! Make sure this directory is present, or change FRONTEND_BUILD_MODE." + log "!!!" + exit 1 + fi + + git_version client + else + log "!!!" + log "!!! Unrecognised FRONTEND_BUILD_MODE: '$FRONTEND_BUILD_MODE'" + log "!!!" + exit 1 + fi + + git_version server + } > /tmp/version.txt diff --git a/nginx.dockerfile b/nginx.dockerfile index 5c2cadf2d..eed940237 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -1,14 +1,18 @@ FROM node:24.14.1-slim AS intermediate +ARG FRONTEND_BUILD_MODE +ARG FRONTEND_VERSION + RUN apt-get update \ && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ git \ && rm -rf /var/lib/apt/lists/* COPY ./ ./ -RUN files/prebuild/write-version.sh -ARG SKIP_FRONTEND_BUILD +RUN files/prebuild/write-version.sh RUN files/prebuild/build-frontend.sh @@ -39,7 +43,7 @@ COPY files/nginx/setup-odk.sh \ COPY files/nginx/redirector.conf /usr/share/odk/nginx/ COPY files/nginx/common-headers.conf /usr/share/odk/nginx/ COPY files/nginx/robots.txt /usr/share/nginx/html -COPY --from=intermediate client/dist/ /usr/share/nginx/html +COPY --from=intermediate dist/ /usr/share/nginx/html COPY --from=intermediate /tmp/version.txt /usr/share/nginx/html ENTRYPOINT [ "/scripts/setup-odk.sh" ] diff --git a/test/check-submodules.sh b/test/check-submodules.sh index 2c86793c7..e142ab8fa 100755 --- a/test/check-submodules.sh +++ b/test/check-submodules.sh @@ -11,25 +11,12 @@ not_rel() { } check_submodules() { - local actualClientSubmodule local actualServerSubmodule - actualClientSubmodule="$(git config --file .gitmodules --get submodule.client.url)" actualServerSubmodule="$(git config --file .gitmodules --get submodule.server.url)" - local expectedClientSubmodule=https://github.com/getodk/central-frontend.git local expectedServerSubmodule=https://github.com/getodk/central-backend.git - if ! [[ "$actualClientSubmodule" = "$expectedClientSubmodule" ]]; then - log "!!!" - log "!!! client submodule is pointing to unexpected repo:" - log "!!!" - log "!!! actual: $actualClientSubmodule" - log "!!! expected: $expectedClientSubmodule" - log "!!!" - exit 1 - fi - if ! [[ "$actualServerSubmodule" = "$expectedServerSubmodule" ]]; then log "!!!" log "!!! server submodule is pointing to unexpected repo:" diff --git a/test/nginx/lib.docker-compose.yml b/test/nginx/lib.docker-compose.yml index 7bcb917df..36ffbc5c3 100644 --- a/test/nginx/lib.docker-compose.yml +++ b/test/nginx/lib.docker-compose.yml @@ -4,7 +4,8 @@ services: context: ../.. dockerfile: nginx.dockerfile args: - SKIP_FRONTEND_BUILD: true + FRONTEND_BUILD_MODE: test + FRONTEND_VERSION: v0.0.0 depends_on: - service - enketo diff --git a/test/test-images.sh b/test/test-images.sh index bb30278b5..3849fa3c0 100755 --- a/test/test-images.sh +++ b/test/test-images.sh @@ -43,19 +43,28 @@ SYSADMIN_EMAIL=no-reply@getodk.org' > .env touch ./files/allow-postgres14-upgrade -log "Building docker containers..." -docker compose build +log "Building docker containers (FRONTEND_BUILD_MODE='${FRONTEND_BUILD_MODE-}')..." +docker compose build --build-arg "FRONTEND_BUILD_MODE=$FRONTEND_BUILD_MODE" log "Starting containers..." docker compose up --detach log "Verifying version.txt..." +case "$FRONTEND_BUILD_MODE" in + source) expectedClientHash="$( cd client && git rev-parse HEAD)" + expectedClientVersion="$(cd client && git describe --tags --always)" + ;; + fetch|test) expectedClientHash="0000000000000000000000000000000000000000" + expectedClientVersion="$FRONTEND_VERSION" + ;; + *) exit 1 +esac diff \ <(docker compose exec nginx cat /usr/share/nginx/html/version.txt) \ <(cat < Date: Tue, 23 Jun 2026 12:31:01 +0900 Subject: [PATCH 49/59] chore: rename variable to SENTRY_DSN_FRONTEND (#2010) --- .env.template | 2 +- README.md | 2 +- docker-compose.yml | 2 +- files/nginx/client-config.json.template | 2 +- test/nginx/lib.docker-compose.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index 39d6eb46f..53566ed78 100644 --- a/.env.template +++ b/.env.template @@ -44,7 +44,7 @@ HTTPS_PORT=443 # SENTRY_TRACE_RATE= # Optional: configure frontend error reporting -# ODK_CENTRAL_FRONTEND_SENTRY_DSN= +# SENTRY_DSN_FRONTEND= # Optional: configure S3-compatible storage for binary files # S3_SERVER= diff --git a/README.md b/README.md index 6d6bedb06..c157c80b7 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ To learn how to run such a stack in production, please take a look at [our Digit ### Sentry (optional) -To enable frontend error reporting and performance monitoring via Sentry, set `ODK_CENTRAL_FRONTEND_SENTRY_DSN` in your `.env` file (see `.env.template`) and restart: +To enable frontend error reporting and performance monitoring via Sentry, set `SENTRY_DSN_FRONTEND` in your `.env` file (see `.env.template`) and restart: ```sh docker compose up -d diff --git a/docker-compose.yml b/docker-compose.yml index 6fd5a6f7d..e27f87f27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -109,7 +109,7 @@ services: - SENTRY_KEY=${SENTRY_KEY:-3cf75f54983e473da6bd07daddf0d2ee} - SENTRY_PROJECT=${SENTRY_PROJECT:-1298632} - OIDC_ENABLED=${OIDC_ENABLED:-false} - - ODK_CENTRAL_FRONTEND_SENTRY_DSN=${ODK_CENTRAL_FRONTEND_SENTRY_DSN:-} + - SENTRY_DSN_FRONTEND=${SENTRY_DSN_FRONTEND:-} volumes: - ./files/local/customssl/:/etc/customssl/live/local/:ro - ./files/nginx/odk.conf.template:/usr/share/odk/nginx/odk.conf.template:ro diff --git a/files/nginx/client-config.json.template b/files/nginx/client-config.json.template index 8f65de621..2550bcdd8 100644 --- a/files/nginx/client-config.json.template +++ b/files/nginx/client-config.json.template @@ -1,4 +1,4 @@ { "oidcEnabled": ${OIDC_ENABLED}, - "sentryDsn": "${ODK_CENTRAL_FRONTEND_SENTRY_DSN}" + "sentryDsn": "${SENTRY_DSN_FRONTEND}" } diff --git a/test/nginx/lib.docker-compose.yml b/test/nginx/lib.docker-compose.yml index 36ffbc5c3..8b13c53f0 100644 --- a/test/nginx/lib.docker-compose.yml +++ b/test/nginx/lib.docker-compose.yml @@ -18,7 +18,7 @@ services: - SENTRY_ORG_SUBDOMAIN=o-fake-dsn - SENTRY_PROJECT=example-sentry-project - OIDC_ENABLED=false - - ODK_CENTRAL_FRONTEND_SENTRY_DSN=https://fake-dsn.fake-sentry + - SENTRY_DSN_FRONTEND=https://fake-dsn.fake-sentry volumes: - ../../files/nginx/odk.conf.template:/usr/share/odk/nginx/odk.conf.template:ro - ../../files/nginx/client-config.json.template:/usr/share/odk/nginx/client-config.json.template:ro From 748958534e4a6e193c5b24defdd91febe0dc3864 Mon Sep 17 00:00:00 2001 From: Jennifer Q <66472237+latin-panda@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:05:10 +0900 Subject: [PATCH 50/59] chore: updates PR template (#2014) --- .github/PULL_REQUEST_TEMPLATE.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 078c26f20..8362236c0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,12 @@ +> [!WARNING] +> Branch off and target `next`, not `master`. The `master` is stable and used in production (exception: documentation/infrastructure-only changes). + Closes # #### What has been done to verify that this works as intended? #### Why is this the best possible solution? Were any other approaches considered? -#### How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks? +#### How does this change impact users? Describe intentional behavior changes from code updates. What are the regression risks? #### Does this change require updates to documentation? If so, please file an issue [here](https://github.com/getodk/docs/issues/new) and include the link below. - -#### Before submitting this PR, please make sure you have: - -- [ ] branched off and targeted the `next` branch OR only changed documentation/infrastructure (`master` is stable and used in production) -- [ ] verified that any code or assets from external sources are properly credited in comments or that everything is internally sourced From 17ec5a4f438ca1d69c20b35afa81c4ef0fc62cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 14:32:32 -0700 Subject: [PATCH 51/59] Update pyxform image version to v4.5.0 (#2020) --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e27f87f27..31e2f5fb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -125,7 +125,7 @@ services: options: max-file: "30" pyxform: - image: 'ghcr.io/getodk/pyxform-http:v4.4.1' + image: 'ghcr.io/getodk/pyxform-http:v4.5.0' restart: always secrets: volumes: From 1214873d8621bb8a2bcb3570ab77597d82509bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 15:07:20 -0700 Subject: [PATCH 52/59] Upgrade node to 24.16.0 Newer versions have been released but less than 14 days ago. None of the high priority CVEs fixed affect us. --- nginx.dockerfile | 2 +- secrets.dockerfile | 2 +- service.dockerfile | 2 +- test/nginx/mock-http-service.dockerfile | 2 +- test/nginx/mock-sentry.dockerfile | 2 +- test/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/nginx.dockerfile b/nginx.dockerfile index eed940237..17a108067 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -1,4 +1,4 @@ -FROM node:24.14.1-slim AS intermediate +FROM node:24.16.0-slim AS intermediate ARG FRONTEND_BUILD_MODE ARG FRONTEND_VERSION diff --git a/secrets.dockerfile b/secrets.dockerfile index 2c8a623e1..a2217ff2c 100644 --- a/secrets.dockerfile +++ b/secrets.dockerfile @@ -1,3 +1,3 @@ -FROM node:24.14.1-slim +FROM node:24.16.0-slim COPY files/enketo/generate-secrets.sh ./ diff --git a/service.dockerfile b/service.dockerfile index c7e8bf8f1..b947d1d72 100644 --- a/service.dockerfile +++ b/service.dockerfile @@ -1,4 +1,4 @@ -ARG node_version=24.14.1 +ARG node_version=24.16.0 diff --git a/test/nginx/mock-http-service.dockerfile b/test/nginx/mock-http-service.dockerfile index e28d92655..fdbe340a6 100644 --- a/test/nginx/mock-http-service.dockerfile +++ b/test/nginx/mock-http-service.dockerfile @@ -1,4 +1,4 @@ -FROM node:24.14.1-slim +FROM node:24.16.0-slim WORKDIR /workspace diff --git a/test/nginx/mock-sentry.dockerfile b/test/nginx/mock-sentry.dockerfile index 8e6023cd5..a4a122eba 100644 --- a/test/nginx/mock-sentry.dockerfile +++ b/test/nginx/mock-sentry.dockerfile @@ -1,4 +1,4 @@ -FROM node:24.14.1-slim +FROM node:24.16.0-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ diff --git a/test/package.json b/test/package.json index d5b3414e6..c3c0a9ae6 100644 --- a/test/package.json +++ b/test/package.json @@ -20,6 +20,6 @@ "yaml": "^2.8.2" }, "volta": { - "node": "24.14.1" + "node": "24.16.0" } } From 1e4cf828f8c4aefee91acebcf5d3eff74caaf4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 15:19:01 -0700 Subject: [PATCH 53/59] Upgrade nginx to 1.31.2 It was released only a week ago but it fixes additional buffer overread and overflow issues over 1.31.1 released a month ago. --- nginx.dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx.dockerfile b/nginx.dockerfile index 17a108067..ee8437224 100644 --- a/nginx.dockerfile +++ b/nginx.dockerfile @@ -24,7 +24,7 @@ RUN files/prebuild/build-frontend.sh # * https://hub.docker.com/r/jonasal/nginx-certbot/tags # 2. Look for upstream changes to redirector.conf # 3. Confirm setup-odk.sh strips out HTTP-01 ACME challenge location. -FROM jonasal/nginx-certbot:6.1.0-nginx1.29.7 +FROM jonasal/nginx-certbot:6.2.0-nginx1.31.2 EXPOSE 80 EXPOSE 443 From ac221166a73c22ad27fc598fb0d15f149974e188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 15:25:52 -0700 Subject: [PATCH 54/59] Upgrade Postgres to 14.23 --- postgres14.dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/postgres14.dockerfile b/postgres14.dockerfile index a6f5bc71d..8af86326f 100644 --- a/postgres14.dockerfile +++ b/postgres14.dockerfile @@ -1,4 +1,4 @@ -FROM postgres:14.22 +FROM postgres:14.23 COPY files/postgres14/start-postgres.sh /usr/local/bin/ From eb8127f0f2460a0571684c0337af6c0d3e67d611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 15:34:39 -0700 Subject: [PATCH 55/59] Upgrade smtp image to latest in 1.2.x 2.0.0 was released 3 weeks ago and followed up with point releases. Let's wait until it stabilizes, we don't need any of the udpates. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 31e2f5fb4..770e09998 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: POSTGRES_PASSWORD: odk POSTGRES_DB: odk mail: - image: "registry.gitlab.com/egos-tech/smtp:1.2.2" + image: "registry.gitlab.com/egos-tech/smtp:1.2.8" volumes: - ./files/mail/rsa.private:/etc/exim4/dkim.key.temp:ro environment: From 41d08277a8c7bf6acd6fb4b3f0f1c5cc45897596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 15:42:14 -0700 Subject: [PATCH 56/59] Upgrade redis to latest in 8.6.x 8.8 has been out a month but it makes some significant changes so I'd rather be conservative --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 770e09998..dc1c1defd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -150,7 +150,7 @@ services: - SUPPORT_EMAIL=${SYSADMIN_EMAIL} - HTTPS_PORT=${HTTPS_PORT:-443} enketo_redis_main: - image: redis:8.6.2 + image: redis:8.6.4 volumes: - ./files/enketo/redis-enketo-main.conf:/usr/local/etc/redis/redis.conf:ro - enketo_redis_main:/data @@ -159,7 +159,7 @@ services: - /usr/local/etc/redis/redis.conf restart: always enketo_redis_cache: - image: redis:8.6.2 + image: redis:8.6.4 volumes: - ./files/enketo/redis-enketo-cache.conf:/usr/local/etc/redis/redis.conf:ro - enketo_redis_cache:/data From d058b881a2067d0060937f849483e2591b74e81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 15:58:07 -0700 Subject: [PATCH 57/59] Upgrade test dependencies --- test/package-lock.json | 94 +++++++++++++++++++++--------------------- test/package.json | 8 ++-- 2 files changed, 50 insertions(+), 52 deletions(-) diff --git a/test/package-lock.json b/test/package-lock.json index 39b27c148..76de10631 100644 --- a/test/package-lock.json +++ b/test/package-lock.json @@ -7,13 +7,13 @@ "name": "odk-central-tests", "dependencies": { "@playwright/test": "^1.58.2", - "chai": "^5.2.0", - "deep-equal-in-any-order": "^2.1.0", + "chai": "^5.3.3", + "deep-equal-in-any-order": "^2.2.0", "eslint": "^9.28.0", "eslint-plugin-no-only-tests": "^3.3.0", "express": "^4.22.2", - "mocha": "^11.7.5", - "yaml": "^2.8.2" + "mocha": "^11.3.0", + "yaml": "^2.9.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -396,9 +396,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -469,9 +469,10 @@ } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -480,7 +481,7 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -700,13 +701,12 @@ } }, "node_modules/deep-equal-in-any-order": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-2.1.0.tgz", - "integrity": "sha512-9FklcFjcehm1yBWiOYtmazJOiMbT+v81Kq6nThIuXbWLWIZMX3ZI+QoLf7wCi0T8XzTAXf6XqEdEyVrjZkhbGA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-2.2.0.tgz", + "integrity": "sha512-lUYf3Oz/HrPcNmKe+S+QSdY5/hzKleftcFBWLwbHNZ5007RUKgN0asWlAHuQGvT9djYd9PYQFiu0TyNS+h3j/g==", "license": "MIT", "dependencies": { - "lodash.mapvalues": "^4.6.0", - "sort-any": "^2.0.0" + "sort-any": "^4.0.0" } }, "node_modules/deep-is": { @@ -1294,9 +1294,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1537,9 +1537,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1597,18 +1607,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash.mapvalues": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", - "integrity": "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -1732,9 +1730,9 @@ } }, "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", @@ -1768,9 +1766,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -2307,12 +2305,12 @@ } }, "node_modules/sort-any": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/sort-any/-/sort-any-2.0.0.tgz", - "integrity": "sha512-T9JoiDewQEmWcnmPn/s9h/PH9t3d/LSWi0RgVmXSuDYeZXTZOZ1/wrK2PHaptuR1VXe3clLLt0pD6sgVOwjNEA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/sort-any/-/sort-any-4.0.7.tgz", + "integrity": "sha512-UuZVEXClHW+bVa6ZBQ4biTWmLXMP7y6/jv5arfA0rKk7ZExy+5Zm19uekIqqDx6ZuvUMu7z5Ba9FfBi6FlGXPQ==", "license": "MIT", - "dependencies": { - "lodash": "^4.17.21" + "engines": { + "node": ">=12" } }, "node_modules/statuses": { @@ -2636,9 +2634,9 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/test/package.json b/test/package.json index c3c0a9ae6..2a557d52c 100644 --- a/test/package.json +++ b/test/package.json @@ -11,13 +11,13 @@ }, "dependencies": { "@playwright/test": "^1.58.2", - "chai": "^5.2.0", - "deep-equal-in-any-order": "^2.1.0", + "chai": "^5.3.3", + "deep-equal-in-any-order": "^2.2.0", "eslint": "^9.28.0", "eslint-plugin-no-only-tests": "^3.3.0", "express": "^4.22.2", - "mocha": "^11.7.5", - "yaml": "^2.8.2" + "mocha": "^11.7.6", + "yaml": "^2.9.0" }, "volta": { "node": "24.16.0" From c0898a8d99ce3896d423ac9a9798af09ab8b803f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9l=C3=A8ne=20Martin?= Date: Thu, 25 Jun 2026 16:37:19 -0700 Subject: [PATCH 58/59] Upgrade playwright to fix CI --- test/package-lock.json | 26 +++++++++++++------------- test/package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/package-lock.json b/test/package-lock.json index 76de10631..a5d3b6886 100644 --- a/test/package-lock.json +++ b/test/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "odk-central-tests", "dependencies": { - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.60.0", "chai": "^5.3.3", "deep-equal-in-any-order": "^2.2.0", "eslint": "^9.28.0", "eslint-plugin-no-only-tests": "^3.3.0", "express": "^4.22.2", - "mocha": "^11.3.0", + "mocha": "^11.7.6", "yaml": "^2.9.0" } }, @@ -234,12 +234,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -1970,12 +1970,12 @@ "license": "ISC" }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -1988,9 +1988,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/test/package.json b/test/package.json index 2a557d52c..f372bc0ae 100644 --- a/test/package.json +++ b/test/package.json @@ -10,7 +10,7 @@ "test": "npm run lint && npm run test:github-actions && npm run test:nginx && npm run test:service" }, "dependencies": { - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.60.0", "chai": "^5.3.3", "deep-equal-in-any-order": "^2.2.0", "eslint": "^9.28.0", From d30c049dea9feabcb0e3f29ab0f872d120efbc54 Mon Sep 17 00:00:00 2001 From: Matthew White Date: Mon, 29 Jun 2026 19:16:45 -0400 Subject: [PATCH 59/59] Update FRONTEND_VERSION before regression testing --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index dc1c1defd..977e5d613 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,7 +97,7 @@ services: dockerfile: nginx.dockerfile args: FRONTEND_BUILD_MODE: ${FRONTEND_BUILD_MODE:-fetch} - FRONTEND_VERSION: v2026.2.0-beta.0 + FRONTEND_VERSION: v2026.2.0-beta.1 depends_on: - service - enketo