CVE-2024-34065 – @strapi/plugin-users-permissions
Package
Manager: npm
Name: @strapi/plugin-users-permissions
Vulnerable Version: >=0 <4.24.2
Severity
Level: High
CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N
CVSS v4.0: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N
EPSS: 0.00766 pctl0.72557
Details
@strapi/plugin-users-permissions leaks 3rd party authentication tokens and authentication bypass ### Summary By combining two vulnerabilities (an `Open Redirect` and `session token sent as URL query parameter`) in Strapi framework is its possible of an unauthenticated attacker to bypass authentication mechanisms and retrieve the 3rd party tokens. The attack requires user interaction (one click). ### Impact Unauthenticated attackers can leverage two vulnerabilities to obtain an 3rd party token and the bypass authentication of Strapi apps. ### Technical details #### Vulnerability 1: Open Redirect ##### Description Open redirection vulnerabilities arise when an application incorporates user-controllable data into the target of a redirection in an unsafe way. An attacker can construct a URL within the application that causes a redirection to an arbitrary external domain. In the specific context of Strapi, this vulnerability allows the SSO token to be stolen, allowing an attacker to authenticate himself within the application. ##### Remediation If possible, applications should avoid incorporating user-controllable data into redirection targets. In many cases, this behavior can be avoided in two ways: - Remove the redirection function from the application, and replace links to it with direct links to the relevant target URLs. - Maintain a server-side list of all URLs that are permitted for redirection. Instead of passing the target URL as a parameter to the redirector, pass an index into this list. If it is considered unavoidable for the redirection function to receive user-controllable input and incorporate this into the redirection target, one of the following measures should be used to minimize the risk of redirection attacks: - The application should use relative URLs in all of its redirects, and the redirection function should strictly validate that the URL received is a relative URL. - The application should use URLs relative to the web root for all of its redirects, and the redirection function should validate that the URL received starts with a slash character. It should then prepend <span dir="">http://yourdomainname.com</span> to the URL before issuing the redirect. ###### Example 1: Open Redirect in <span dir="">/api/connect/microsoft</span> via `$_GET["callback"]` - Path: <span dir="">/api/connect/microsoft</span> - Parameter: `$_GET["callback"]` Payload: ```plaintext https://google.fr/ ``` Final payload: ```plaintext https://<TARGET>/api/connect/microsoft?callback=https://google.fr/ ``` User clicks on the link:  Look at the intercepted request in Burp and see the redirect to Microsoft:  Microsoft check the cookies and redirects to the original domain (and route) but with different GET parameters. Then, the page redirects to the domain controlled by the attacker (and a token is added to controlled the URL):  The domain originally specified (https://google.fr) as `$_GET["callback"]` parameter is present in the cookies. So <span dir="">\<TARGET\></span> is using the cookies (`koa.sess`) to redirect.  `koa.sess` cookie: ```base64 eyJncmFudCI6eyJwcm92aWRlciI6Im1pY3Jvc29mdCIsImR5bmFtaWMiOnsiY2FsbGJhY2siOiJodHRwczovL2dvb2dsZS5mci8ifX0sIl9leHBpcmUiOjE3MDAyMzQyNDQyNjMsIl9tYXhBZ2UiOjg2NDAwMDAwfQ== ``` ```json {"grant":{"provider":"microsoft","dynamic":{"callback":"https://google.fr/"}},"_expire":1700234244263,"_maxAge":86400000} ``` The vulnerability seems to come from the application's core: File: [<span dir="">packages/plugins/users-permissions/server/controllers/auth.js</span>](https://github.com/strapi/strapi/blob/develop/packages/plugins/users-permissions/server/controllers/auth.js) ```js 'use strict'; /** * Auth.js controller * * @description: A set of functions called "actions" for managing `Auth`. */ /* eslint-disable no-useless-escape */ const crypto = require('crypto'); const _ = require('lodash'); const { concat, compact, isArray } = require('lodash/fp'); const utils = require('@strapi/utils'); const { contentTypes: { getNonWritableAttributes }, } = require('@strapi/utils'); const { getService } = require('../utils'); const { validateCallbackBody, validateRegisterBody, validateSendEmailConfirmationBody, validateForgotPasswordBody, validateResetPasswordBody, validateEmailConfirmationBody, validateChangePasswordBody, } = require('./validation/auth'); const { getAbsoluteAdminUrl, getAbsoluteServerUrl, sanitize } = utils; const { ApplicationError, ValidationError, ForbiddenError } = utils.errors; const sanitizeUser = (user, ctx) => { const { auth } = ctx.state; const userSchema = strapi.getModel('plugin::users-permissions.user'); return sanitize.contentAPI.output(user, userSchema, { auth }); }; module.exports = { async callback(ctx) { const provider = ctx.params.provider || 'local'; const params = ctx.request.body; const store = strapi.store({ type: 'plugin', name: 'users-permissions' }); const grantSettings = await store.get({ key: 'grant' }); const grantProvider = provider === 'local' ? 'email' : provider; if (!_.get(grantSettings, [grantProvider, 'enabled'])) { throw new ApplicationError('This provider is disabled'); } if (provider === 'local') { await validateCallbackBody(params); const { identifier } = params; // Check if the user exists. const user = await strapi.query('plugin::users-permissions.user').findOne({ where: { provider, $or: [{ email: identifier.toLowerCase() }, { username: identifier }], }, }); if (!user) { throw new ValidationError('Invalid identifier or password'); } if (!user.password) { throw new ValidationError('Invalid identifier or password'); } const validPassword = await getService('user').validatePassword( params.password, user.password ); if (!validPassword) { throw new ValidationError('Invalid identifier or password'); } const advancedSettings = await store.get({ key: 'advanced' }); const requiresConfirmation = _.get(advancedSettings, 'email_confirmation'); if (requiresConfirmation && user.confirmed !== true) { throw new ApplicationError('Your account email is not confirmed'); } if (user.blocked === true) { throw new ApplicationError('Your account has been blocked by an administrator'); } return ctx.send({ jwt: getService('jwt').issue({ id: user.id }), user: await sanitizeUser(user, ctx), }); } // Connect the user with the third-party provider. try { const user = await getService('providers').connect(provider, ctx.query); if (user.blocked) { throw new ForbiddenError('Your account has been blocked by an administrator'); } return ctx.send({ jwt: getService('jwt').issue({ id: user.id }), user: await sanitizeUser(user, ctx), }); } catch (error) { throw new ApplicationError(error.message); } }, //... async connect(ctx, next) { const grant = require('grant-koa'); const providers = await strapi .store({ type: 'plugin', name: 'users-permissions', key: 'grant' }) .get(); const apiPrefix = strapi.config.get('api.rest.prefix'); const grantConfig = { defaults: { prefix: `${apiPrefix}/connect`, }, ...providers, }; const [requestPath] = ctx.request.url.split('?'); const provider = requestPath.split('/connect/')[1].split('/')[0]; if (!_.get(grantConfig[provider], 'enabled')) { throw new ApplicationError('This provider is disabled'); } if (!strapi.config.server.url.startsWith('http')) { strapi.log.warn( 'You are using a third party provider for login. Make sure to set an absolute url in config/server.js. More info here: https://docs.strapi.io/developer-docs/latest/plugins/users-permissions.html#setting-up-the-server-url' ); } // Ability to pass OAuth callback dynamically grantConfig[provider].callback = _.get(ctx, 'query.callback') || _.get(ctx, 'session.grant.dynamic.callback') || grantConfig[provider].callback; grantConfig[provider].redirect_uri = getService('providers').buildRedirectUri(provider); return grant(grantConfig)(ctx, next); }, //... }; ``` And more specifically: ```js ... // Ability to pass OAuth callback dynamically grantConfig[provider].callback = _.get(ctx, 'query.callback') || _.get(ctx, 'session.grant.dynamic.callback') || grantConfig[provider].callback; grantConfig[provider].redirect_uri = getService('providers').buildRedirectUri(provider); return grant(grantConfig)(ctx, next); ... ``` Possible patch: ```js grantConfig[provider].callback = process.env[`${provider.toUpperCase()}_REDIRECT_URL`] || grantConfig[provider].callback ``` `_.get(ctx, 'query.callback')` = `$_GET["callback"]` and `_.get(ctx, 'session')` = `$_COOKIE["koa.sess"]` (which is `{"grant":{"provider":"microsoft","dynamic":{"callback":"https://XXXXXXX/"}},"_expire":1701275652123,"_maxAge":86400000}`) so `_.get(ctx, 'session.grant.dynamic.callback')` = `https://XXXXXXX/`. The route is clearly defined here: File: [<span dir="">packages/plugins/users-permissions/server/routes/content-api/auth.js</span>](https://github.com/strapi/strapi/blob/develop/packages/plugins/users-permissions/server/routes/content-api/auth.js) ```js 'use strict'; module.exports = [ //... { method: 'GET', path: '/auth/:provider/callback', handler: 'auth.callback', config: { prefix: '',
Metadata
Created: 2024-06-12T19:39:11Z
Modified: 2024-06-12T19:39:11Z
Source: https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2024/06/GHSA-wrvh-rcmr-9qfc/GHSA-wrvh-rcmr-9qfc.json
CWE IDs: ["CWE-294", "CWE-601"]
Alternative ID: GHSA-wrvh-rcmr-9qfc
Finding: F100
Auto approve: 1