Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions files/nginx/odk.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
120 changes: 120 additions & 0 deletions test/nginx/test-nginx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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&param=2',
'/projects/1/forms/some_xml_form_id/submissions/new/?fake=true&query=false&param=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(), '<div id="app"></div>\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(), '<div id="app"></div>\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' } });
Expand Down