Skip to content

Commit

Permalink
Fix URL Redirection in OAuth2/OpenID/SAML (directus#21238)
Browse files Browse the repository at this point in the history
Co-authored-by: Pascal Jufer <[email protected]>
Co-authored-by: Azri Kahar <[email protected]>
  • Loading branch information
3 people authored Mar 4, 2024
1 parent 068591d commit 5477d7d
Show file tree
Hide file tree
Showing 11 changed files with 261 additions and 66 deletions.
6 changes: 6 additions & 0 deletions .changeset/calm-tables-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@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
21 changes: 12 additions & 9 deletions api/src/auth/drivers/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEnv } from '@directus/env';
import {
ErrorCode,
InvalidCredentialsError,
InvalidPayloadError,
InvalidProviderConfigError,
InvalidProviderError,
InvalidTokenError,
Expand All @@ -16,6 +17,7 @@ import jwt from 'jsonwebtoken';
import type { Client } from 'openid-client';
import { errors, generators, Issuer } from 'openid-client';
import { getAuthProvider } from '../../auth.js';
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
import getDatabase from '../../database/index.js';
import emitter from '../../emitter.js';
import { useLogger } from '../../logger.js';
Expand All @@ -26,9 +28,9 @@ import type { AuthData, AuthDriverOptions, User } from '../../types/index.js';
import asyncHandler from '../../utils/async-handler.js';
import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
import { getIPFromReq } from '../../utils/get-ip-from-req.js';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
import { Url } from '../../utils/url.js';
import { LocalAuthDriver } from './local.js';
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';

export class OAuth2AuthDriver extends LocalAuthDriver {
client: Client;
Expand Down Expand Up @@ -298,15 +300,16 @@ export function createOAuth2AuthRouter(providerName: string): Router {
const provider = getAuthProvider(providerName) as OAuth2AuthDriver;
const codeVerifier = provider.generateCodeVerifier();
const prompt = !!req.query['prompt'];
const redirect = req.query['redirect'];

const token = jwt.sign(
{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
env['SECRET'] as string,
{
expiresIn: '5m',
issuer: 'directus',
},
);
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',
});

res.cookie(`oauth2.${providerName}`, token, {
httpOnly: true,
Expand Down
22 changes: 12 additions & 10 deletions api/src/auth/drivers/openid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEnv } from '@directus/env';
import {
ErrorCode,
InvalidCredentialsError,
InvalidPayloadError,
InvalidProviderConfigError,
InvalidProviderError,
InvalidTokenError,
Expand All @@ -16,6 +17,7 @@ import jwt from 'jsonwebtoken';
import type { Client } from 'openid-client';
import { errors, generators, Issuer } from 'openid-client';
import { getAuthProvider } from '../../auth.js';
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
import getDatabase from '../../database/index.js';
import emitter from '../../emitter.js';
import { useLogger } from '../../logger.js';
Expand All @@ -26,9 +28,9 @@ import type { AuthData, AuthDriverOptions, User } from '../../types/index.js';
import asyncHandler from '../../utils/async-handler.js';
import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
import { getIPFromReq } from '../../utils/get-ip-from-req.js';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
import { Url } from '../../utils/url.js';
import { LocalAuthDriver } from './local.js';
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';

export class OpenIDAuthDriver extends LocalAuthDriver {
client: Promise<Client>;
Expand Down Expand Up @@ -320,7 +322,6 @@ const handleError = (e: any) => {

export function createOpenIDAuthRouter(providerName: string): Router {
const env = useEnv();

const router = Router();

router.get(
Expand All @@ -329,15 +330,16 @@ export function createOpenIDAuthRouter(providerName: string): Router {
const provider = getAuthProvider(providerName) as OpenIDAuthDriver;
const codeVerifier = provider.generateCodeVerifier();
const prompt = !!req.query['prompt'];
const redirect = req.query['redirect'];

const token = jwt.sign(
{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
env['SECRET'] as string,
{
expiresIn: '5m',
issuer: 'directus',
},
);
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',
});

res.cookie(`openid.${providerName}`, token, {
httpOnly: true,
Expand Down
17 changes: 15 additions & 2 deletions api/src/auth/drivers/saml.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as validator from '@authenio/samlify-node-xmllint';
import { useEnv } from '@directus/env';
import { ErrorCode, InvalidCredentialsError, InvalidProviderError, isDirectusError } from '@directus/errors';
import {
ErrorCode,
InvalidCredentialsError,
InvalidPayloadError,
InvalidProviderError,
isDirectusError,
} from '@directus/errors';
import express, { Router } from 'express';
import * as samlify from 'samlify';
import { getAuthProvider } from '../../auth.js';
Expand All @@ -15,6 +21,7 @@ import type { AuthDriverOptions, User } from '../../types/index.js';
import asyncHandler from '../../utils/async-handler.js';
import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
import { LocalAuthDriver } from './local.js';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';

// Register the samlify schema validator
samlify.setSchemaValidator(validator);
Expand Down Expand Up @@ -126,7 +133,13 @@ export function createSAMLAuthRouter(providerName: string) {
const parsedUrl = new URL(url);

if (req.query['redirect']) {
parsedUrl.searchParams.append('RelayState', req.query['redirect'] as string);
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);
}

return res.redirect(parsedUrl.toString());
Expand Down
78 changes: 78 additions & 0 deletions api/src/utils/is-login-redirect-allowed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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);
});
41 changes: 41 additions & 0 deletions api/src/utils/is-login-redirect-allowed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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}`;
}
37 changes: 37 additions & 0 deletions api/src/utils/is-url-allowed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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);
});
10 changes: 5 additions & 5 deletions api/src/utils/is-url-allowed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { URL } from 'url';
import { useLogger } from '../logger.js';

/**
* Check if url matches allow list either exactly or by domain+path
* Check if URL matches allow list either exactly or by origin (protocol+domain+port) + pathname
*/
export default function isUrlAllowed(url: string, allowList: string | string[]): boolean {
const logger = useLogger();
Expand All @@ -15,8 +15,8 @@ export default function isUrlAllowed(url: string, allowList: string | string[]):
const parsedWhitelist = urlAllowList
.map((allowedURL) => {
try {
const { hostname, pathname } = new URL(allowedURL);
return hostname + pathname;
const { origin, pathname } = new URL(allowedURL);
return origin + pathname;
} catch {
logger.warn(`Invalid URL used "${url}"`);
}
Expand All @@ -26,8 +26,8 @@ export default function isUrlAllowed(url: string, allowList: string | string[]):
.filter((f) => f) as string[];

try {
const { hostname, pathname } = new URL(url);
return parsedWhitelist.includes(hostname + pathname);
const { origin, pathname } = new URL(url);
return parsedWhitelist.includes(origin + pathname);
} catch {
return false;
}
Expand Down
11 changes: 11 additions & 0 deletions docs/releases/breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ AuthenticationService.login('email', 'password', { otp: 'otp-code', session: tru
:::
### 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).

## Version 10.9.0

### Updated Exif Tags
Expand Down
Loading

0 comments on commit 5477d7d

Please sign in to comment.