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.