From 8092ac42cb4b0c5e79af6e57a97e14420fcbe7b7 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 3 Dec 2025 12:26:30 +0000 Subject: [PATCH] CSP: frontend: allow Google Translate images This commit: * makes the frontend policy consistent with other policies which allow for Google Translate images * provides a template for addition of other browser-plugin-related policy * removes enforcement of policy order, although the served policies maintain their current ordering Closes #1518 use more specific naming and avoid commentary --- files/nginx/odk.conf.template | 2 +- test/nginx/package-lock.json | 32 +++++++++++++++++++++++ test/nginx/package.json | 1 + test/nginx/test-nginx.js | 49 ++++++++++++++++++++++------------- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index 1362b54d8..5e61c2490 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -186,7 +186,7 @@ server { # Rules set to 'none' here would fallback to default-src if excluded. # They are included here to ease interpretation of violation reports. - add_header Content-Security-Policy-Report-Only "default-src 'none'; connect-src 'self' https://translate.google.com https://translate.googleapis.com; font-src 'self'; frame-src 'self' https://getodk.github.io/central/news.html; img-src * data:; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; worker-src data:; report-uri /csp-report"; + add_header Content-Security-Policy-Report-Only "default-src 'none'; connect-src 'self' https://translate.google.com https://translate.googleapis.com; font-src 'self'; frame-src 'self' https://getodk.github.io/central/news.html; img-src * data: https://translate.google.com; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; worker-src data:; report-uri /csp-report"; include /usr/share/odk/nginx/common-headers.conf; } diff --git a/test/nginx/package-lock.json b/test/nginx/package-lock.json index 999cd3a54..c9e741e28 100644 --- a/test/nginx/package-lock.json +++ b/test/nginx/package-lock.json @@ -7,6 +7,7 @@ "name": "odk-central-tests", "dependencies": { "chai": "^5.2.0", + "deep-equal-in-any-order": "^2.1.0", "eslint": "^9.28.0", "mocha": "^11.6.0" } @@ -534,6 +535,16 @@ "node": ">=6" } }, + "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==", + "license": "MIT", + "dependencies": { + "lodash.mapvalues": "^4.6.0", + "sort-any": "^2.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1040,6 +1051,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "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", @@ -1383,6 +1406,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/test/nginx/package.json b/test/nginx/package.json index 820affbb2..4355b1fef 100644 --- a/test/nginx/package.json +++ b/test/nginx/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "chai": "^5.2.0", + "deep-equal-in-any-order": "^2.1.0", "eslint": "^9.28.0", "mocha": "^11.6.0" }, diff --git a/test/nginx/test-nginx.js b/test/nginx/test-nginx.js index 28f367595..df8931405 100644 --- a/test/nginx/test-nginx.js +++ b/test/nginx/test-nginx.js @@ -1,30 +1,46 @@ const https = require('node:https'); const tls = require('node:tls'); const { Readable } = require('stream'); -const { assert } = require('chai'); + +const deepEqualInAnyOrder = require('deep-equal-in-any-order'); +const chai = require('chai'); +chai.use(deepEqualInAnyOrder); +const { assert } = chai; const none = `'none'`; const self = `'self'`; const unsafeInline = `'unsafe-inline'`; + +const asArray = val => { + if (val == null) return []; + if (Array.isArray(val)) return val; + return [val]; +}; +const allowGoogleTranslate = ({ 'connect-src':connectSrc, 'img-src':imgSrc, ...others }) => ({ + ...others, + 'connect-src': [ + ...asArray(connectSrc), + 'https://translate.google.com', + 'https://translate.googleapis.com', + ], + 'img-src': [ + ...asArray(imgSrc), + 'https://translate.google.com', + ], +}); + const contentSecurityPolicies = { - 'backend-default': { + 'backend-default': allowGoogleTranslate({ 'default-src': none, - 'connect-src': [ - 'https://translate.google.com', - 'https://translate.googleapis.com', - ], - 'img-src': 'https://translate.google.com', 'report-uri': '/csp-report', - }, + }), 'backend-unmodified': { 'default-src': 'NOTE:FROM-BACKEND', }, - 'central-frontend': { + 'central-frontend': allowGoogleTranslate({ 'default-src': none, 'connect-src': [ self, - 'https://translate.google.com', - 'https://translate.googleapis.com', ], 'font-src': self, 'frame-src': [ @@ -40,11 +56,11 @@ const contentSecurityPolicies = { 'style-src-attr': unsafeInline, 'worker-src': 'data:', 'report-uri': '/csp-report', - }, + }), 'disallow-all': { 'default-src': none, }, - enketo: { + enketo: allowGoogleTranslate({ 'default-src': none, 'connect-src': [ self, @@ -54,8 +70,6 @@ const contentSecurityPolicies = { 'https://maps.gstatic.com/mapfiles/', 'https://fonts.gstatic.com/', 'https://fonts.googleapis.com/', - 'https://translate.google.com', - 'https://translate.googleapis.com', ], 'font-src': [ self, @@ -71,7 +85,6 @@ const contentSecurityPolicies = { 'https://maps.gstatic.com/mapfiles/', 'https://maps.googleapis.com/maps/', 'https://tile.openstreetmap.org/', - 'https://translate.google.com', ], 'manifest-src': none, 'media-src': [ @@ -94,7 +107,7 @@ const contentSecurityPolicies = { ], 'style-src-attr': unsafeInline, 'report-uri': '/csp-report', - }, + }), }; describe('nginx config', () => { @@ -863,5 +876,5 @@ function assertSecurityHeaders(res, { csp }) { const expectedCsp = contentSecurityPolicies[csp]; if(!expectedCsp) assert.fail(`Tried to match unknown CSP '${csp}'`); const actualCsp = res.headers.get('Content-Security-Policy-Report-Only'); - assert.deepEqual(actualCsp.split('; '), Object.entries(expectedCsp).map(([ k, v ]) => `${k} ${Array.isArray(v) ? v.join(' ') : v}`)); + assert.deepEqualInAnyOrder(actualCsp.split('; '), Object.entries(expectedCsp).map(([ k, v ]) => `${k} ${Array.isArray(v) ? v.join(' ') : v}`)); }