Чек-лист по тестированию формы регистрации

Игнорирование 2FA при восстановлении пароля

Многие сервисы выполняют автоматический вход в аккаунт после завершения процедуры восстановления пароля. Так как доступ к аккаунту предоставляется мгновенно, при входе в аккаунт 2FA может миноваться и полностью игнорироваться.

Impact аналогичного репорта на hackerone, который я прислал недавно:

Если злоумышленник получит доступ к электронной почте жертвы (он может взломать учетную запись с помощью фишинга, brute-force атаки, credentials stuffing и тд), он может обойти 2FA, хотя в этом случае 2FA должен защищать учетную запись. На данный момент для 2FA есть проверка кода Google Authenticator или резервного кода, но не кода из электронного письма, поэтому данный Bypass имеет смысл.

Ограничение скорости потоков с отсутствием блокировки после достижения определенной скорости

Зачастую исследователи безопасности пытаются подобрать код с использованием 5-и или более количества потоков, чтобы быстрее выполнить атаку (в Burp Intruder количество потоков по умолчанию- 5 без задержки). Но иногда система безопасности от перебора или обычный Load Balancer может реагировать только на этот единственный фактор.

Если вы пытаетесь брутфорсить с 5-ю потоками, стоит уменьшить количество до 1-го, а потом до 1-го с задержкой в одну секунду. Ранее мне посчастливилось наблюдать за таким поведением и именно с помощью таких манипуляций произошел успешный подбор кода, что привело к Account Takeover.

Если у 2FA кода нет определенного срока действия, то у нас есть много времени на перебор. Если же срок действия присутствует, то успешность атаки уменьшена, но потенциальная опасность уязвимости все равно присутствует, так как шанс попадания в нужный код все же есть.

Отсутствие Rate-лимита

Алгоритм Rate-лимита используется для проверки возможности пользовательского сеанса (или IP-адреса) быть ограниченым в попытках или скорости, и при каких обстоятельствах это происходит. Если пользователь выполнил слишком много запросов в течение определенного промежутка времени, веб приложение может ответить 429 кодом (много запросов) или применить Rate-лимит, не показав при этом ошибок.

Похожее:  Подбор персонала в Липецке, поиск резюме работников в базе Superjob, найти сотрудника

. Манипуляция версиями API

Если вы видите в запросе web приложения что-то вроде /v*/, где *  —  это цифра, то есть вероятность, что можно переключиться на более старую версию API. В старой версии API может быть слабая защита или таковой может вовсе не быть. Это довольно редкое явление и возникает в том случае, если разработчики забыли удалить старую версию API в production/staging среде.

Генерируемый OTP код не изменяется

Это касается не постоянно изменяющихся кодов как в Google Authenticator, а только статичных, которые приходят в SMS, email или личным сообщением в мессенджере.

Суть данного обхода состоит в том, что постоянно или в течении некоторого времени, например, 5 минут, в SMS отправляется один и тот же OTP код, который в течении всего этого времени является валидным. Так же стоить следить за тем, чтобы не произошел silent rate-limit.

Если 2FA крепится к IP-адресу, то можно попытаться его подменить

Чтобы идентифицировать данный метод, войдите в свой аккаунт с помощью функции запоминания 2FA, потом перейдите в другой браузер или incognito режим текущего браузера и попробуйте войти снова. Если 2FA не запрашивается вовсе, значит произошло крепление 2FA к IP-адресу.

Игнорирование 2FA при входе через соцсеть

К аккаунту пользователя можно прикрепить социальную сеть для быстрого входа в аккаунт и одновременно настроить 2FA. При входе в аккаунт через соцсеть, 2FA может игнорироваться. Если email жертвы будет взломан, то можно будет восстановить пароль к аккаунту соцсети (если она позволяет это сделать) и войти на сервис без ввода 2FA.

Impact одного из репортов:

Rate лимит существует, но его можно обойти

Кейсы, которые раньше приходилось встречать:

Сброс rate-limit-a при обновления кода.


В запросе проверки кода, rate-лимит присутствует, но после задействования функционала повторной отправки кода он сбрасывается и позволяет продолжать брутфорс кода.

Примеры репортов:

Обход 2фа с помощью подстановки части запроса из сессии другого аккаунта

Если для верификации кода в запросе отправляется параметр с определенным значением, попробуйте отправить значение с запроса другого аккаунта.

Например, при отправке OTP-кода проверяется ID формы, ID пользователя или cookie, которое связано с отправкой кода. Если применить данные с параметров аккаунта, на котором нужно обойти code-верификацию (Account 1), на сессию совсем другого аккаунта(Account 2), получим код и введем его на втором аккаунте, то сможем обойти защиту на первом аккаунте. После перезагрузки страницы 2FA должна исчезнуть.

Игнорирование 2FA в случае кроссплатформенности

Имплементации 2FA в мобильной или десктопной версии могут отличаться от web версии приложения. 2FA может быть слабее, чем в web версии или вовсе отсутствовать.

7. При отключении 2FA не запрашивается текущий код.

Если при отключении 2FA не запрашивается дополнительное подтверждение, такое как текущий код с google authenticator приложения, код с email/телефона, то в таком случае имеются определенные риски. При чистом запросе существует вероятность CSRF атаки. Если будет найден вектор обхода CSRF защиты (если она есть)

, то 2FA можно будет отключить. Также может использоваться clickjacking уязвимость, — после пары кликов от ничего не подозревающего пользователя 2FA будет отключена. Подтверждение предыдущего кода добавит дополнительную защиту 2FA, учитывая потенциальные CSRF/XSS/Clickjacking атаки, а также CORS misconfigurations.

Обход rate-лимита путем смены IP адреса

Множество блокировок основаны на ограничении приема запросов с IP, который достиг порога определенного количества попыток при выполнении запроса. Если IP-адрес сменить, то есть возможность обойти это ограничение. Для того, чтобы проверить данный способ, просто смените свой IP с помощью Proxy-сервера/VPN и увидите, зависит ли блокировка от IP.

Способы смены IP:

Так как IP rotate тулза отправляет запросы с помощью AWS IP-адресов, все запросы будут блокироваться, если веб приложение находится за CloudFlare фаерволом.

В данном случае нужно дополнительно обнаружить IP оригинального веб сервера или найти способ, не касающийся AWS IP-адресов.

Обход 2FA с помощью «функционала запомининания»

На многих сайтах, поддерживающих 2FA авторизацию, есть функционал «запомнить меня». Он полезен в том случае, когда пользователь не желает вводить 2FA код при последующих входах в аккаунт. Важно идентифицировать способ, с помощью которого 2FA «запоминается». Это может быть cookie, значение в session/local storage или просто крепление 2FA к IP адресу.

На сайте включена поддержка X-Forwarded-For

Встроенный header X-Forwarded-For может использоваться для смены IP. Если в приложение встроена обработка данного хедера, просто отправьте X-Forwarded-For: desired_IP для подмены IP, чтобы обойти ограничение без использования дополнительных прокси. Каждый раз, когда будет отправлен запрос с X-Forwarded-For, веб-сервер будет думать, что наш IP адрес соответствует значению, переданному через хедер.

Материалы на эту тему:

Improper access control баг на странице ввода 2FA

Иногда страница-диалог для ввода 2FA представлена в виде URL с параметрами. Доступ к такой странице с параметрами в URL с cookies, которые не соответствуют тем, которые использовались при генерации страницы или вообще без cookies, —  это не безопасно. Но если разработчики решили принять риски, то нужно пройтись по нескольким важным пунктам:

  1. истекает ли ссылка для ввода 2FA;
  2. индексируется ли ссылка в поисковиках.

Игнорирование 2FA при определенных обстоятельствах

При выполнении некоторых действий, которые приводят к автоматическому входу в аккаунту, 2FA может не запрашиваться.

Отсутствие Rate-limit-а в личном кабинете

2FA может внедряться в различные функционалы личного кабинета пользователя для большей безопасности. Это может быть изменение email адреса, пароля, подтверждение изменения кода для осуществления финансовых операций, etc. Наличие rate-limit-a в личном кабинете может отличаться от наличия rate-limit-a в 2FA при входе в аккаунт.

Если разработчики изначально добавили защиту против несанкционированного изменения данных, то данную защиту нужно поддерживать и исправлять все возможные bypass-ы. Если bypass найден, то это расценивается как уязвимость обхода «security feature», которая была имплементирована разработчиками.

Cypress пишем первые тесты на авторизацию

npm install cypress --save-dev
или yarn
yarn add cypress --dev
cy.intercept('http://example.com/widgets',{ fixture:'widgets.json'})
describe(‘Test describe’, () => {
it(‘should visit login page’, () => {
cy.visit(‘ http://localhost:4200/')
})
})
// cypress.json
{
“baseUrl”: “http://localhost:4200"
}
describe('Test describe', () => {
it('should visit login page', () => {
cy.visit('/login')
cy.getEl('register').click()
cy.url().should('include', '/register')
})
})
cy.getEl(‘register’).click()
Cypress.Commands.add('getEl', name => cy.get(`[data-cy="${name}"]`))
describe('auth', () => {
it('fill all the gaps', () => {
cy.visit('/register')
cy.getEl('firstName').type('Nikolay');
cy.getEl('lastName').type('Kozub');
cy.getEl('username').type('mk');
cy.getEl('password').type('qwerty');
})
})
describe('auth', () => {
it('fill all the gaps', () => {
cy.visit('/register')
cy.getEl('firstName').type('Nikolay');
cy.getEl('lastName').type('Kozub');
cy.getEl('userName').type('mk');
cy.getEl('password').type('qwerty');
cy.getEl('register').click()

cy.getEl('loader').should('be.visible')
cy.getEl('loader').should('not.exist')
cy.url().should('include', '/login');
cy.getEl('alert').should('contains.class', 'alert-success');
cy.getEl('alert').should('contain', 'successful');
})
})

export const user = {
name: 'firstName',
lastName: 'lastName',
userName: 'username',
pass: 'password',
regBtn: 'register',
loader: 'loader'
}

export const alertTab = {
alert: 'alert'
}

import {alertTab, user} from "../support/locators";

describe('User', () => {
it('fill oll the gaps', () => {
cy.visit('/register')
cy.getEl(user.name).type('Nikolay');
cy.getEl(user.lastName).type('Kozub');
cy.getEl(user.userName).type('mk');
cy.getEl(user.pass).type('qwerty');
cy.getEl(user.regBtn).click()

cy.getEl(user.loader).should('be.visible')
cy.getEl(user.loader).should('not.exist')
cy.url().should('include', '/login');
cy.getEl(alertTab.alert).should('contains.class', 'alert-success');
cy.getEl(alertTab.alert).should('contain', 'successful');
})

import {alertTab, user, authLocators} from "../support/locators";

describe('User', () => {
const {loader, regBtn} = authLocators
const {alert} = alertTab

it('fill all the gaps', () => {
cy.visit('/register')
Object.values(user).forEach(el => cy.getEl(el.key).type(el.value))
cy.getEl(regBtn).click()

cy.getEl(loader).should('be.visible')
cy.getEl(loader).should('not.exist')
cy.url().should('include', '/login');
cy.getEl(alert).should('contains.class', 'alert-success');
cy.getEl(alert).should('contain', 'successful');
})
})

{
"baseUrl": "http://localhost:4200",
"username": "mk",
"password": "qwerty"
}
export const user = {
firstName: 'Nikolay',
lastName: 'Kozub',
username: Cypress.config('username'),
password: Cypress.config('password'),
}
Cypress.Commands.add('setItem', (keyName, data) => localStorage.setItem(keyName,
JSON.stringify(data),
)
)
export const loginUserData = (firstName, lastName, password, username,idUser = 1) => ([
{
firstName,
lastName,
username,
password,
"id": idUser
}
])
<input type="text" formControlName="username" data-cy="loginName" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.username.errors }" />
<input type=”password” data-cy=”loginPass” formControlName=”password” class=”form-control” [ngClass]=”{ ‘is-invalid’: submitted && f.password.errors }” />
<button [disabled]="loading" class="btn btn-primary" data-cy="login">Login</button>
<h1 data-cy="userName">Hi {{currentUser.firstName}}!</h1>
export const authLocators = {
regBtn: 'register',
loader: 'loader',
loginBtn: 'login',
loginName: 'loginName',
loginPass: 'loginPass'
}
export const homePage = {
userName: 'userName'
}
import { authLocators, user, homePage } from '../support/locators'
import { loginUserData } from '../fixtures/user'
const { name, lastName, userName, pass } = userdescribe('login', () => {before(() => {
cy.setItem('users', loginUserData(name.value,lastName.value,userName.value, pass.value))
cy.visit('/login')
})

it('should login user page', () => {
cy.getEl(authLocators.loginName).type(Cypress.config('username'))
cy.getEl(authLocators.loginPass).type(Cypress.config('password'))
cy.getEl(authLocators.loginBtn).click()
cy.getEl(homePage.userName).should('contain.text', name.value)
})
})

cy.intercept('http://example.com/settings').as(‘alias’)
cy.wait(‘@alias’)
import { authLocators, user, homePage } from '../support/locators'
import { loginUserData } from '../fixtures/user'

const { name, lastName, userName, pass } = user

describe('login', () => {
before(() => {
cy.setItem('users', loginUserData(name.value, lastName.value, userName.value, pass.value))
cy.visit('/login')
cy.getEl(authLocators.loginName).type(Cypress.config('username'))
cy.getEl(authLocators.loginPass).type(Cypress.config('password'))
cy.intercept('GET', '**/posts').as('alias')
cy.getEl(authLocators.loginBtn).click()
})

it('should login user page', () => {
cy.wait('@alias').then(req => {
const { body } = req.response
const firstTenItems = body.splice(0, 10).map(el => el.title)

firstTenItems.forEach((el, idx) =>
cy.getEl(homePage.listItem)
.eq(idx)
.then(element => expect(element.text()).contains(el)),
)
cy.getEl(homePage.userName).should('contain.text', name.value)
})
})
})

Cypress.Commands.add('login', () => {
cy.request({
method: 'POST',
url: '/login',
body: {
username: Cypress.config('username'),
password: Cypress.config('password')
}
}).then((response) => {
localStorage.setItem('token', response.body);
});
});

Testconfig

Что нам нужно сделать. Нам нужно:
Начнем. Для того чтобы взять Web.Config – нам нужно скопировать его в свою папку. Назовем её Sandbox. Теперь скопируем, поставим на pre-build Event в Project Properties:
Чек-лист по тестированию формы регистрации

xcopy $(SolutionDir)LessonProjectWeb.config $(ProjectDir)Sandbox /y

При каждом запуске билда мы копируем Web.config (и, если надо, то перезаписываем) к себе в Sandbox.Создадим TestConfig.cs и в конструктор будем передавать наш файл (/Tools/TestConfig.cs):

Генерация данных

Кроме всего прочего, мы можем и не удалять базу данных после пробегов теста. (переписать)Я добавлю GenerateData проект в папку Test, но подробно рассматривать мы его не будем, просто чтобы был. Он достаточно тривиальный. Суть его – есть некоторые наименования, и мы используем их для генерации. Например, для генерации фамилии используются фамилии американских президентов (зная их, мы сразу отличаем их от других фамилий, которые скорее будут реальными).

Это также в будущем позволяет избежать «эффекта рыбы», когда в шаблоне тестовые данные были одной определенной, но не максимальной длины и шаблон выглядел прилично, но при использовании реальных данных всё поехало.Создадим 100 пользователей и потом посмотрим на них:

Интегрированное тестирование

Идея будет совершенно безумной, мы будем использовать и проверять уже существующий код в SqlRepository. Для этого мы через Web.config находим базу (она должна располагаться локально), дублировать ее, подключаться к дубликату БД, проходить тесты и в конце, удалять дубликат БД.

Создаем проект LessonProject.IntegrationTest в папке Test.

Добавляем Ninject, Moq и NUnit:

Install-Package Ninject
Install-Package Moq
Install-Package NUnit

Так же создаем папку Sandbox и в Setup наследуем UnitTestSetupFixture (/Setup/IntegrationTestSetupFixture.cs) и функцию по копированию БД:

[SetUpFixture]
    public class IntegrationTestSetupFixture : UnitTestSetupFixture
    {
        public class FileListRestore
        {
            public string LogicalName { get; set; }
            public string Type { get; set; }
        }

        protected static string NameDb = "LessonProject";

        protected static string TestDbName;

        private void CopyDb(StandardKernel kernel, out FileInfo sandboxFile, out string connectionString)
        {
            var config = kernel.Get<IConfig>();
            var db = new DataContext(config.ConnectionStrings("ConnectionString"));

            TestDbName = string.Format("{0}_{1}", NameDb, DateTime.Now.ToString("yyyyMMdd_HHmmss"));

            Console.WriteLine("Create DB = "   TestDbName);
            sandboxFile = new FileInfo(string.Format("{0}\{1}.bak", Sandbox, TestDbName));
            var sandboxDir = new DirectoryInfo(Sandbox);

            //backupFile
            var textBackUp = string.Format(@"-- Backup the database
            BACKUP DATABASE [{0}]
            TO DISK = '{1}'
            WITH COPY_ONLY",
            NameDb, sandboxFile.FullName);
            db.ExecuteCommand(textBackUp);

            var restoreFileList = string.Format("RESTORE FILELISTONLY FROM DISK = '{0}'", sandboxFile.FullName);
            var fileListRestores = db.ExecuteQuery<FileListRestore>(restoreFileList).ToList();
            var logicalDbName = fileListRestores.FirstOrDefault(p => p.Type == "D");
            var logicalLogDbName = fileListRestores.FirstOrDefault(p => p.Type == "L");

            var restoreDb = string.Format("RESTORE DATABASE [{0}] FROM DISK = '{1}' WITH FILE = 1, MOVE N'{2}' TO N'{4}\{0}.mdf', MOVE N'{3}' TO N'{4}\{0}.ldf', NOUNLOAD, STATS = 10", TestDbName, sandboxFile.FullName, logicalDbName.LogicalName, logicalLogDbName.LogicalName, sandboxDir.FullName);
            db.ExecuteCommand(restoreDb);

            connectionString = config.ConnectionStrings("ConnectionString").Replace(NameDb, TestDbName);
        }

    }

По порядку: В строках

            var config = kernel.Get<IConfig>();
            var db = new DataContext(config.ConnectionStrings("ConnectionString"));

— получаем подключение к БД.

TestDbName = string.Format("{0}_{1}", NameDb, DateTime.Now.ToString("yyyyMMdd_HHmmss"));

Создаем наименование тестовой БД.

//backupFile
            var textBackUp = string.Format(@"-- Backup the database
            BACKUP DATABASE [{0}]
            TO DISK = '{1}'
            WITH COPY_ONLY",
            NameDb, sandboxFile.FullName);
            db.ExecuteCommand(textBackUp);

— выполняем бекап БД в папку Sandbox.

            var restoreFileList = string.Format("RESTORE FILELISTONLY FROM DISK = '{0}'", sandboxFile.FullName);
            var fileListRestores = db.ExecuteQuery<FileListRestore>(restoreFileList).ToList();
            var logicalDbName = fileListRestores.FirstOrDefault(p => p.Type == "D");
            var logicalLogDbName = fileListRestores.FirstOrDefault(p => p.Type == "L");

— получаем логическое имя БД и файла логов, используя приведение к классу FIleListRestore.

            var restoreDb = string.Format("RESTORE DATABASE [{0}] FROM DISK = '{1}' WITH FILE = 1, MOVE N'{2}' TO N'{4}\{0}.mdf', MOVE N'{3}' TO N'{4}\{0}.ldf', NOUNLOAD, STATS = 10", TestDbName, sandboxFile.FullName, logicalDbName.LogicalName, logicalLogDbName.LogicalName, sandboxDir.FullName);
            db.ExecuteCommand(restoreDb);

— восстанавливаем БД под другим именем (TestDbName)

 connectionString = config.ConnectionStrings("ConnectionString").Replace(NameDb, TestDbName);

— меняем connectionString.

И теперь можем спокойно проинициализировать IRepository к SqlRepository:

protected override void InitRepository(StandardKernel kernel)
        {
            FileInfo sandboxFile;
            string connectionString;
            CopyDb(kernel, out sandboxFile, out connectionString);
            kernel.Bind<webTemplateDbDataContext>().ToMethod(c =>  new webTemplateDbDataContext(connectionString));
            kernel.Bind<IRepository>().To<SqlRepository>().InTransientScope();
            sandboxFile.Delete();
        }

Итак, у нас есть sandboxFile – это файл бекапа, и connectionString – это новая строка подключения (к дубликату БД). Мы копируем БД, связываем именно с SqlRepository, но базу подсовываем не основную. И с ней можно делать всё что угодно. Файл бекапа базы в конце удаляем.И дописываем уже удаление тестовой БД, после прогона всех тестов:

Как правильно создавать тест-кейсы для формы регистрации?

Если форма заполнена не полностью, то кнопка отправить должна быть неактивна.

Если форма заполнена невалидными данными и/или неполностью – кнопка “Отправить” должна быть неактивна и неверно заполненые поля должны показывать подсказку.

Для каждого поля нужно проверять, что разные ошибочные варианты ввода в это поле распознаются. Переполнение поля тоже.

Если есть необязательные поля, нужно проверить, что их заполнение, незаполнение или неверное заполнение не влияет на результат. Если есть кнопки переключатели (radio buttons) можно проверить выставляется ли значение по умолчанию если должно или не выставляется если не должно. Бывает что выставляется хотя не должно.

Кроме этого навигацию по полям табуляцией можно проверить. Можно проверить, что при перезагрузке страницы введенные в формуляр данные не сбрасываются, если они не должны сбрасываться.
Если поля поддерживают автозаполнение можно проверить и это.
Если формуляр многостраничный – нужно проверить навигацию между страницами, что введенные данные не теряются. Что их можно отредактировать вернувшись назад.

Не думайте о количестве тесткейсов, думайте о том, в чем вы хотите убедиться.

Проверка валидации

Если мы просто вызовем что-то типа:

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *