Skip to content

Commit 373fd05

Browse files
authored
/oidc/callback: set CSP (#1679)
Generate the proper content-hash for the page's inline `<style>` element. Ref getodk/central#1235 Ref getodk/central#1478
1 parent b70fccf commit 373fd05

File tree

3 files changed

+65
-25
lines changed

3 files changed

+65
-25
lines changed

lib/resources/oidc.js

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
// OpenID Connect auth handling using Authorization Code Flow with PKCE.
1414
// TODO document _why_ auth-code-flow, and not e.g. implicit flow?
1515

16+
const { createHash } = require('node:crypto');
17+
1618
const { generators } = require('openid-client');
1719
const config = require('config');
1820
const { parse, render } = require('mustache');
@@ -51,38 +53,42 @@ const callbackCookieProps = {
5153
path: '/v1/oidc/callback',
5254
};
5355

56+
const style = `
57+
#container { text-align:center }
58+
59+
.lds-spinner { display:inline-block; position:relative; width:80px; height:80px }
60+
.lds-spinner div { transform-origin:40px 40px; animation:lds-spinner 1.2s linear infinite }
61+
.lds-spinner div:after {
62+
content:" "; display:block; position:absolute; top:3px; left:37px;
63+
width:6px; height:18px; border-radius:20%; background:#000;
64+
}
65+
.lds-spinner div:nth-child(1) { transform:rotate(0deg); animation-delay:-1.1s }
66+
.lds-spinner div:nth-child(2) { transform:rotate(30deg); animation-delay:-1s }
67+
.lds-spinner div:nth-child(3) { transform:rotate(60deg); animation-delay:-0.9s }
68+
.lds-spinner div:nth-child(4) { transform:rotate(90deg); animation-delay:-0.8s }
69+
.lds-spinner div:nth-child(5) { transform:rotate(120deg); animation-delay:-0.7s }
70+
.lds-spinner div:nth-child(6) { transform:rotate(150deg); animation-delay:-0.6s }
71+
.lds-spinner div:nth-child(7) { transform:rotate(180deg); animation-delay:-0.5s }
72+
.lds-spinner div:nth-child(8) { transform:rotate(210deg); animation-delay:-0.4s }
73+
.lds-spinner div:nth-child(9) { transform:rotate(240deg); animation-delay:-0.3s }
74+
.lds-spinner div:nth-child(10) { transform:rotate(270deg); animation-delay:-0.2s }
75+
.lds-spinner div:nth-child(11) { transform:rotate(300deg); animation-delay:-0.1s }
76+
.lds-spinner div:nth-child(12) { transform:rotate(330deg); animation-delay:0s }
77+
@keyframes lds-spinner { 0% { opacity:1 } 100% { opacity:0 } }
78+
79+
.continue-message { opacity:0; animation: 1s ease-in 10s 1 normal forwards fade-in; margin-top:1em }
80+
@keyframes fade-in { from { opacity:0 } to { opacity:1 } }
81+
`;
82+
const styleHash = createHash('sha256').update(style).digest('base64');
83+
5484
// id=cl only set for playwright. Why can't it locate this anchor in any other way?
5585
const loaderTemplate = `
5686
<!doctype html>
5787
<html>
5888
<head>
5989
<title>Loading... | ODK Central</title>
6090
<meta http-equiv="refresh" content="0; url={{nextPath}}">
61-
<style>
62-
#container { text-align:center }
63-
64-
.lds-spinner { display:inline-block; position:relative; width:80px; height:80px }
65-
.lds-spinner div { transform-origin:40px 40px; animation:lds-spinner 1.2s linear infinite }
66-
.lds-spinner div:after {
67-
content:" "; display:block; position:absolute; top:3px; left:37px;
68-
width:6px; height:18px; border-radius:20%; background:#000;
69-
}
70-
.lds-spinner div:nth-child(1) { transform:rotate(0deg); animation-delay:-1.1s }
71-
.lds-spinner div:nth-child(2) { transform:rotate(30deg); animation-delay:-1s }
72-
.lds-spinner div:nth-child(3) { transform:rotate(60deg); animation-delay:-0.9s }
73-
.lds-spinner div:nth-child(4) { transform:rotate(90deg); animation-delay:-0.8s }
74-
.lds-spinner div:nth-child(5) { transform:rotate(120deg); animation-delay:-0.7s }
75-
.lds-spinner div:nth-child(6) { transform:rotate(150deg); animation-delay:-0.6s }
76-
.lds-spinner div:nth-child(7) { transform:rotate(180deg); animation-delay:-0.5s }
77-
.lds-spinner div:nth-child(8) { transform:rotate(210deg); animation-delay:-0.4s }
78-
.lds-spinner div:nth-child(9) { transform:rotate(240deg); animation-delay:-0.3s }
79-
.lds-spinner div:nth-child(10) { transform:rotate(270deg); animation-delay:-0.2s }
80-
.lds-spinner div:nth-child(11) { transform:rotate(300deg); animation-delay:-0.1s }
81-
.lds-spinner div:nth-child(12) { transform:rotate(330deg); animation-delay:0s }
82-
@keyframes lds-spinner { 0% { opacity:1 } 100% { opacity:0 } }
83-
84-
.continue-message { opacity:0; animation: 1s ease-in 10s 1 normal forwards fade-in; margin-top:1em }
85-
@keyframes fade-in { from { opacity:0 } to { opacity:1 } }
91+
<style>${style}</style>
8692
</style>
8793
</head>
8894
<body>
@@ -170,6 +176,7 @@ module.exports = (service, __, anonymousEndpoint) => {
170176
// return redirect(303, nextPath);
171177
// Instead, we need to render a page and then "browse" from that page to the normal frontend:
172178

179+
res.set('Content-Security-Policy', `default-src 'none'; img-src 'self'; style-src-elem 'sha256-${styleHash}'; report-uri /csp-report`);
173180
return render(loaderTemplate, { nextPath });
174181
} catch (err) {
175182
if (redirect.isRedirect(err)) {

test/e2e/oidc/playwright-tests/src/oidc-login.spec.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
// including this file, may be copied, modified, propagated, or distributed
88
// except according to the terms contained in the LICENSE file.
99

10+
const assert = require('node:assert');
11+
1012
const { test } = require('@playwright/test');
1113

1214
const { frontendUrl } = require('./config');
@@ -16,6 +18,7 @@ const { // eslint-disable-line object-curly-newline
1618
assertLoginSuccessful,
1719
fillLoginForm,
1820
initTest,
21+
sleep,
1922
} = require('./utils'); // eslint-disable-line object-curly-newline
2023

2124
const password = 'topsecret123'; // fake-oidc-server will accept any non-empty password
@@ -43,6 +46,35 @@ test.describe('happy', () => {
4346
});
4447
});
4548

49+
test('/callback should not trigger Content-Security-Policy', async ({ browserName, page }, testInfo) => {
50+
// given
51+
await initTest({ browserName, page }, testInfo);
52+
// and
53+
const cspViolations = [];
54+
page.on('console', msg => {
55+
if (msg.type() === 'error') {
56+
const messageText = msg.text();
57+
58+
// This feels weak, but currently catches CSP violations on all of firefox, chromium & webkit.
59+
if (messageText.match(/Content.Security.Policy/)) cspViolations.push(messageText);
60+
}
61+
});
62+
63+
// when
64+
await page.goto(`${frontendUrl}/v1/oidc/login`);
65+
await fillLoginForm(page, { username: 'alice', password });
66+
// then
67+
await assertLoginSuccessful(page, '/');
68+
await assertLocation(page, frontendUrl + '/');
69+
70+
// when
71+
// Race condition: console events seem to be asynchronous. Not documented, but
72+
// see: https://playwright.dev/docs/api/class-page#page-event-console
73+
await sleep(1);
74+
// then
75+
assert.deepEqual(cspViolations, []);
76+
});
77+
4678
test.describe('redirected errors', () => {
4779
[
4880
[ 'user unknown by central', 'bob', 'auth-ok-user-not-found' ], // eslint-disable-line no-multi-spaces

test/e2e/oidc/playwright-tests/src/utils.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = {
1616
assertTitle,
1717
fillLoginForm,
1818
initTest,
19+
sleep,
1920
};
2021

2122
const assert = require('node:assert');

0 commit comments

Comments
 (0)