Перехватываем запросы из Flutter приложения или как мы настраиваем Charles Proxy для тестировщиков


Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead в Amiga, а также соавтор книги Основы Flutter. В данной статье рассмотрим, как помочь тестировщикам в поиске багов, связанных с запросами на сервер.
Для этого используется специальное ПО, например, Charles. Это прокси, который умеет перехватывать трафик, подменять ответы и т. д. Только вот незадача - запросы из Flutter он перехватывать не умеет.
Из данной статьи вы узнаете:
Для чего тестировщикам использовать Charles Proxy?
Как подключить его к Flutter-приложению?
Какие подводные камни есть в таком решении?
Немного теории
Давайте сначала разберемся с тем, как работает сниффер и поймем, почему запросы из Flutter ему не удается перехватить.
Что такое сниффер и зачем он тестировщикам?
Сниффер - специализированное ПО, которое выступает посредником между приложением и backend-сервером и помогает просматривать запросы к API. Для тестирования часто используется Charles, который позволяет не только просматривать запросы, но и обрабатывать их, увеличивая задержку между пакетами или подменяя данные.
Давайте теперь посмотрим, что можно делать с использованием Charles Proxy.
Анализ трафика и данных
Не всегда тестировщикам удобно работать с логами, которые выдает приложение. В таких случаях использование Charles Proxy помогает увидеть все запросы и их данные, а также удобнее просматривать их.
Это касается не только того, в какое время был отправлен запрос, на какой endpoint и какой статус у него вернулся, но и того, идут ли дублирующие запросы, хватает ли там данных и т. п.
Например, запрос уходит, но на него возвращается ошибка из-за того, что приложение не добавило токен авторизации в заголовок, или отправились не все данные.

Подмена данных
Как уже было сказано выше, Charles Proxy позволяет подменить данные в ответе от сервера. Это нужно в 2 случаях - протестировать как приложение реагирует на определенные ошибки, которые возвращает сервер, или правильно ли приложение десериализует данные из ответа.
Особый пример - если backend еще только в процессе разработки и сделан на моках. Таким образом тестировщик эмулирует другие ответы от сервера, чтобы проверить как работает приложение.
Замедление соединения
При помощи функции Throttling тестировщик может гибко эмулировать низкую скорость соединения или нестабильность сети. Это необходимо для проверки того, как в таких случаях отрабатывает приложение.
Да, скорость можно ограничить и другими способами, но через Charles мы можем лучше протестировать эти моменты.
Как работает сеть в Flutter и почему базовой настройки недостаточно
Flutter-разработчики обычно в приложениях настраивают работу с сетью очень просто - используя готовые решения по типу библиотек dio или http и либо сразу отправляя запросы, либо добавляя какие-либо Middleware. Например, для логирования или для подстановки и обновления токенов авторизации.
Дело в том, что такие запросы идут напрямую в сеть, минуя прокси, который настроен через Wi-Fi устройства. Поэтому для настройки именно под Charles нам нужно будет добавить сам прокси к нашему коду на Flutter.
Вы можете спросить, а почему тогда отдельные прокси-клиенты работают с приложением, а такой прокси нет? Тут все максимально просто - они встраиваются немного другим образом и находятся перед выходом трафика с устройства в сеть, а вот прокси от Charles настраивается для определенного Wi-Fi и может не работать. Даже сами разработчики Android пишут о том, что эта настройка работает только в браузере.
Поэтому предлагаю рассмотреть, как мы готовим приложение и устройство к перехвату запросов через Charles Proxy.
Готовим приложение к перехвату
Подключаем устройство к прокси

Первое, что мы делаем для настройки снифферов, это устанавливаем прокси и подключаем к нему устройство. Без этого ничего не будет работать.
Это нам нужно будет для дальнейших шагов по подключению именно нашего приложения.
Находим IP-адрес прокси
Самое быстрое - установить и запустить Charles Proxy. Он сразу начнет работать на всю вашу локальную сеть на 8888 порту компьютера, на котором запущен. Для того, чтобы подключить к нему устройства нужно сначала узнать IP-адрес компьютера в локальной сети. Делается это так:
Для Windows:
ipconfigДля macOS:
ipconfig getifaddr en0Тут нам нужен именно en0, так как он выведет IP-адрес для Wi-Fi подключений
Для Linux:
ip aили для более старых версий:
ifconfigВ итоге получаем IP-адрес в виде 192.168.X.X, к которому и будет вести наш прокси. Поэтому мы знаем, что прокси у нас развернут на 192.168.X.X:8888.
Добавляем настройки прокси на Android
Чтобы подключиться к прокси на Android нам нужно быть подключенным к одной сети Wi-Fi с компьютером.
Далее идем в настройки Сеть и Интернет => Интернет => Wi-Fi => Подключенная сеть => Редактировать. У нас появится диалоговое окно с вводом пароля и расширенными настройками, в которых нам и предстоит добавить прокси.
Выбираем “Настройка вручную” у прокси сервера и вводим IP-адрес в поле имя хоста прокси-сервера, а также 8888 в поле порт прокси-сервера.
После чего наше устройство считается подключенным к прокси и в Charles можно будет увидеть диалоговое окно, что устройство с таким-то IP хочет к нам подключиться, его нужно принять.
Для того, чтобы отключить прокси, нужно будет вместо “Настройка вручную” выставить “Нет”. Но при повторном подключении нужно будет вводить IP-адрес и порт снова.
Добавляем настройки прокси на iOS
На устройствах от Apple все максимально похоже. Также нужно быть подключенным к одной сети.
Далее идем в настройки Wi-Fi => Подключенная сеть => Настройка прокси. Выбираем “Вручную”, вводим IP-адрес в Сервер и 8888 в Порт, нажимаем Сохранить. После этого в Charles Proxy также всплывет диалоговое окно о подключении именно этого устройства.
Для отключения просто переключаем с “Вручную” на “Выкл.”. В отличие от Android, тут все настройки сохранятся.
Добавляем SSL-сертификат

Как мы уже выяснили, для расшифровки запроса нам потребуется SSL-сертификат. Сделать так, чтобы это работало с нашим приложением можно двумя способами:
Добавить сертификат сразу на устройство;
Добавить сертификат в нативную часть приложения.
У каждого из этих способов есть свои плюсы и минусы. Давайте для начала с ними ознакомимся.
Добавление сертификата сразу на устройство
Плюсы:
Делается один раз для всех приложений;
Не нужно добавлять код к приложению и следить, чтобы сертификат не попал в релизные сборки.
Минусы:
Нужно делать на каждом устройстве, даже если у вас 10 тестировщиков и по 5 устройств у каждого;
Нарушает безопасность устройства, так как и запросы сторонних приложений попадут под перехват.
Добавление сертификата в нативную часть приложения
Плюсы:
Делается один раз для всех устройств;
Можно настроить только под debug-режим или под определенный Flavor;
Работает только с вашим приложением.
Минусы:
Работает только с Android.
Где взять сертификат для Charles Proxy?
Для настройки вы можете попросить сертификат в виде crt или pem у вашего тестировщика либо скачать его сами на http://chls.pro/ssl, когда устройство подключено к прокси.
Еще один вариант - получить его из приложения Charles. Идем Help => SSL Proxying => Save Charles Root Certificate. После чего отправляем его себе на устройство. Отсюда же можно поставить и на iOS симуляторы.
Добавляем сертификат на устройство
Для того, чтобы наш сертификат заработал, его нужно установить. Для этого способа потребуется именно crt, а не pem.
Как мы уже поняли, это единственный вариант под iOS, поэтому начнем с него. Самый лучший вариант - подключиться к прокси и перейти на http://chls.pro/ssl в сафари или любом другом браузере. После скачивания система сама предложит его установить.
Для Android все немного сложнее. Во-первых, официальная документация Charles Proxy не рекомендует такой способ. Во-вторых, он немного сложнее и все может не получиться с первого раза. Но если вы хотите воспользоваться именно им, то вот что нужно сделать:
Сначала скачиваем сертификат на устройство. Тут отличий от iOS будет немного, подключаемся к прокси и заходим на сайт.
Далее можно попробовать запустить файл для его установки, но получается это не на всех устройствах. Если таким образом сертификат не поставился, то идем через настройки. Лучший способ ввести в поиске по настройкам “Установка сертификатов”, поиск найдет “Сертификат пользователя приложений и VPN”, там выбираем то же самое и нам предложат выбрать уже сам файл.
Добавляем сертификат в нативный Android
Так как прошлый способ не рекомендуется официальной документацией Charles Proxy, применим для этого второй - добавление в само приложение.
Сначала сам сертификат добавляем в ресурсы нашего приложения под Android. Делать это нужно в папку android/app/src/.../res/raw/. Вместо пропуска режим запуска либо нужный нам Flavor. Например, мы часто настраиваем для debug-режима и поэтому полный путь будет таким - android/app/src/debug/res/raw/.
Далее нужно сказать нативной части, что этот сертификат нужно использовать. Для этого создается файл res/xml/network_security_config.xml во всех Flavor и всех режимах запуска, даже если не будем туда добавлять сертификат. Это делается из-за следующего шага, так как иначе наше приложение перестанет собираться.
Поэтому у нас будет 2 вида контента под этот файл.
Первый, где мы не добавляем сертификат:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src=”system” />
</trust-anchors>
</base-config>
</network-security-config>Если смотреть правде в глаза, то мы ничего и не трогаем, просто явно задаем то, что уже изначально задается системой.
Второй, для добавления сертификата:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src=”system” />
<certificates src=”@raw/charles” />
</trust-anchors>
</base-config>
</network-security-config>Вся разница с первым вариантом - одна строка, в которой мы подставили наш сертификат для использования вместе с системными.
Теперь у нас есть добавленный SSL-сертификат и файл настроек безопасности сети. Осталось их полноценно подцепить к приложению. Это делаем в AndroidManifest.xml. Чтобы не копировать в каждый flavor и режим объект application, мы и создавали network_security_config.xml в каждом из них, а саму настройку применяем в основном, который находится в android/app/src/main/AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest ... >
<application
android:networkSecurityConfig="@xml/network_security_config"
...
>
...
</application>
</manifest>Настраиваем прокси в Flutter
Теперь перейдем уже к самому приложению. Тут нам тоже нужно будет делать определенные настройки, так как весь сетевой трафик из приложения игнорирует прокси устройства и Wi-Fi сети.
Мы экспериментировали со способами подключения. Сначала, заменяли ClientAdapter со стандартного IOHttpClientAdapter на NativeAdapter из native_dio_adapter. Это частично помогало, но часто ломалось. В итоге, мы пришли к более стандартному способу без необходимости ставить отдельный пакет.
Этот способ подойдёт не только для dio, но и для пакета http. С chopper и retrofit он тоже сработает: под капотом они используют те же http и dio соответственно.

Создаем новый HttpClient
Как для http, так и для Dio нам понадобится создать новый HttpClient и настроить у него подключение к прокси.
HttpClient getProxiedClient() {
final client = HttpClient();
client.findProxy = (uri) => ‘PROXY 192.168.X.X:8888’;
client.badCertificateCallback = (
X509Certificate cert,
String host,
int port,
) => true;
return client;
}В функции findProxy мы задаем сам прокси, к которому идет подключение, а в параметре badCertificateCallback игнорируем любые ошибки, связанные с SSL-сертификатом.
Если мы используем пакет http или chopper, то этого нам должно быть достаточно. А вот для Dio, нужно обернуть это в новый IOHttpClientAdapter.
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: getProxiedClient,
);Уже на этом этапе мы можем работать со снифферами, но чтобы усовершенствовать это, нужно убрать привязку к определенному прокси.
Убираем привязку к определенному прокси
Базовая настройка завершена, но приложение будет работать на определенных устройствах и в определенной сети и пока у компьютера не сменится IP-адрес. Поэтому лучше будет, чтобы мы сами или тестировщик задавали прокси для приложения. Для этого есть 2 метода:
Забивать IP-адрес и порт в переменные окружения;
Дать тестировщику возможность самому ввести прокси и включать или отключать его.
Вводим прокси при сборке приложения
Давайте рассмотрим первый вариант поподробнее.
В Flutter у нас уже есть способ задавать переменные окружения при запуске приложения. Как через командную строку, так и через файл. Поэтому используем этот метод, так как он достаточно надежен, не требует дополнительной библиотеки, как в случае с использованием файла .env, и не добавляет дополнительный файл в сборку.
Начнем с простого использования командной строки, так как сейчас у нас мало данных:
flutter run --dart-define=PROXY_ADDRESS=192.168.X.X --dart-define=PROXY_PORT=8888Если у вас уже используются другие переменные окружения, например, ключи для различных сервисов, то сначала подготовим файл. Dart умеет подцеплять из JSON, который человекочитаемый, поэтому используем его. Создаем файл env.json в корне проекта:
{
“PROXY_ADDRESS”: “192.168.X.X”,
“PROXY_PORT”: “8888”
}И теперь передадим это все в команду запуска:
flutter run --dart-define-from-file=env.jsonНе забудьте добавить этот файл в .gitignore. А также те же самые параметры можно использовать и для команды flutter build.
Теперь модернизируем наш метод для получения HttpClient, чтобы он опирался уже на эти переменные и только если обе переменные заданы.
HttpClient getProxiedClient() {
final client = HttpClient();
final proxyAddress = String.fromEnvironment(‘PROXY_ADDRESS’);
final proxyPort = String.fromEnvironment(‘PROXY_PORT’);
if (proxyAddress.isEmpty || proxyPort.isEmpty) {
client.findProxy = (uri) => ‘PROXY $proxyAddress:$proxyPort’;
client.badCertificateCallback = (
X509Certificate cert,
String host,
int port,
) => true;
}
return client;
}Теперь при каждом запуске приложение будет подключаться к прокси-серверу, параметры которого указаны в переменных окружения. Но это все равно не всегда удобно, так как придется делать несколько сборок под разных тестировщиков и собирать у них IP перед сборкой.
Даем возможность самостоятельного включения прокси тестировщику
По причине, которую мы обозначили выше, самым надежным способом будет дать тестировщику самому вводить IP-адрес и порт для прокси и сохранять это где-то в постоянной памяти.
Как это работает:
HttpClientпри запуске приложения подцепляет 3 переменные из любого удобного вам хранилища - включено ли прокси, сами IP-адрес и порт;По ним происходит подключение если проксирование включено, а IP-адрес и порт не нулевые и не пустые;
На отдельном специальном экране тестировщик вводит эти значения, после чего перезапускает приложение.
Мы у себя на проектах реализуем такое через пакет shared_preferences. Кто-то скажет, что это небезопасно, так как нет шифрования. Ответим, что такие сборки у нас получают только тестировщики и все это настраивается на их устройствах, поэтому даже достав IP-адрес из хранилища, злоумышленник не особо что-то сможет с этим сделать.
Настраиваем все для использования только в целях разработки
Последнее, о чем стоит позаботиться, так это чтобы никто не перехватывал запросы, сделанные приложением в релизных сборках.
Почему это важно? Charles Proxy может показывать абсолютно все данные в запросе, в том числе токены и другие данные, а также его использование это как выполнение угрозы man in the middle в целях тестирования. Чтобы такого не происходило, кода, который связан с прокси не должно быть в тех версиях, которые идут конечным пользователям и не используются внутри.
Я уже говорил про это, когда подключали SSL-сертификат. Как раз из-за этого мы и подставляем все либо для debug-режима, либо для определенного Flavor.

Давайте в последний раз модернизируем наш HttpClient, чтобы прокси можно было использовать только в debug-режиме:
HttpClient getProxiedClient() {
final client = HttpClient();
if (kDebugMode) {
final proxyAddress = String.fromEnvironment(‘PROXY_ADDRESS’);
final proxyPort = String.fromEnvironment(‘PROXY_PORT’);
if (proxyAddress.isEmpty || proxyPort.isEmpty) {
client.findProxy = (uri) => ‘PROXY $proxyAddress:$proxyPort’;
client.badCertificateCallback = (
X509Certificate cert,
String host,
int port,
) => true;
}
}
return client;
}Заключение
В данной статье мы разобрали, как используются снифферы и как настраивается приложение на Flutter под использование с ними, а также вывели несколько важных правил, на которые мы опираемся:
Отделяем код, который необходим для использования с прокси, от основного кода приложения, чтобы предотвратить угрозы;
Даем возможность тестировщику самому включить или выключить прокси внутри мобильного приложения.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.