2025, Sep 24 21:17
Как снять флаг SSRF в SAST: безопасное построение URL с UUID в Python
Почему статанализ (Checkmarx) помечает UUID в URL как SSRF, и два безопасных способа собрать запрос в Python без изменения логики и с зелёным CI/CD в пайплайне
Инструменты статического анализа вроде Checkmarx хорошо выявляют опасные потоки загрязнённых данных и остановят конвейер CI/CD, как только пользовательский ввод подставляется в URL. Типичный случай — формирование пути запроса с якобы безобидным идентификатором. Даже после строгих проверок UUID прямое размещение переменной в пути URL может пометиться как потенциальный SSRF. Ниже — минимальный, встречающийся на практике шаблон, который вызывает такое срабатывание, и способ устранить его без изменения поведения приложения.
Постановка задачи
Приложение обращается к внутреннему REST‑эндпоинту, чтобы получить файл по его UUIDv4. Имя хоста берётся из конфигурации; идентификатор строго валидируется, после чего используется для построения URL. Несмотря на проверки, прямая интерполяция строки отмечается на этапе CI/CD как риск SSRF.
import os
import re
import uuid
import validators
import requests
UUID_V4_PATTERN = r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
def grab_document(doc_key):
    if not re.match(UUID_V4_PATTERN, doc_key):
        raise ValueError('invalid file_id')
    if not validators.uuid(doc_key):
        raise ValueError('invalid file_id')
    try:
        doc_key = str(uuid.UUID(doc_key))
    except Exception:
        raise ValueError('invalid file_id')
    svc_host = os.getenv('HOSTNAME', None)
    if svc_host is None:
        raise ValueError('invalid file_id')
    endpoint = f'https://{svc_host}/v1/files/{doc_key}'
    resp = requests.get(endpoint)
    if resp.status_code == 200:
        pass
Почему это помечается
С точки зрения сканера, контролируемое пользователем значение попадает в строку URL, которая затем используется в HTTP‑запросе. Такой прямой поток — классический паттерн SSRF. Хотя код и проверяет, что значение — UUIDv4, сам способ формирования пути остаётся «приёмником загрязнения». Проблема не в формате UUID, а в подстановке внешних данных в URL запроса.
Два безопасных способа построения
Есть два простых способа перестроить код так, чтобы оборвать поток загрязнения и формировать запрос контролируемо. Оба сохраняют прежнюю логику проверок и поведение, и в обоих избегается «сырой» конкатенации пользовательского значения в URL.
Первый подход использует явные примитивы для построения URL, которые корректно кодируют и объединяют сегменты пути.
import os
import re
import uuid
import validators
import requests
from urllib.parse import urljoin, quote
UUID_V4_PATTERN = r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
def fetch_asset(asset_uuid):
    if not re.match(UUID_V4_PATTERN, asset_uuid):
        raise ValueError('invalid file_id')
    if not validators.uuid(asset_uuid):
        raise ValueError('invalid file_id')
    try:
        normalized_uuid = str(uuid.UUID(asset_uuid))
    except Exception:
        raise ValueError('invalid file_id')
    cfg_host = os.getenv('HOSTNAME', None)
    if cfg_host is None:
        raise ValueError('invalid hostname')
    base_root = f'https://{cfg_host}/v1/files/'
    encoded_uuid = quote(normalized_uuid, safe='')
    target_url = urljoin(base_root, encoded_uuid)
    resp = requests.get(target_url)
    if resp.status_code == 200:
        return resp
Второй подход задействует requests.Session с составным базовым URL и аккуратным расширением пути. На практике именно этот вариант снял проблему в пайплайне.
import os
import re
import uuid
import validators
import requests
UUID_V4_PATTERN = r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
def retrieve_file(item_id):
    if not re.match(UUID_V4_PATTERN, item_id):
        raise ValueError('invalid file_id')
    if not validators.uuid(item_id):
        raise ValueError('invalid file_id')
    try:
        clean_id = str(uuid.UUID(item_id))
    except Exception:
        raise ValueError('invalid file_id')
    env_host = os.getenv('HOSTNAME', None)
    if env_host is None:
        raise ValueError('invalid hostname')
    api_base = f'https://{env_host}/v1/files'
    sess = requests.Session()
    resp = sess.get(f"{api_base}/{clean_id}")
    if resp.status_code == 200:
        return resp
Что изменилось и почему это работает
В обоих вариантах проверка входных данных не меняется. Отличается лишь способ сборки итогового URL. Явное построение и кодирование URL либо формирование базового эндпоинта с отправкой запроса через Session разрывают прямой поток загрязнения, на который охотятся инструменты статанализа. В результате внешний запрос тот же, но сконструирован без паттерна, вызывающего тревогу.
Зачем это важно
SSRF — высокоприоритетная категория в SAST и может блокировать релизы даже при трафике только внутри контура. Корректируя способ построения URL, вы сохраняете зелёный статус пайплайнов, не ослабляя проверок. Кроме того, намерение кода становится очевиднее: сегмент пути рассматривается как данные, а не как произвольный URL.
Итоги
Проверяйте идентификатор настолько строго, насколько требуется, а затем формируйте URI запроса безопасными примитивами или через Session, а не через прямую интерполяцию строки. Это сохраняет функциональность, снижает воспринимаемый риск SSRF в статанализе и не даёт вашему CI/CD застревать из‑за ложноположительного паттерна загрязнения.
Статья основана на вопросе с StackOverflow от hivegu и ответе Mahrez BenHamad.