CVE-2024-28239 (TypeScript, published 2024-03-12T21:15Z)
Description: value: Directus is a real-time API and App dashboard for managing SQL database content. The authentication API has a `redirect` parameter that can be exploited as an open redirect vulnerability as the user tries to log in via the API URL. There's a redirect that is done after successful login via the Auth API GET request to `directus/auth/login/google?redirect=http://malicious-fishing-site.com`. While credentials don't seem to be passed to the attacker site, the user can be phished into clicking a legitimate directus site and be taken to a malicious site made to look like a an error message "Your password needs to be updated" to phish out the current password. Users who login via OAuth2 into Directus may be at risk. This issue has been addressed in version 10.10.0. Users are advised to upgrade. There are no known workarounds for this vulnerability.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/directus/directus
Files changed:
.changeset/calm-tables-destroy.md
api/src/auth/drivers/oauth2.ts
api/src/auth/drivers/openid.ts
api/src/auth/drivers/saml.ts
api/src/utils/is-login-redirect-allowed.test.ts
api/src/utils/is-login-redirect-allowed.ts
api/src/utils/is-url-allowed.test.ts
api/src/utils/is-url-allowed.ts
docs/releases/breaking-changes.md
docs/self-hosted/config-options.md
packages/env/src/constants/directus-variables.ts
Commit message: Fix URL Redirection in OAuth2/OpenID/SAML (#21238)

Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

Vulnerable code:
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
			const token = jwt.sign(
				{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
				env['SECRET'] as string,
				{
					expiresIn: '5m',
					issuer: 'directus',
				},
			);
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';

			const token = jwt.sign(
				{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
				env['SECRET'] as string,
				{
					expiresIn: '5m',
					issuer: 'directus',
				},
			);
import { ErrorCode, InvalidCredentialsError, InvalidProviderError, isDirectusError } from '@directus/errors';
				parsedUrl.searchParams.append('RelayState', req.query['redirect'] as string);
 * Check if url matches allow list either exactly or by domain+path
				const { hostname, pathname } = new URL(allowedURL);
				return hostname + pathname;
		const { hostname, pathname } = new URL(url);
		return parsedWhitelist.includes(hostname + pathname);
| Variable                                    | Description                                                                                       | Default Value    |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the OAuth provider.                                                         | --               |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the OAuth provider.                                                             | --               |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                           | `email`          |
| `AUTH_<PROVIDER>_AUTHORIZE_URL`             | Authorization page URL of the OAuth provider.                                                     | --               |
| `AUTH_<PROVIDER>_ACCESS_URL`                | Access token URL of the OAuth provider.                                                           | --               |
| `AUTH_<PROVIDER>_PROFILE_URL`               | User profile URL of the OAuth provider.                                                           | --               |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                          | --               |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                           | `email`          |
| `AUTH_<PROVIDER>_FIRST_NAME_KEY`            | User profile first name key.                                                                      | --               |
| `AUTH_<PROVIDER>_LAST_NAME_KEY`             | User profile last name key.                                                                       | --               |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                           | `false`          |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                       | --               |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons). | `account_circle` |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                    | `<PROVIDER>`     |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                         | --               |
| Variable                                    | Description                                                                                       | Default Value          |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the external service.                                                       | --                     |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the external service.                                                           | --                     |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                           | `openid profile email` |
| `AUTH_<PROVIDER>_ISSUER_URL`                | OpenID `.well-known` discovery document URL of the external service.                              | --                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                          | `sub`<sup>[2]</sup>    |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                           | `false`                |
| `AUTH_<PROVIDER>_REQUIRE_VERIFIED_EMAIL`    | Require created users to have a verified email address.                                           | `false`                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                       | --                     |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons). | `account_circle`       |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                    | `<PROVIDER>`           |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                         | --                     |
| Variable                                    | Description                                             | Default Value                                                          |
| ------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- |
| `AUTH_<PROVIDER>_SP_metadata`               | String containing XML metadata for service provider     | --                                                                     |
| `AUTH_<PROVIDER>_IDP_metadata`              | String containing XML metadata for identity provider    | --                                                                     |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users. | `false`                                                                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.             | --                                                                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>.             | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                 | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`   |
| `AUTH_<PROVIDER>_GIVEN_NAME_KEY`            | User first name attribute.                              | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`      |
| `AUTH_<PROVIDER>_FAMILY_NAME_KEY`           | User last name attribute.                               | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`        |

Fixed code:
---
"@directus/api": patch
"@directus/env": patch
---

Fixed potential Open Redirect vulnerability with OAuth2/OpenID/SAML SSO providers (via Directus redirect query parameter), by requiring redirect URLs to be enabled via allow list
	InvalidPayloadError,
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
			const redirect = req.query['redirect'];
			if (isLoginRedirectAllowed(redirect, providerName) === false) {
				throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
			}

			const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'] as string, {
				expiresIn: '5m',
				issuer: 'directus',
			});
	InvalidPayloadError,
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
			const redirect = req.query['redirect'];
			if (isLoginRedirectAllowed(redirect, providerName) === false) {
				throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
			}

			const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'] as string, {
				expiresIn: '5m',
				issuer: 'directus',
			});
import {
	ErrorCode,
	InvalidCredentialsError,
	InvalidPayloadError,
	InvalidProviderError,
	isDirectusError,
} from '@directus/errors';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
				const redirect = req.query['redirect'] as string;

				if (isLoginRedirectAllowed(redirect, providerName) === false) {
					throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
				}

				parsedUrl.searchParams.append('RelayState', redirect);
import { vi, expect, test, afterEach } from 'vitest';
import { useEnv } from '@directus/env';
import { isLoginRedirectAllowed } from './is-login-redirect-allowed.js';

vi.mock('@directus/env');

afterEach(() => {
	vi.clearAllMocks();
});

test('isLoginRedirectAllowed returns true with no redirect', () => {
	const redirect = undefined;
	const provider = 'local';

	expect(isLoginRedirectAllowed(redirect, provider)).toBe(true);
});

test('isLoginRedirectAllowed returns false with invalid redirect', () => {
	const redirect = 123456;
	const provider = 'local';

	expect(isLoginRedirectAllowed(redirect, provider)).toBe(false);
});

test('isLoginRedirectAllowed returns true for allowed URL', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]:
			'http://external.example.com,https://external.example.com,http://external.example.com:8055/test',
		PUBLIC_URL: 'http://public.example.com',
	});

	expect(isLoginRedirectAllowed('http://public.example.com', provider)).toBe(true);
	expect(isLoginRedirectAllowed('http://external.example.com', provider)).toBe(true);
	expect(isLoginRedirectAllowed('https://external.example.com', provider)).toBe(true);
	expect(isLoginRedirectAllowed('http://external.example.com:8055/test', provider)).toBe(true);
});

test('isLoginRedirectAllowed returns false for denied URL', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://external.example.com',
		PUBLIC_URL: 'http://public.example.com',
	});

	expect(isLoginRedirectAllowed('https://external.example.com', provider)).toBe(false);
	expect(isLoginRedirectAllowed('http://external.example.com:8055', provider)).toBe(false);
	expect(isLoginRedirectAllowed('http://external.example.com/test', provider)).toBe(false);
});

test('isLoginRedirectAllowed returns true for relative paths', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://external.example.com',
		PUBLIC_URL: 'http://public.example.com',
	});

	expect(isLoginRedirectAllowed('/admin/content', provider)).toBe(true);
	expect(isLoginRedirectAllowed('../admin/content', provider)).toBe(true);
	expect(isLoginRedirectAllowed('./admin/content', provider)).toBe(true);

	expect(isLoginRedirectAllowed('http://public.example.com/admin/content', provider)).toBe(true);
});

test('isLoginRedirectAllowed returns false if missing protocol', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://example.com',
		PUBLIC_URL: 'http://example.com',
	});

	expect(isLoginRedirectAllowed('//example.com/admin/content', provider)).toBe(false);
	expect(isLoginRedirectAllowed('//user@password:example.com/', provider)).toBe(false);
});
import { useEnv } from '@directus/env';
import { toArray } from '@directus/utils';
import isUrlAllowed from './is-url-allowed.js';

/**
 * Checks if the defined redirect after successful SSO login is in the allow list
 */
export function isLoginRedirectAllowed(redirect: unknown, provider: string): boolean {
	if (!redirect) return true; // empty redirect
	if (typeof redirect !== 'string') return false; // invalid type

	const env = useEnv();
	const publicUrl = env['PUBLIC_URL'] as string;

	if (URL.canParse(redirect) === false) {
		if (redirect.startsWith('//') === false) {
			// should be a relative path like `/admin/test`
			return true;
		}

		// domain without protocol `//example.com/test`
		return false;
	}

	const { protocol: redirectProtocol, hostname: redirectDomain } = new URL(redirect);

	const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;

	if (envKey in env) {
		if (isUrlAllowed(redirect, [...toArray(env[envKey] as string), publicUrl])) return true;
	}

	if (URL.canParse(publicUrl) === false) {
		return false;
	}

	// allow redirects to the defined PUBLIC_URL
	const { protocol: publicProtocol, hostname: publicDomain } = new URL(publicUrl);

	return `${redirectProtocol}//${redirectDomain}` === `${publicProtocol}//${publicDomain}`;
}
import { expect, test } from 'vitest';
import isUrlAllowed from './is-url-allowed.js';

test('isUrlAllowed should allow matching domain', () => {
	const checkUrl = 'https://directus.io';
	const allowedUrls = ['https://directus.io/'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(true);
});

test('isUrlAllowed should allow matching path', () => {
	const checkUrl = 'https://directus.io/tv';
	const allowedUrls = ['https://directus.io/tv'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(true);
});

test('isUrlAllowed should block different paths', () => {
	const checkUrl = 'http://example.com/test1';
	const allowedUrls = ['http://example.com/test2', 'http://example.com/test3', 'http://example.com/'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
});

test('isUrlAllowed should block different domains', () => {
	const checkUrl = 'http://directus.io/';
	const allowedUrls = ['http://example.com/', 'http://directus.chat'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
});

test('isUrlAllowed blocks varying protocols', () => {
	const checkUrl = 'http://example.com/';
	const allowedUrls = ['ftp://example.com/', 'https://example.com/'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
});
 * Check if URL matches allow list either exactly or by origin (protocol+domain+port) + pathname
				const { origin, pathname } = new URL(allowedURL);
				return origin + pathname;
		const { origin, pathname } = new URL(url);
		return parsedWhitelist.includes(origin + pathname);
### Introduced Allow List for OAuth2/OpenID/SAML Redirects

Due to an Open Redirect vulnerability with the OAuth2, OpenID and SAML SSO providers, we have introduced an allow list
for these redirects.

If your current workflow depends on redirecting to an external domain after successful SSO login using the
`?redirect=http://example.com/login` query parameter, then you'll need to add this URL to the
`AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST` config option.

`AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST` accepts a comma-separated list of URLs (path is included in comparison).

| Variable                                    | Description                                                                                               | Default Value    |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the OAuth provider.                                                                 | --               |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the OAuth provider.                                                                     | --               |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                                   | `email`          |
| `AUTH_<PROVIDER>_AUTHORIZE_URL`             | Authorization page URL of the OAuth provider.                                                             | --               |
| `AUTH_<PROVIDER>_ACCESS_URL`                | Access token URL of the OAuth provider.                                                                   | --               |
| `AUTH_<PROVIDER>_PROFILE_URL`               | User profile URL of the OAuth provider.                                                                   | --               |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                                  | --               |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                                   | `email`          |
| `AUTH_<PROVIDER>_FIRST_NAME_KEY`            | User profile first name key.                                                                              | --               |
| `AUTH_<PROVIDER>_LAST_NAME_KEY`             | User profile last name key.                                                                               | --               |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`          |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --               |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons).         | `account_circle` |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                            | `<PROVIDER>`     |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                                 | --               |
| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --               |
| Variable                                    | Description                                                                                               | Default Value          |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the external service.                                                               | --                     |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the external service.                                                                   | --                     |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                                   | `openid profile email` |
| `AUTH_<PROVIDER>_ISSUER_URL`                | OpenID `.well-known` discovery document URL of the external service.                                      | --                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                                  | `sub`<sup>[2]</sup>    |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`                |
| `AUTH_<PROVIDER>_REQUIRE_VERIFIED_EMAIL`    | Require created users to have a verified email address.                                                   | `false`                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --                     |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons).         | `account_circle`       |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                            | `<PROVIDER>`           |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                                 | --                     |
| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --                     |
| Variable                                    | Description                                                                                               | Default Value                                                          |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `AUTH_<PROVIDER>_SP_metadata`               | String containing XML metadata for service provider                                                       | --                                                                     |
| `AUTH_<PROVIDER>_IDP_metadata`              | String containing XML metadata for identity provider                                                      | --                                                                     |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`                                                                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --                                                                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>.                                                               | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                                   | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`   |
| `AUTH_<PROVIDER>_GIVEN_NAME_KEY`            | User first name attribute.                                                                                | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`      |
| `AUTH_<PROVIDER>_FAMILY_NAME_KEY`           | User last name attribute.                                                                                 | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`        |
| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --                                                                     |
	'AUTH_.+_REDIRECT_ALLOW_LIST',