diff --git a/client/src/behaviors/SafelyRedirectBehavior.ts b/client/src/behaviors/SafelyRedirectBehavior.ts new file mode 100644 index 000000000..8f73d6c00 --- /dev/null +++ b/client/src/behaviors/SafelyRedirectBehavior.ts @@ -0,0 +1,15 @@ +import { Dispatch } from "redux"; +import * as AutheliaService from '../services/AutheliaService'; + +export default async function(url: string, dispatch: Dispatch) { + try { + // Check the url against the backend before redirecting. + await AutheliaService.checkRedirection(url); + window.location.href = url; + } catch (e) { + console.error( + 'Cannot redirect since the URL is not in the protected domain.' + + 'This behavior could be malicious so please the issue to an administrator.'); + throw e; + } +} \ No newline at end of file diff --git a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts index 98f2e0606..41f83fa2b 100644 --- a/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts +++ b/client/src/containers/components/SecondFactorForm/SecondFactorForm.ts @@ -9,6 +9,7 @@ import * as AutheliaService from '../../../services/AutheliaService'; import { push } from 'connected-react-router'; import fetchState from '../../../behaviors/FetchStateBehavior'; import LogoutBehavior from '../../../behaviors/LogoutBehavior'; +import SafelyRedirectBehavior from '../../../behaviors/SafelyRedirectBehavior'; const mapStateToProps = (state: RootState): StateProps => ({ securityKeySupported: state.secondFactor.securityKeySupported, @@ -52,19 +53,23 @@ async function triggerSecurityKeySigning(dispatch: Dispatch) { await dispatch(securityKeySignSuccess()); } -function redirectOnSuccess(dispatch: Dispatch, ownProps: OwnProps, duration?: number) { - function redirect() { +async function handleSuccess(dispatch: Dispatch, ownProps: OwnProps, duration?: number) { + async function handle() { if (ownProps.redirection) { - window.location.href = ownProps.redirection; + try { + await SafelyRedirectBehavior(ownProps.redirection, dispatch); + } catch (e) { + await fetchState(dispatch); + } } else { - fetchState(dispatch); + await fetchState(dispatch); } } if (duration) { - setTimeout(redirect, duration); + setTimeout(handle, duration); } else { - redirect(); + await handle(); } } @@ -84,7 +89,7 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => { if (isU2FSupported) { await dispatch(setSecurityKeySupported(true)); await triggerSecurityKeySigning(dispatch); - redirectOnSuccess(dispatch, ownProps, 1000); + await handleSuccess(dispatch, ownProps, 1000); } }, onOneTimePasswordValidationRequested: async (token: string) => { @@ -106,7 +111,7 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => { throw body['error']; } dispatch(oneTimePasswordVerificationSuccess()); - redirectOnSuccess(dispatch, ownProps); + await handleSuccess(dispatch, ownProps); }, } } diff --git a/client/src/containers/views/AuthenticationView/AuthenticationView.ts b/client/src/containers/views/AuthenticationView/AuthenticationView.ts index fe9fd47d4..7aa6dec4b 100644 --- a/client/src/containers/views/AuthenticationView/AuthenticationView.ts +++ b/client/src/containers/views/AuthenticationView/AuthenticationView.ts @@ -1,10 +1,10 @@ import { connect } from 'react-redux'; -import AuthenticationView, {StateProps, Stage, DispatchProps} from '../../../views/AuthenticationView/AuthenticationView'; +import QueryString from 'query-string'; +import AuthenticationView, {StateProps, Stage, DispatchProps, OwnProps} from '../../../views/AuthenticationView/AuthenticationView'; import { RootState } from '../../../reducers'; import { Dispatch } from 'redux'; import AuthenticationLevel from '../../../types/AuthenticationLevel'; import FetchStateBehavior from '../../../behaviors/FetchStateBehavior'; -import { setRedirectionUrl } from '../../../reducers/Portal/Authentication/actions'; function authenticationLevelToStage(level: AuthenticationLevel): Stage { switch (level) { @@ -17,12 +17,21 @@ function authenticationLevelToStage(level: AuthenticationLevel): Stage { } } -const mapStateToProps = (state: RootState): StateProps => { +const mapStateToProps = (state: RootState, ownProps: OwnProps): StateProps => { const stage = (state.authentication.remoteState) ? authenticationLevelToStage(state.authentication.remoteState.authentication_level) : Stage.FIRST_FACTOR; + + let url: string | null = null; + if (ownProps.location) { + const params = QueryString.parse(ownProps.location.search); + if ('rd' in params) { + url = params['rd'] as string; + } + } + return { - redirectionUrl: state.authentication.redirectionUrl, + redirectionUrl: url, remoteState: state.authentication.remoteState, stage: stage, }; @@ -30,11 +39,8 @@ const mapStateToProps = (state: RootState): StateProps => { const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { return { - onInit: async (redirectionUrl?: string) => { + onInit: async () => { await FetchStateBehavior(dispatch); - if (redirectionUrl) { - await dispatch(setRedirectionUrl(redirectionUrl)); - } } } } diff --git a/client/src/services/AutheliaService.ts b/client/src/services/AutheliaService.ts index 5c4a962bc..d91386855 100644 --- a/client/src/services/AutheliaService.ts +++ b/client/src/services/AutheliaService.ts @@ -114,4 +114,24 @@ export async function resetPassword(newPassword: string) { }, body: JSON.stringify({password: newPassword}) }); +} + +export async function checkRedirection(url: string) { + const res = await fetch('/api/redirect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({url}) + }) + + if (res.status !== 200) { + throw new Error('Status code ' + res.status); + } + + const text = await res.text(); + if (text !== 'OK') { + throw new Error('Cannot redirect'); + } + return; } \ No newline at end of file diff --git a/client/src/views/AuthenticationView/AuthenticationView.tsx b/client/src/views/AuthenticationView/AuthenticationView.tsx index 6e502e487..0c43e0a49 100644 --- a/client/src/views/AuthenticationView/AuthenticationView.tsx +++ b/client/src/views/AuthenticationView/AuthenticationView.tsx @@ -3,8 +3,7 @@ import AlreadyAuthenticated from "../../containers/components/AlreadyAuthenticat import FirstFactorForm from "../../containers/components/FirstFactorForm/FirstFactorForm"; import SecondFactorForm from "../../containers/components/SecondFactorForm/SecondFactorForm"; import RemoteState from "./RemoteState"; -import { RouterProps } from "react-router"; -import queryString from 'query-string'; +import { RouterProps, RouteProps } from "react-router"; export enum Stage { FIRST_FACTOR, @@ -12,6 +11,8 @@ export enum Stage { ALREADY_AUTHENTICATED, } +export interface OwnProps extends RouteProps {} + export interface StateProps { stage: Stage; remoteState: RemoteState | null; @@ -19,19 +20,13 @@ export interface StateProps { } export interface DispatchProps { - onInit: (redirectionUrl?: string) => void; + onInit: () => void; } export type Props = StateProps & DispatchProps & RouterProps; class AuthenticationView extends Component { - componentDidMount() { - if (this.props.history.location) { - const params = queryString.parse(this.props.history.location.search); - if ('rd' in params) { - this.props.onInit(params['rd'] as string); - } - } + componentWillMount() { this.props.onInit(); } diff --git a/config.minimal.yml b/config.minimal.yml index 1a26aae91..e3330595f 100644 --- a/config.minimal.yml +++ b/config.minimal.yml @@ -4,6 +4,8 @@ port: 9091 +log_level: debug + authentication_backend: file: path: ./users_database.yml diff --git a/package-lock.json b/package-lock.json index 5042c9bc8..6cef3032a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -532,6 +532,11 @@ "integrity": "sha512-vOVmaruQG5EatOU/jM6yU2uCp3Lz6mK1P5Ztu4iJjfM4SVHU9XYktPUQtKlIXuahqXHdEyUarMrBEwg5Cwu+bA==", "dev": true }, + "@types/url-parse": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/url-parse/-/url-parse-1.4.2.tgz", + "integrity": "sha512-Z25Ef2iI0lkiBAoe8qATRHQWy+BWFWuWasD6HB5prsWx2QjLmB/ngXv8v9dePw2jDwdSvET4/6J5HxmeqhslmQ==" + }, "@types/winston": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@types/winston/-/winston-2.3.9.tgz", @@ -7053,6 +7058,11 @@ "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", "dev": true }, + "querystringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.0.tgz", + "integrity": "sha512-sluvZZ1YiTLD5jsqZcDmFyV2EwToyXZBfpoVOmktMmW+VEnhgakFHnasVph65fOjGPTWN0Nw3+XQaSeMayr0kg==" + }, "random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -7424,6 +7434,11 @@ "semver": "5.5.0" } }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, "resolve": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", @@ -8924,6 +8939,15 @@ } } }, + "url-parse": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.4.tgz", + "integrity": "sha512-/92DTTorg4JjktLNLe6GPS2/RvAd/RGr6LuktmWSMLEOa6rjnlrFXNgSbSmkNvCoL2T028A0a1JaJLzRMlFoHg==", + "requires": { + "querystringify": "2.1.0", + "requires-port": "1.0.0" + } + }, "url-parse-lax": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", diff --git a/package.json b/package.json index d05c2051a..11654cb71 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "title": "Authelia API documentation" }, "dependencies": { + "@types/url-parse": "^1.4.2", "ajv": "^6.3.0", "bluebird": "^3.5.0", "body-parser": "^1.15.2", @@ -50,6 +51,7 @@ "speakeasy": "^2.0.0", "u2f": "^0.1.2", "u2f-api": "^1.0.7", + "url-parse": "^1.4.4", "winston": "^2.3.1", "yamljs": "^0.3.0" }, diff --git a/server/src/lib/routes/redirect/post.ts b/server/src/lib/routes/redirect/post.ts new file mode 100644 index 000000000..ab001a9b6 --- /dev/null +++ b/server/src/lib/routes/redirect/post.ts @@ -0,0 +1,31 @@ +import * as Express from "express"; +import { ServerVariables } from "../../ServerVariables"; +import { AuthenticationSessionHandler } from "../../AuthenticationSessionHandler"; +import { Level } from "../../authentication/Level"; +import * as URLParse from "url-parse"; + +export default function (vars: ServerVariables) { + return function (req: Express.Request, res: Express.Response) { + if (!req.body.url) { + res.status(400); + vars.logger.error(req, "Provide url for verification to be done."); + return; + } + + const authSession = AuthenticationSessionHandler.get(req, vars.logger); + const url = new URLParse(req.body.url); + + const urlInDomain = url.hostname.endsWith(vars.config.session.domain); + vars.logger.debug(req, "Check domain %s is in url %s.", vars.config.session.domain, url.hostname); + const sufficientPermissions = authSession.authentication_level >= Level.TWO_FACTOR; + + vars.logger.debug(req, "Check that protocol %s is HTTPS.", url.protocol); + const protocolIsHttps = url.protocol === "https:"; + + if (sufficientPermissions && urlInDomain && protocolIsHttps) { + res.send("OK"); + return; + } + res.send("NOK"); + }; +} diff --git a/server/src/lib/web_server/RestApi.ts b/server/src/lib/web_server/RestApi.ts index b5ca43ae1..75bd78fdd 100644 --- a/server/src/lib/web_server/RestApi.ts +++ b/server/src/lib/web_server/RestApi.ts @@ -3,6 +3,7 @@ import Express = require("express"); import FirstFactorPost = require("../routes/firstfactor/post"); import LogoutPost from "../routes/logout/post"; import StateGet from "../routes/state/get"; +import RedirectPost from "../routes/redirect/post"; import VerifyGet = require("../routes/verify/get"); import TOTPSignGet = require("../routes/secondfactor/totp/sign/post"); @@ -86,6 +87,7 @@ function setupResetPassword(app: Express.Application, vars: ServerVariables) { export class RestApi { static setup(app: Express.Application, vars: ServerVariables): void { app.get(Endpoints.STATE_GET, StateGet(vars)); + app.post(Endpoints.REDIRECT_POST, RedirectPost(vars)); app.post(Endpoints.LOGOUT_POST, LogoutPost(vars)); diff --git a/shared/api.ts b/shared/api.ts index 3f01d6c4a..522a420d7 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -287,3 +287,17 @@ export const VERIFY_GET = "/api/verify"; */ export const LOGOUT_POST = "/api/logout"; +/** + * @api {post} /api/redirect Url redirection checking endpoint + * @apiName Redirect + * @apiGroup Authentication + * @apiVersion 1.0.0 + * @apiDescription Check if the user can be redirected to the url provided. + * The level of permissions for this user are checked and the url must be + * in the domain protected by authelia. + * + * @apiSuccess (Success 200) + * + * @apiDescription Resets the session to logout the user. + */ +export const REDIRECT_POST = "/api/redirect"; \ No newline at end of file diff --git a/test/complete-config/00-suite.ts b/test/complete-config/00-suite.ts deleted file mode 100644 index 3ef451b56..000000000 --- a/test/complete-config/00-suite.ts +++ /dev/null @@ -1,28 +0,0 @@ -require("chromedriver"); -import Environment = require('../environment'); - -const includes = [ - "docker-compose.yml", - "example/compose/docker-compose.base.yml", - "example/compose/mongo/docker-compose.yml", - "example/compose/redis/docker-compose.yml", - "example/compose/nginx/backend/docker-compose.yml", - "example/compose/nginx/portal/docker-compose.yml", - "example/compose/smtp/docker-compose.yml", - "example/compose/httpbin/docker-compose.yml", - "example/compose/ldap/docker-compose.yml" -]; - - -before(function() { - this.timeout(20000); - this.environment = new Environment.Environment(includes); - return this.environment.setup(5000); -}); - -after(function() { - this.timeout(30000); - if(process.env.KEEP_ENV != "true") { - return this.environment.cleanup(); - } -}); \ No newline at end of file diff --git a/test/complete-config/closed-redirection.ts b/test/complete-config/closed-redirection.ts deleted file mode 100644 index 54ff74d3f..000000000 --- a/test/complete-config/closed-redirection.ts +++ /dev/null @@ -1,41 +0,0 @@ -import WithDriver from "../helpers/context/WithDriver"; -import LoginAndRegisterTotp from "../helpers/LoginAndRegisterTotp"; -import SeeNotification from "../helpers/SeeNotification"; -import VisitPage from "../helpers/VisitPage"; -import FillLoginPageWithUserAndPasswordAndClick from '../helpers/FillLoginPageAndClick'; -import ValidateTotp from "../helpers/ValidateTotp"; -import {CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN} from '../../shared/UserMessages'; - -/* - * Authelia should not be vulnerable to open redirection. Otherwise it would aid an - * attacker in conducting a phishing attack. - * - * To avoid the issue, Authelia's client scans the URL and prevent any redirection if - * the URL is pointing to an external domain. - */ -describe("Redirection should be performed only if in domain", function() { - this.timeout(10000); - WithDriver(); - - before(function() { - const that = this; - return LoginAndRegisterTotp(this.driver, "john", true) - .then((secret: string) => that.secret = secret) - }); - - function DoNotRedirect(url: string) { - it(`should see an error message instead of redirecting to ${url}`, function() { - const driver = this.driver; - const secret = this.secret; - return VisitPage(driver, `https://login.example.com:8080/?rd=${url}`) - .then(() => FillLoginPageWithUserAndPasswordAndClick(driver, 'john', 'password')) - .then(() => ValidateTotp(driver, secret)) - .then(() => SeeNotification(driver, "error", CANNOT_REDIRECT_TO_EXTERNAL_DOMAIN)) - .then(() => driver.get(`https://login.example.com:8080/logout`)); - }); - } - - DoNotRedirect("www.google.fr"); - DoNotRedirect("http://www.google.fr"); - DoNotRedirect("https://www.google.fr"); -}) \ No newline at end of file diff --git a/test/complete-config/mongo-broken-connection.ts b/test/complete-config/mongo-broken-connection.ts deleted file mode 100644 index 92f087b8f..000000000 --- a/test/complete-config/mongo-broken-connection.ts +++ /dev/null @@ -1,17 +0,0 @@ -import WithDriver from '../helpers/context/WithDriver'; -import fullLogin from '../helpers/FullLogin'; -import loginAndRegisterTotp from '../helpers/LoginAndRegisterTotp'; - -describe("Connection retry when mongo fails or restarts", function() { - this.timeout(30000); - WithDriver(); - - it("should be able to login after mongo restarts", function() { - const that = this; - let secret; - return loginAndRegisterTotp(that.driver, "john", true) - .then(_secret => secret = _secret) - .then(() => that.environment.restart_service("mongo", 1000)) - .then(() => fullLogin(that.driver, "https://admin.example.com:8080/secret.html", "john", secret)); - }) -}); diff --git a/test/helpers/IsAlreadyAuthenticatedStage.ts b/test/helpers/IsAlreadyAuthenticatedStage.ts new file mode 100644 index 000000000..8475b5c08 --- /dev/null +++ b/test/helpers/IsAlreadyAuthenticatedStage.ts @@ -0,0 +1,5 @@ +import SeleniumWebDriver, { WebDriver } from "selenium-webdriver"; + +export default async function(driver: WebDriver) { + await driver.wait(SeleniumWebDriver.until.elementLocated(SeleniumWebDriver.By.className('already-authenticated-step'))); +} \ No newline at end of file diff --git a/test/helpers/Logout.ts b/test/helpers/Logout.ts new file mode 100644 index 000000000..eccd40f1e --- /dev/null +++ b/test/helpers/Logout.ts @@ -0,0 +1,5 @@ +import { WebDriver } from "selenium-webdriver"; + +export default async function(driver: WebDriver) { + await driver.get(`https://login.example.com:8080/logout`); +} \ No newline at end of file diff --git a/test/helpers/context/AutheliaSuite.ts b/test/helpers/context/AutheliaSuite.ts index 546ba0929..adbe09f4d 100644 --- a/test/helpers/context/AutheliaSuite.ts +++ b/test/helpers/context/AutheliaSuite.ts @@ -4,15 +4,15 @@ import WithDriver from "./WithDriver"; let running = false; interface AutheliaSuiteType { - (description: string, cb: (this: Mocha.ISuiteCallbackContext) => void): Mocha.ISuite; - only: (description: string, cb: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite; + (description: string, configPath: string, cb: (this: Mocha.ISuiteCallbackContext) => void): Mocha.ISuite; + only: (description: string, configPath: string, cb: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite; } -function AutheliaSuiteBase(description: string, - context: (description: string, ctx: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite, - cb: (this: Mocha.ISuiteCallbackContext) => void) { +function AutheliaSuiteBase(description: string, configPath: string, + cb: (this: Mocha.ISuiteCallbackContext) => void, + context: (description: string, ctx: (this: Mocha.ISuiteCallbackContext) => void) => Mocha.ISuite) { if (!running && process.env['WITH_SERVER'] == 'y') { - WithAutheliaRunning(); + WithAutheliaRunning(configPath); running = true; } @@ -22,13 +22,16 @@ function AutheliaSuiteBase(description: string, }); } -const AutheliaSuite = function(description: string, cb: (this: Mocha.ISuiteCallbackContext) => void) { - return AutheliaSuiteBase(description, describe, cb); +const AutheliaSuite = function( + description: string, configPath: string, + cb: (this: Mocha.ISuiteCallbackContext) => void) { + return AutheliaSuiteBase(description, configPath, cb, describe); } -AutheliaSuite.only = function(description: string, cb: (this: Mocha.ISuiteCallbackContext) => void) { - return AutheliaSuiteBase(description, describe.only, cb); +AutheliaSuite.only = function(description: string, configPath: string, + cb: (this: Mocha.ISuiteCallbackContext) => void) { + return AutheliaSuiteBase(description, configPath, cb, describe.only); } export default AutheliaSuite as AutheliaSuiteType; \ No newline at end of file diff --git a/test/helpers/context/WithAutheliaRunning.ts b/test/helpers/context/WithAutheliaRunning.ts index afae77194..963c62a20 100644 --- a/test/helpers/context/WithAutheliaRunning.ts +++ b/test/helpers/context/WithAutheliaRunning.ts @@ -1,12 +1,12 @@ import ChildProcess from 'child_process'; -export default function WithAutheliaRunning(waitTimeout: number = 3000) { +export default function WithAutheliaRunning(configPath: string, waitTimeout: number = 3000) { before(function() { this.timeout(5000); const authelia = ChildProcess.spawn( './scripts/authelia-scripts', - ['serve', '--no-watch', '--config', 'config.minimal.yml'], + ['serve', '--no-watch', '--config', configPath], {detached: true}); this.authelia = authelia; diff --git a/test/helpers/context/WithDriver.ts b/test/helpers/context/WithDriver.ts index fe9280074..fa7bbd3ad 100644 --- a/test/helpers/context/WithDriver.ts +++ b/test/helpers/context/WithDriver.ts @@ -8,8 +8,7 @@ export default function() { beforeEach(function() { const driver = new SeleniumWebdriver.Builder() .forBrowser("chrome") - .setChromeOptions( - new chrome.Options().headless()) + .setChromeOptions(new chrome.Options().headless()) .build(); this.driver = driver; }); diff --git a/test/suites/complete-config/index.ts b/test/suites/complete-config/index.ts new file mode 100644 index 000000000..277982bed --- /dev/null +++ b/test/suites/complete-config/index.ts @@ -0,0 +1,10 @@ +import AutheliaSuite from "../../helpers/context/AutheliaSuite"; +import MongoConnectionRecovery from "./scenarii/MongoConnectionRecovery"; +import EnforceInternalRedirectionsOnly from "./scenarii/EnforceInternalRedirectionsOnly"; + +AutheliaSuite('Complete configuration', 'config.template.yml', function() { + this.timeout(10000); + + describe('Mongo broken connection recovery', MongoConnectionRecovery); + describe('Enforce internal redirections only', EnforceInternalRedirectionsOnly); +}); \ No newline at end of file diff --git a/test/suites/complete-config/scenarii/EnforceInternalRedirectionsOnly.ts b/test/suites/complete-config/scenarii/EnforceInternalRedirectionsOnly.ts new file mode 100644 index 000000000..b1d7cbbc1 --- /dev/null +++ b/test/suites/complete-config/scenarii/EnforceInternalRedirectionsOnly.ts @@ -0,0 +1,63 @@ +import LoginAndRegisterTotp from "../../../helpers/LoginAndRegisterTotp"; +import VisitPage from "../../../helpers/VisitPage"; +import FillLoginPageWithUserAndPasswordAndClick from '../../../helpers/FillLoginPageAndClick'; +import ValidateTotp from "../../../helpers/ValidateTotp"; +import Logout from "../../../helpers/Logout"; +import WaitRedirected from "../../../helpers/WaitRedirected"; +import IsAlreadyAuthenticatedStage from "../../../helpers/IsAlreadyAuthenticatedStage"; + +/* + * Authelia should not be vulnerable to open redirection. Otherwise it would aid an + * attacker in conducting a phishing attack. + * + * To avoid the issue, Authelia's client scans the URL and prevent any redirection if + * the URL is pointing to an external domain. + */ +export default function() { + describe("Only redirection to a subdomain of the protected domain should be allowed", function() { + this.timeout(10000); + let secret: string; + + beforeEach(async function() { + secret = await LoginAndRegisterTotp(this.driver, "john", true) + }); + + afterEach(async function() { + await Logout(this.driver); + }) + + function CannotRedirectTo(url: string) { + it(`should redirect to already authenticated page when requesting ${url}`, async function() { + await VisitPage(this.driver, `https://login.example.com:8080/?rd=${url}`); + await FillLoginPageWithUserAndPasswordAndClick(this.driver, 'john', 'password'); + await ValidateTotp(this.driver, secret); + await IsAlreadyAuthenticatedStage(this.driver); + }); + } + + function CanRedirectTo(url: string) { + it(`should redirect to ${url}`, async function() { + await VisitPage(this.driver, `https://login.example.com:8080/?rd=${url}`); + await FillLoginPageWithUserAndPasswordAndClick(this.driver, 'john', 'password'); + await ValidateTotp(this.driver, secret); + await WaitRedirected(this.driver, url); + }); + } + + describe('blocked redirection', function() { + // Do not redirect to another domain than example.com + CannotRedirectTo("https://www.google.fr"); + + // Do not redirect to rogue domain + CannotRedirectTo("https://public.example.com.a:8080"); + + // Do not redirect to http website + CannotRedirectTo("http://public.example.com:8080"); + }); + + describe('allowed redirection', function() { + // Can redirect to any subdomain of the domain protected by Authelia. + CanRedirectTo("https://public.example.com:8080/"); + }); + }); +} \ No newline at end of file diff --git a/test/suites/complete-config/scenarii/MongoConnectionRecovery.ts b/test/suites/complete-config/scenarii/MongoConnectionRecovery.ts new file mode 100644 index 000000000..1064f00c9 --- /dev/null +++ b/test/suites/complete-config/scenarii/MongoConnectionRecovery.ts @@ -0,0 +1,12 @@ +import LoginAndRegisterTotp from "../../../helpers/LoginAndRegisterTotp"; +import FullLogin from "../../../helpers/FullLogin"; +import child_process from 'child_process'; + +export default function() { + it("should be able to login after mongo restarts", async function() { + this.timeout(30000); + const secret = await LoginAndRegisterTotp(this.driver, "john", true); + child_process.execSync("./scripts/dc-dev.sh restart mongo"); + await FullLogin(this.driver, "https://admin.example.com:8080/secret.html", "john", secret); + }); +} \ No newline at end of file diff --git a/test/suites/minimal-config/index.ts b/test/suites/minimal-config/index.ts index cb9ff4f15..ffe7649c3 100644 --- a/test/suites/minimal-config/index.ts +++ b/test/suites/minimal-config/index.ts @@ -9,7 +9,7 @@ import TOTPValidation from './scenarii/TOTPValidation'; const execAsync = Bluebird.promisify(ChildProcess.exec); -AutheliaSuite('Minimal configuration', function() { +AutheliaSuite('Minimal configuration', 'config.minimal.yml', function() { this.timeout(10000); beforeEach(function() { return execAsync("cp users_database.example.yml users_database.yml");