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 застревать из‑за ложноположительного паттерна загрязнения.