Skip to content

Commit 5718537

Browse files
Merge pull request #1773 from getodk/next
Release v2026.1.1
2 parents 0895149 + bd6b195 commit 5718537

11 files changed

Lines changed: 112 additions & 35 deletions

File tree

docker-compose.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ services:
8080
- OIDC_ISSUER_URL=${OIDC_ISSUER_URL:-}
8181
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
8282
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
83-
- PGAPPNAME=${PGAPPNAME:-odkcentral}
8483
- SENTRY_ORG_SUBDOMAIN=${SENTRY_ORG_SUBDOMAIN:-o130137}
8584
- SENTRY_KEY=${SENTRY_KEY:-3cf75f54983e473da6bd07daddf0d2ee}
8685
- SENTRY_PROJECT=${SENTRY_PROJECT:-1298632}
@@ -124,7 +123,7 @@ services:
124123
options:
125124
max-file: "30"
126125
pyxform:
127-
image: 'ghcr.io/getodk/pyxform-http:v4.4.0'
126+
image: 'ghcr.io/getodk/pyxform-http:v4.4.1'
128127
restart: always
129128
secrets:
130129
volumes:

files/nginx/odk.conf.template

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,32 @@ server {
88
return 421;
99
}
1010

11+
types {
12+
application/manifest+json webmanifest;
13+
}
14+
1115
map "$request_method::$uri$is_args$args" $cache_strategy {
1216
# general
13-
~^(GET|HEAD)::/client-config.json$ "revalidate";
14-
~^(GET|HEAD)::/favicon.ico$ "revalidate";
15-
~^(GET|HEAD)::/robots.txt$ "revalidate";
16-
~^(GET|HEAD)::/version.txt$ "revalidate";
17+
~^(GET|HEAD)::/client-config\.json$ "revalidate";
18+
~^(GET|HEAD)::/robots\.txt$ "revalidate";
19+
~^(GET|HEAD)::/version\.txt$ "revalidate";
1720

1821
# central-backend
1922
~^(GET|HEAD)::/v1/ "passthrough";
2023

2124
# central-frontend - unversioned
22-
~^(GET|HEAD)::/$ "revalidate";
23-
~^(GET|HEAD)::/index.html$ "revalidate";
24-
~^(GET|HEAD)::/blank.html$ "revalidate";
25-
~^(GET|HEAD)::/fonts/.*\?\w+ "immutable";
26-
~^(GET|HEAD)::/fonts/ "revalidate";
25+
~^(GET|HEAD)::/$ "revalidate";
26+
~^(GET|HEAD)::/index\.html$ "revalidate";
27+
~^(GET|HEAD)::/android-chrome-192x192\.png$ "revalidate";
28+
~^(GET|HEAD)::/android-chrome-512x512\.png$ "revalidate";
29+
~^(GET|HEAD)::/apple-touch-icon\.png$ "revalidate";
30+
~^(GET|HEAD)::/blank\.html$ "revalidate";
31+
~^(GET|HEAD)::/favicon\.ico$ "revalidate";
32+
~^(GET|HEAD)::/favicon-16x16\.png$ "revalidate";
33+
~^(GET|HEAD)::/favicon-32x32\.png$ "revalidate";
34+
~^(GET|HEAD)::/fonts/.*\?\w+ "immutable";
35+
~^(GET|HEAD)::/fonts/ "revalidate";
36+
~^(GET|HEAD)::/site\.webmanifest$ "revalidate";
2737

2838
# central-frontend - versioned
2939
~^(GET|HEAD)::/assets/ "immutable";
@@ -81,10 +91,10 @@ map $arg_st $redirect_single_prefix {
8191
map $request_uri $central_frontend_csp {
8292
# Web Forms CSP for /f/... and /projects/.../forms/... routes
8393
~^/(?:f/[^/]+(?:/.*)?|projects/\d+/forms/[^/]+/(?:(?:draft/)?(?:preview|submissions/new(?:/offline)?)|submissions/[^/]+/edit)(?:/)?)(?:\?.*)?$
84-
"default-src 'report-sample' 'none'; connect-src 'self' https:; font-src 'self' data:; frame-src 'self'; img-src blob: data: https:; manifest-src 'none'; media-src 'none'; 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";
94+
"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";
8595

8696
default
87-
"default-src 'report-sample' '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 'report-sample' 'self'; style-src 'report-sample' 'self'; style-src-attr 'unsafe-inline'; worker-src 'report-sample' blob:; report-uri /csp-report";
97+
"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";
8898
}
8999

90100
server {
@@ -160,7 +170,7 @@ server {
160170
# Google Maps API: https://developers.google.com/maps/documentation/javascript/content-security-policy
161171
# Use 'none' per directive instead of falling back to default-src to make CSP violation reports more specific
162172
proxy_hide_header Content-Security-Policy-Report-Only;
163-
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/; 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;
173+
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;
164174

165175
include /usr/share/odk/nginx/common-headers.conf;
166176
}
@@ -173,7 +183,7 @@ server {
173183

174184
location ~ ^/v\d {
175185
proxy_hide_header Content-Security-Policy-Report-Only;
176-
add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; report-uri /csp-report" always;
186+
add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; form-action 'none'; frame-ancestors 'none'; report-uri /csp-report" always;
177187

178188
include /usr/share/odk/nginx/common-headers.conf;
179189
include /usr/share/odk/nginx/backend.conf;
@@ -183,7 +193,7 @@ server {
183193
root /usr/share/nginx/html;
184194
try_files /blank.html =404;
185195

186-
add_header Content-Security-Policy-Report-Only "default-src 'report-sample' 'none'; connect-src https://translate.google.com https://translate.googleapis.com; img-src 'self' https://translate.google.com; report-uri /csp-report" always;
196+
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;
187197
include /usr/share/odk/nginx/common-headers.conf;
188198
}
189199
location = /blank.html {

files/prebuild/build-frontend.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ if [[ ${SKIP_FRONTEND_BUILD-} != "" ]]; then
1010
# Create minimal fake frontend to allow tests to pass:
1111
mkdir dist dist/assets dist/fonts
1212
echo > dist/blank.html
13-
echo > dist/favicon.ico
1413
echo > dist/index.html '<div id="app"></div>'
14+
echo > dist/android-chrome-192x192.png
15+
echo > dist/android-chrome-512x512.png
16+
echo > dist/apple-touch-icon.png
17+
echo > dist/favicon-16x16.png
18+
echo > dist/favicon-32x32.png
19+
echo > dist/favicon.ico
20+
echo > dist/site.webmanifest
1521

1622
echo > dist/assets/actor-link-CHKNLRJ6.js
1723
echo > dist/assets/branch-data-NQSuaxke.js

files/service/with-pgenvblock.pl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@
2121
}
2222
}
2323

24-
exec @ARGV[1..$#ARGV];
24+
exec @ARGV[1..$#ARGV]
25+
or die;

test/.npmrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ignore-scripts=true
2+
min-release-age=14

test/nginx/mock-http-server/.npmrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ignore-scripts=true
2+
min-release-age=14

test/nginx/mock-http-server/index.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ app.use((req, res, next) => {
1111
console.log(new Date(), req.method, req.originalUrl);
1212

1313
// always set CSP header to detect (or allow) leaks from backend through to the client
14-
res.set('Content-Security-Policy', 'default-src NOTE:FROM-BACKEND:block');
15-
res.set('Content-Security-Policy-Report-Only', 'default-src NOTE:FROM-BACKEND:reportOnly');
14+
const topLevelDirectives = [
15+
'default-src',
16+
'form-action',
17+
'frame-ancestors',
18+
];
19+
res.set('Content-Security-Policy', topLevelDirectives.map(d => `${d} NOTE:FROM-BACKEND:block`) .join('; '));
20+
res.set('Content-Security-Policy-Report-Only', topLevelDirectives.map(d => `${d} NOTE:FROM-BACKEND:reportOnly`).join('; '));
1621

1722
next();
1823
});

test/nginx/mock-sentry/.npmrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ignore-scripts=true
2+
min-release-age=14

test/nginx/src/mocha/nginx.spec.js

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ const self = `'self'`;
1414
const unsafeInline = `'unsafe-inline'`;
1515
const wasmUnsafeEval = `'wasm-unsafe-eval'`;
1616

17+
// Central has notifications defined in https://github.com/getodk/central/tree/master/docs, and served from GitHub Pages. These include:
18+
//
19+
// * https://getodk.github.io/central/news.html
20+
// * https://getodk.github.io/central/outdated-version.html
21+
const centralNotifications = 'https://getodk.github.io/central/';
22+
1723
const asArray = val => {
1824
if (val == null) return [];
1925
if (Array.isArray(val)) return val;
@@ -37,10 +43,14 @@ const allowGoogleTranslate = ({ 'connect-src':connectSrc, 'img-src':imgSrc, ...o
3743
const contentSecurityPolicies = {
3844
'backend-unmodified': {
3945
block: {
40-
'default-src': 'NOTE:FROM-BACKEND:block',
46+
'default-src': 'NOTE:FROM-BACKEND:block',
47+
'form-action': 'NOTE:FROM-BACKEND:block',
48+
'frame-ancestors': 'NOTE:FROM-BACKEND:block',
4149
},
4250
reportOnly: {
43-
'default-src': 'NOTE:FROM-BACKEND:reportOnly',
51+
'default-src': 'NOTE:FROM-BACKEND:reportOnly',
52+
'form-action': 'NOTE:FROM-BACKEND:reportOnly',
53+
'frame-ancestors': 'NOTE:FROM-BACKEND:reportOnly',
4454
},
4555
},
4656
'blank-html': {
@@ -49,6 +59,8 @@ const contentSecurityPolicies = {
4959
reportSample,
5060
none,
5161
],
62+
'form-action': none,
63+
'frame-ancestors': self,
5264
'img-src': self, // allow favicon.ico
5365
'report-uri': '/csp-report',
5466
}),
@@ -63,15 +75,17 @@ const contentSecurityPolicies = {
6375
self,
6476
],
6577
'font-src': self,
78+
'form-action': self,
79+
'frame-ancestors': none,
6680
'frame-src': [
6781
self,
68-
'https://getodk.github.io/central/news.html',
82+
centralNotifications,
6983
],
7084
'img-src': [
7185
'data:',
7286
'https:',
7387
],
74-
'manifest-src': none,
88+
'manifest-src': self,
7589
'media-src': none,
7690
'object-src': none,
7791
'script-src': [
@@ -92,19 +106,25 @@ const contentSecurityPolicies = {
92106
},
93107
'disallow-all': {
94108
block: {
95-
'default-src': 'NOTE:FROM-BACKEND:block',
109+
'default-src': 'NOTE:FROM-BACKEND:block',
110+
'form-action': 'NOTE:FROM-BACKEND:block',
111+
'frame-ancestors': 'NOTE:FROM-BACKEND:block',
96112
},
97113
reportOnly: {
98114
'default-src': [
99115
reportSample,
100116
none,
101117
],
118+
'form-action': none,
119+
'frame-ancestors': none,
102120
'report-uri': '/csp-report',
103121
},
104122
},
105123
enketo: {
106124
block: {
107-
'default-src': 'NOTE:FROM-BACKEND:block',
125+
'default-src': 'NOTE:FROM-BACKEND:block',
126+
'form-action': 'NOTE:FROM-BACKEND:block',
127+
'frame-ancestors': 'NOTE:FROM-BACKEND:block',
108128
},
109129
reportOnly: allowGoogleTranslate({
110130
'default-src': [
@@ -124,6 +144,8 @@ const contentSecurityPolicies = {
124144
self,
125145
'https://fonts.gstatic.com/',
126146
],
147+
'form-action': self,
148+
'frame-ancestors': self,
127149
'frame-src': none,
128150
'img-src': [
129151
'data:',
@@ -173,14 +195,21 @@ const contentSecurityPolicies = {
173195
self,
174196
'data:',
175197
],
176-
'frame-src': self, // web-forms pages also host /enketo-passthrough/ URLs via iframes
198+
'form-action': self,
199+
'frame-ancestors': self,
200+
'frame-src': [
201+
self, // web-forms pages also host /enketo-passthrough/ URLs via iframes
202+
centralNotifications,
203+
],
177204
'img-src': [
178205
'blob:',
179206
'data:',
180207
'https:',
181208
],
182-
'manifest-src': none,
183-
'media-src': none,
209+
'manifest-src': self,
210+
'media-src': [
211+
'blob:',
212+
],
184213
'object-src': none,
185214
'script-src': [
186215
reportSample,
@@ -201,6 +230,12 @@ const contentSecurityPolicies = {
201230
};
202231

203232
describe('Content-Security-Policy definitions', () => {
233+
const requiredDirectives = [
234+
'default-src',
235+
'form-action',
236+
'frame-ancestors',
237+
];
238+
204239
const supportsReportSample = [
205240
'default-src',
206241
'require-trusted-types-for',
@@ -224,7 +259,15 @@ describe('Content-Security-Policy definitions', () => {
224259
const policy = policies[headerType];
225260
if(!policy) continue;
226261

262+
it(`should have required directives: ${requiredDirectives}`, () => {
263+
assert.containsAllKeys(policy, requiredDirectives);
264+
});
265+
227266
describe(`header: ${headerNames[headerType]}`, () => {
267+
it(`should have required directives: ${requiredDirectives}`, () => {
268+
assert.containsAllKeys(policy, requiredDirectives);
269+
});
270+
228271
Object.entries(policy)
229272
.map (([ key, directive ]) => [ key, asArray(directive) ])
230273
.filter (([ key, directive ]) => !(directive.length === 1 && directive[0] === `NOTE:FROM-BACKEND:${headerType}`)) // eslint-disable-line no-unused-vars
@@ -441,17 +484,24 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward
441484
});
442485

443486
[
444-
[ '/index.html', /<div id="app"><\/div>/ ],
445-
[ '/version.txt', /^versions:/ ],
446-
[ '/favicon.ico', /^\n$/ ],
447-
].forEach(([ path, expectedContent ]) => {
487+
[ '/index.html', 'text/html', /<div id="app"><\/div>/ ],
488+
[ '/version.txt', 'text/plain', /^versions:/ ],
489+
[ '/android-chrome-192x192.png', 'image/png', /^\n$/ ],
490+
[ '/android-chrome-512x512.png', 'image/png', /^\n$/ ],
491+
[ '/apple-touch-icon.png', 'image/png', /^\n$/ ],
492+
[ '/favicon.ico', 'image/x-icon', /^\n$/ ],
493+
[ '/favicon-16x16.png', 'image/png', /^\n$/ ],
494+
[ '/favicon-32x32.png', 'image/png', /^\n$/ ],
495+
[ '/site.webmanifest', 'application/manifest+json', /^\n$/ ],
496+
].forEach(([ path, expectedContentType, expectedContent ]) => {
448497
it(`${path} file should serve expected content`, async () => {
449498
// when
450499
const res = await apiFetch(path);
451500

452501
// then
453502
assert.equal(res.status, 200);
454503
assert.match(await res.text(), expectedContent);
504+
assert.equal(res.headers.get('Content-Type'), expectedContentType);
455505
assertSecurityHeaders(res, { csp:'central-frontend' });
456506
});
457507
});

0 commit comments

Comments
 (0)