diff --git a/files/nginx/odk.conf.template b/files/nginx/odk.conf.template index efb36c3f6..6ab6e0ce6 100644 --- a/files/nginx/odk.conf.template +++ b/files/nginx/odk.conf.template @@ -75,6 +75,18 @@ 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 + ~^/(?:f/[^/]+(?:/.*)?|projects/\d+/forms/[^/]+/(?:(?:draft/)?(?:preview|submissions/new(?:/offline)?)|submissions/[^/]+/edit)(?:/)?)(?:\?.*)?$ + "default-src 'none'; connect-src 'self' https:; font-src 'self' data:; frame-src 'none'; img-src blob: https:; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; worker-src blob:; report-uri /csp-report"; + + default + "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:; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; worker-src blob:; report-uri /csp-report"; +} + server { listen 443 ssl; http2 on; @@ -182,8 +194,7 @@ server { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; - # Use 'none' per directive instead of falling back to default-src to make CSP violation reports more specific - 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:; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; worker-src blob:; report-uri /csp-report"; + add_header Content-Security-Policy-Report-Only "$central_frontend_csp"; include /usr/share/odk/nginx/common-headers.conf; } diff --git a/test/nginx/test-nginx.js b/test/nginx/test-nginx.js index 07e9a204a..e015f27a5 100644 --- a/test/nginx/test-nginx.js +++ b/test/nginx/test-nginx.js @@ -10,6 +10,7 @@ const { assert } = chai; const none = `'none'`; const self = `'self'`; const unsafeInline = `'unsafe-inline'`; +const wasmUnsafeEval = `'wasm-unsafe-eval'`; const asArray = val => { if (val == null) return []; @@ -114,6 +115,37 @@ const contentSecurityPolicies = { 'style-src-attr': unsafeInline, 'report-uri': '/csp-report', }), + 'web-forms': allowGoogleTranslate({ + 'default-src': none, + 'connect-src': [ + self, + 'https:', + ], + 'font-src': [ + self, + 'data:', + ], + 'frame-src': none, + 'img-src': [ + 'blob:', + 'https:', + ], + 'manifest-src': none, + 'media-src': none, + 'object-src': none, + 'script-src': [ + self, + wasmUnsafeEval, + ], + 'style-src': [ + self, + unsafeInline, + ], + 'worker-src': [ + 'blob:' + ], + 'report-uri': '/csp-report', + }), }; describe('nginx config', () => { @@ -416,6 +448,94 @@ describe('nginx config', () => { assert.equal(body['x-forwarded-proto'], 'https'); }); + 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/', + '/projects/1/forms/some_xml_form_id/submissions/new?fake=true&query=false¶m=2', + '/projects/1/forms/some_xml_form_id/submissions/new/?fake=true&query=false¶m=2', + '/projects/1/forms/some_xml_form_id/submissions/new/offline', + '/projects/1/forms/some_xml_form_id/submissions/new/offline/', + '/projects/1/forms/some_xml_form_id/submissions/00000000-0000-0000-0000-000000000000/edit', + '/projects/1/forms/some_xml_form_id/submissions/00000000-0000-0000-0000-000000000000/edit/', + '/projects/1/forms/some_xml_form_id/preview', + '/projects/1/forms/some_xml_form_id/preview/', + '/projects/1/forms/some_xml_form_id/draft/submissions/new', + '/projects/1/forms/some_xml_form_id/draft/submissions/new/', + '/projects/1/forms/some_xml_form_id/draft/submissions/new/offline', + '/projects/1/forms/some_xml_form_id/draft/submissions/new/offline/', + '/projects/1/forms/some_xml_form_id/draft/preview', + '/projects/1/forms/some_xml_form_id/draft/preview/', + '/f/anything', + '/f/anything/', + '/f/SCUZtGUjC7fgL2O1AXqqG8YN8Jdkthi?st=vcm7tFeqEFR1Itrmjq50KEFSrK$osbXrtu', + '/f/SCUZtGUjC7fgL2O1AXqqG8YN8Jdkthi/?st=vcm7tFeqEFR1Itrmjq50KEFSrK$osbXrtu', + + // invalid submission ID - currently not checking for valid UUIDs + '/projects/1/forms/some_xml_form_id/submissions/any-old-nonsense/edit', + '/projects/1/forms/some_xml_form_id/submissions/any-old-nonsense/edit/', + + // longer project id, shorter form ID + '/projects/99999/forms/_/submissions/new', + '/projects/99999/forms/_/submissions/new/', + ].forEach(path => { + it(`should add specific Content Security Policy restrictions for webforms path: ${path}`, async () => { + // when + const res = await fetchHttps(path); + + // then + assert.equal(res.status, 200); + assert.equal(await res.text(), '
\n'); + assertSecurityHeaders(res, { csp:'web-forms' }); + }); + }); + + [ + '/projects/1/forms/MarkdownExamples', // no /preview + '/projects/1/forms/preview/perview', // misspelt preview + '/projects/3/forms/preview', // form named "preview", but not the actual preview path + + // invalid project ids + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/submissions/new', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/submissions/new/', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/submissions/00000000-0000-0000-0000-000000000000/edit', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/submissions/00000000-0000-0000-0000-000000000000/edit/', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/preview', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/preview/', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/draft/submissions/new', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/draft/submissions/new/', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/draft/preview', + '/projects/1-not-just-a-number-1/forms/some_xml_form_id/draft/preview/', + + // missing project id + '/projects//forms/some_xml_form_id/submissions/new', + '/projects//forms/some_xml_form_id/submissions/new/', + + // missing form id + '/projects/1/forms//preview', + '/projects/1/forms//preview/', + + // missing submission ID + '/projects/1/forms/some_xml_form_id/submissions//edit', + '/projects/1/forms/some_xml_form_id/submissions//edit/', + + // all /f/* should be valid + '/f', + '/f/', + ].forEach(path => { + it(`should serve standard frontend Content Security Policy for fake webforms path: ${path}`, async () => { + // when + const res = await fetchHttps(path); + + // then + assert.equal(res.status, 200); + assert.equal(await res.text(), '\n'); + assertSecurityHeaders(res, { csp:'central-frontend' }); + }); + }); + }); + it('should reject HTTP requests with incorrect host header supplied', async () => { // when const res = await fetchHttp('/', { headers:{ host:'bad.example.com' } });