Skip to content

Commit 6c27f10

Browse files
committed
feat: check session client Origin check
To satisfy a normative requirement the Origin of a valid formatted postMessage must be checked to be a valid/expected one. The iframe will now do this check with an XMLHttpRequest to the provider with the client_id and Origin and will check the Origin to be amongst the Origins of a given client's registered redirect_uris. This is enough since the session state computation uses the redirect_uri Origin. If the client is not found, request is malformed or Origin is not whitelisted the frame will respond with "error" status back. Additionally: more format checks are in place to filter out invalid messages from i.e. trackers. Additionally: the check_session_iframe debug was removed as it was not sufficient for debugging anyway.
1 parent 5e4dbe3 commit 6c27f10

File tree

8 files changed

+282
-107
lines changed

8 files changed

+282
-107
lines changed

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,7 @@ provider.use(async (ctx, next) => {
849849
* `userinfo`
850850
* `webfinger`
851851
* `check_session`
852+
* `check_session_origin`
852853
* `client`
853854
* `discovery`
854855
*

docs/events.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ parameters, loaded client or session.
2727
| registration_delete.error | (error, ctx) | ... whenever a handled error is encountered in the DELETE `registration` endpoint. |
2828
| userinfo.error | (error, ctx) | ... whenever a handled error is encountered in the `userinfo` endpoint. |
2929
| check_session.error | (error, ctx) | ... whenever a handled error is encountered in the `check_session` endpoint. |
30+
| check_session_origin.error | (error, ctx) | ... whenever a handled error is encountered in the `check_session_origin` endpoint. |
3031
| end_session.success | (ctx) | ... with every success end session request. |
3132
| end_session.error | (error, ctx) | ... whenever a handled error is encountered in the `end_session` endpoint. |
3233
| webfinger.error | (error, ctx) | ... whenever a handled error is encountered in the `webfinger` endpoint. |

lib/actions/check_session.js

Lines changed: 143 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,171 @@
11
const instance = require('../helpers/weak_cache');
2+
const bodyParser = require('../shared/selective_body');
3+
const noCache = require('../shared/no_cache');
4+
const getParams = require('../shared/assemble_params');
5+
const presence = require('../helpers/validate_presence');
6+
const { InvalidRequest, InvalidClient } = require('../helpers/errors');
7+
8+
const PARAM_LIST = new Set(['client_id', 'origin']);
9+
const buildParams = getParams(PARAM_LIST);
210

311
module.exports = function checkSessionAction(provider) {
412
const removeHeaders = !instance(provider).configuration('features.sessionManagement.keepHeaders');
513
const thirdPartyCheckUrl = instance(provider).configuration('cookies.thirdPartyCheckUrl');
614

7-
return async function checkSessionIframe(ctx, next) {
8-
const debug = ctx.query.debug !== undefined;
9-
10-
if (removeHeaders) {
11-
ctx.response.remove('X-Frame-Options');
12-
const csp = ctx.response.get('Content-Security-Policy');
13-
if (csp.includes('frame-ancestors')) {
14-
ctx.response.set('Content-Security-Policy', csp.replace(/ ?frame-ancestors [^;]+;/, ''));
15+
return {
16+
get: async function checkSessionIframe(ctx, next) {
17+
if (removeHeaders) {
18+
ctx.response.remove('X-Frame-Options');
19+
const csp = ctx.response.get('Content-Security-Policy');
20+
if (csp.includes('frame-ancestors')) {
21+
ctx.response.set('Content-Security-Policy', csp.replace(/ ?frame-ancestors [^;]+;/, ''));
22+
}
1523
}
16-
}
1724

18-
ctx.type = 'html';
19-
ctx.body = `<!DOCTYPE html>
20-
<html>
21-
<head lang="en">
22-
<meta charset="UTF-8">
23-
<title>Session Management - OP iframe</title>
24-
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.3.1/sha256.js" integrity="sha256-NyuvLfsvfCfE+ceV6/W19H+qVp3M8c9FzAgj72CW39w=" crossorigin="anonymous"></script>
25-
</head>
26-
<body>
25+
ctx.type = 'html';
26+
ctx.body = `<!DOCTYPE html>
27+
<html>
28+
<head lang="en">
29+
<meta charset="UTF-8">
30+
<title>Session Management - OP iframe</title>
31+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.3.1/sha256.js" integrity="sha256-NyuvLfsvfCfE+ceV6/W19H+qVp3M8c9FzAgj72CW39w=" crossorigin="anonymous"></script>
32+
<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=fetch&rum=0"></script>
33+
</head>
34+
<body>
2735
28-
<script type="application/javascript">
29-
var debug = ${debug};
30-
var thirdPartyCookies = true;
36+
<script type="application/javascript">
37+
(function () {
38+
var thirdPartyCookies = true;
39+
var originCheckResult;
3140
32-
function receiveMessage(e) {
33-
if (e.data === 'MM:3PCunsupported') {
34-
thirdPartyCookies = false;
35-
return;
36-
} else if (e.data === 'MM:3PCsupported') {
37-
return;
41+
function calculate(clientId, origin, actual, salt, cb) {
42+
try {
43+
if (originCheckResult.clientId !== clientId || originCheckResult.origin !== origin) {
44+
throw new Error('client_id and/or origin mismatch');
3845
}
39-
try {
40-
var message_parts = e.data.split(' ');
41-
var clientId = message_parts[0];
42-
var actual = message_parts[1];
43-
if (debug && console) console.log('OP recv session state: ' + actual);
44-
var salt = actual.split('.')[1];
46+
var opbs = getOPBrowserState(clientId);
47+
var stat = 'changed';
4548
46-
var opbs = getOPBrowserState(clientId);
49+
if (opbs) {
4750
var shaObj = new jsSHA('SHA-256', 'TEXT');
48-
shaObj.update(clientId + ' ' + e.origin + ' ' + opbs + ' ' + salt);
49-
var expected = shaObj.getHash('HEX') + ['.' + salt];
50-
if (debug && console) console.log('OP computed session state: ' + expected);
51+
shaObj.update(clientId + ' ' + origin + ' ' + opbs + ' ' + salt);
52+
var expected = shaObj.getHash('HEX') + '.' + salt;
5153
52-
var stat;
5354
if (actual === expected) {
5455
stat = 'unchanged';
55-
} else {
56-
stat = 'changed';
5756
}
57+
}
5858
59-
if (debug && console) console.log('OP status: ' + stat);
59+
cb(stat);
60+
} catch (err) {
61+
cb('error');
62+
}
63+
}
6064
61-
e.source.postMessage(stat, e.origin);
62-
} catch (err) {
63-
e.source.postMessage('error', e.origin);
64-
}
65+
function check(clientId, origin, actual, salt, cb) {
66+
if (!originCheckResult) {
67+
fetch(location.pathname, {
68+
method: 'POST',
69+
headers: {
70+
'Content-Type': 'application/json; charset=utf-8',
71+
},
72+
body: JSON.stringify({ client_id: clientId, origin: origin }),
73+
redirect: 'error',
74+
}).then(function (response) {
75+
if (response.ok) {
76+
originCheckResult = {
77+
origin: origin,
78+
clientId: clientId,
79+
};
80+
calculate(clientId, origin, actual, salt, cb);
81+
} else {
82+
throw new Error('invalid client_id and/or origin');
83+
}
84+
}).catch(function () {
85+
cb('error');
86+
});
87+
} else {
88+
calculate(clientId, origin, actual, salt, cb);
6589
}
90+
}
6691
67-
function getOPBrowserState(clientId) {
68-
var cookie = readCookie('${provider.cookieName('state')}.' + clientId);
69-
if (debug && console) console.log('session state cookie: ' + cookie);
70-
if (!thirdPartyCookies && !cookie) throw new Error('third party cookies are most likely blocked');
71-
return cookie;
92+
function receiveMessage(e) {
93+
if (typeof e.data !== 'string') {
94+
return;
95+
}
96+
if (e.data === 'MM:3PCunsupported') {
97+
thirdPartyCookies = false;
98+
return;
99+
}
100+
if (e.data === 'MM:3PCsupported') {
101+
return;
102+
}
103+
var parts = e.data.split(' ');
104+
var clientId = parts[0];
105+
var actual = parts[1];
106+
if (parts.length !== 2 || !clientId || !actual) {
107+
return;
108+
}
109+
var actualParts = actual.split('.');
110+
var sessionStr = actualParts[0];
111+
var salt = actualParts[1];
112+
if (actualParts.length !== 2 || !salt || !sessionStr) {
113+
return;
72114
}
115+
check(clientId, e.origin, actual, salt, function (stat) {
116+
e.source.postMessage(stat, e.origin);
117+
});
118+
}
73119
74-
function readCookie(name) {
75-
var nameEQ = name + "=";
76-
var ca = document.cookie.split(';');
77-
for(var i=0;i < ca.length;i++) {
78-
var c = ca[i];
79-
while (c.charAt(0)==' ') c = c.substring(1,c.length);
80-
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
81-
}
82-
return null;
120+
function getOPBrowserState(clientId) {
121+
var cookie = readCookie('${provider.cookieName('state')}.' + clientId);
122+
if (!thirdPartyCookies && !cookie) throw new Error('third party cookies are most likely blocked');
123+
return cookie;
124+
}
125+
126+
function readCookie(name) {
127+
var nameEQ = name + '=';
128+
var ca = document.cookie.split(';');
129+
for (var i=0; i < ca.length; i++) {
130+
var c = ca[i];
131+
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
132+
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
83133
}
134+
return null;
135+
}
84136
85-
window.addEventListener('message', receiveMessage, false);
86-
</script>
87-
<iframe src="${thirdPartyCheckUrl}" style="display:none" />
137+
window.addEventListener('message', receiveMessage, false);
138+
})();
139+
</script>
140+
<iframe src="${thirdPartyCheckUrl}" style="display:none" />
88141
89-
</body>
90-
</html>`;
91-
await next();
142+
</body>
143+
</html>`;
144+
await next();
145+
},
146+
post: [
147+
noCache,
148+
bodyParser('application/json'),
149+
buildParams,
150+
async function checkClientOrigin(ctx, next) {
151+
presence(ctx, 'origin', 'client_id');
152+
const { client_id: clientId, origin } = ctx.oidc.params;
153+
[clientId, origin].forEach((value) => {
154+
if (typeof value !== 'string') {
155+
throw new InvalidRequest('only string parameter values are expected');
156+
}
157+
});
158+
const client = await provider.Client.find(clientId);
159+
ctx.oidc.entity('Client', client);
160+
if (!client) {
161+
throw new InvalidClient();
162+
}
163+
if (!client.redirectUriOrigins.has(origin)) {
164+
throw new InvalidRequest('origin not allowed', 403);
165+
}
166+
ctx.status = 204;
167+
await next();
168+
},
169+
],
92170
};
93171
};

lib/actions/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const getWebfinger = require('./webfinger');
99
const getDiscovery = require('./discovery');
1010
const getCheckSession = require('./check_session');
1111
const getEndSession = require('./end_session');
12+
const getCodeVerification = require('./code_verification');
1213

1314
module.exports = {
1415
getAuthorization,
@@ -22,4 +23,5 @@ module.exports = {
2223
getDiscovery,
2324
getCheckSession,
2425
getEndSession,
26+
getCodeVerification,
2527
};

lib/helpers/initialize_app.js

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,12 @@ const Router = require('koa-router');
44
const getCors = require('@koa/cors');
55

66
const { homepage, version } = require('../../package.json');
7-
const getAuthorization = require('../actions/authorization');
8-
const getUserinfo = require('../actions/userinfo');
9-
const getToken = require('../actions/token');
10-
const getCertificates = require('../actions/certificates');
11-
const getRegistration = require('../actions/registration');
12-
const getRevocation = require('../actions/revocation');
13-
const getIntrospection = require('../actions/introspection');
14-
const getWebfinger = require('../actions/webfinger');
15-
const getDiscovery = require('../actions/discovery');
16-
const getCheckSession = require('../actions/check_session');
17-
const getEndSession = require('../actions/end_session');
7+
const {
8+
getAuthorization, getUserinfo, getToken, getCertificates, getRegistration, getRevocation,
9+
getIntrospection, getWebfinger, getDiscovery, getCheckSession, getEndSession, getCodeVerification,
10+
} = require('../actions');
1811
const getInteraction = require('../actions/interaction');
1912
const grants = require('../actions/grants');
20-
const getCodeVerification = require('../actions/code_verification');
2113
const responseModes = require('../response_modes');
2214
const error = require('../shared/error_handler');
2315
const getAuthError = require('../shared/authorization_error_handler');
@@ -150,8 +142,9 @@ module.exports = function initializeApp() {
150142
}
151143

152144
if (configuration.features.sessionManagement) {
153-
const checkFrame = getCheckSession(this);
154-
get('check_session', routes.check_session, error(this, 'check_session.error'), checkFrame);
145+
const checkSession = getCheckSession(this);
146+
get('check_session', routes.check_session, error(this, 'check_session.error'), checkSession.get);
147+
post('check_session_origin', routes.check_session, error(this, 'check_session_origin.error'), ...checkSession.post);
155148

156149
const endSession = getEndSession(this);
157150
get('end_session', routes.end_session, error(this, 'end_session.error'), ...endSession.get);

lib/models/client.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,20 @@ module.exports = function getClient(provider) {
313313
return this.redirectUris.includes(checkedUri);
314314
}
315315

316+
get redirectUriOrigins() {
317+
if ('redirectUriOrigins' in instance(this)) {
318+
return instance(this).redirectUriOrigins;
319+
}
320+
321+
instance(this).redirectUriOrigins = this.redirectUris.reduce((acc, uri) => {
322+
const { origin } = new URL(uri);
323+
acc.add(origin);
324+
return acc;
325+
}, new Set());
326+
327+
return instance(this).redirectUriOrigins;
328+
}
329+
316330
webMessageUriAllowed(webMessageUri) {
317331
return this.webMessageUris && this.webMessageUris.includes(webMessageUri);
318332
}

test/session_management/authorization.test.js

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,33 +37,5 @@ describe('session management', () => {
3737
});
3838
});
3939
});
40-
41-
describe('[session_management] check_session_iframe', () => {
42-
before(function () {
43-
this.provider.use(async (ctx, next) => {
44-
ctx.response.set('X-Frame-Options', 'SAMEORIGIN');
45-
ctx.response.set('Content-Security-Policy', "default-src 'none'; frame-ancestors 'self' example.com *.example.net; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self';");
46-
await next();
47-
});
48-
});
49-
50-
it('responds with frameable html', function () {
51-
return this.agent.get('/session/check')
52-
.expect(200)
53-
.expect('content-type', /text\/html/)
54-
.expect((response) => {
55-
expect(response.headers['x-frame-options']).not.to.be.ok;
56-
expect(response.headers['content-security-policy']).not.to.match(/frame-ancestors/);
57-
});
58-
});
59-
60-
it('does not populate ctx.oidc.entities', function (done) {
61-
this.provider.use(this.assertOnce((ctx) => {
62-
expect(ctx.oidc.entities).to.be.empty;
63-
}, done));
64-
65-
this.agent.get('/session/check').end(() => {});
66-
});
67-
});
6840
});
6941
});

0 commit comments

Comments
 (0)