Я доверил браузеру считать прогресс — и любой ученик мог выписать себе сертификат
Совсем недавно я начал делать бесплатную образовательную платформу: курсы с заданиями, которые проверяются автоматически, без оплат и «первого урока в подарок». Сейчас там больше двадцати курсов и пять тысяч заданий, ими пользуются живые люди, а платформа выдаёт именные сертификаты с кодом проверки подлинности.
Недавно я провёл аудит собственного кода — и нашёл дыру, из-за которой сертификат можно было получить одним запросом из консоли браузера, не решив ни единой задачи. Ошибка оказалась не в проверке прав и не в валидации: она была в архитектурном решении, которое я принял осознанно и считал удачным. Об этом и хочу рассказать — вместе с двумя другими решениями, которые, наоборот, себя оправдали.
Как всё было устроено
Платформа состоит из хаба (список курсов, прогресс, кабинет) и самих курсов. Курсы очень разные: в одном человек пишет Python, в другом собирает программу из блоков, в третьем двигает фигуры на шахматной доске. Задания тоже разные — выбор ответа, соответствия, свободный текст, код.
Когда я это начинал, ключевым было желание не переписывать сервер каждый раз, когда добавляется новый курс. Отсюда родилось решение, которое казалось мне красивым:
Сервер не знает содержимого курсов. Курс сам считает свой прогресс и присылает результат.
Выглядело это так. Курс собирает «скелет» — список разделов, уроков и набранных баллов, — и отправляет его вместе с данными:
{
"summary": [
{ "id": "s1", "hasQuiz": true, "quizBest": 80,
"lessons": [ { "id": "l1-1", "score": 100 },
{ "id": "l1-2", "score": 40 } ] }
]
}
Сервер получает это, считает средний процент по формуле, общей для всей платформы, и сохраняет. Добавление курса не требовало ни строчки серверного кода — достаточно положить папку с данными. Три года такой архитектуры в вебе называют «тонкий сервер», и в задачах вроде синхронизации заметок она работает прекрасно.
В комментарии к этому коду у меня было написано:
/* Процент считаем сами по присланному скелету: клиент не может нарисовать себе 100%, не решив задачи. */
Комментарий врал. Причём врал ровно наполовину, и это худший вид неправды в коде.
Где именно дыра
Сервер действительно не брал процент из запроса — он считал его сам. Но считал он его по данным, которые прислал тот же самый браузер. Скелет курса — это тоже данные из запроса.
То есть достаточно было отправить скелет из одного раздела с одним уроком:
fetch("/api/progress/python", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
track: "start:21",
summary: [{ id: "x", lessons: [{ id: "y", score: 100 }] }]
})
});
Средний балл по присланному скелету — сто процентов. Сервер честно посчитал, честно сохранил. Дальше человек открывает страницу сертификата, сервер видит percent >= 100, подписывает код HMAC своим секретом и выдаёт настоящий PDF, который проходит публичную проверку подлинности на сайте.
Отдельная ирония: слияние прогресса, написанное как раз для честного случая (две открытые вкладки не должны затирать достижения друг друга), помогало и здесь — форма скелета задавалась входящими данными, поэтому список разделов можно было не только подделать, но и сократить, чтобы процент подскочил.
Тем же способом накручивался опыт платформы. Он считался по количеству ключей в объекте решённых заданий:
for (const lesson of Object.values(data.lessons || {})) {
tasks += Object.keys(lesson.tasks || {}).length;
}
Тысяча выдуманных уроков по тысяче выдуманных заданий в каждом — и человек мгновенно получал высшее звание платформы. Комментарий над этим кодом гласил: «его нельзя накрутить с клиента — сервер берёт только то, что реально сохранено». Формально верно. Фактически «сохранено» означало «прислано».
Почему это не ловится ревью
Дыра прожила год, и я хочу быть точным в причине. Дело не в невнимательности: каждая отдельная строчка тут правильная.
Права проверяются — маршрут требует авторизации.
Значение из запроса не берётся — процент вычисляется на сервере.
Данные нормализуются — числа обрезаются в диапазон 0–100, строки приводятся к строкам.
Формула прогресса одна на всю платформу, чтобы цифры нигде не разъезжались.
Все проверки на месте. Не хватало одной-единственной вещи: источника правды о том, из чего состоит курс. Сервер спрашивал об этом браузер, а браузер — это не собеседник, а канал, по которому приходят чужие данные.
Формулирую то, чему меня это научило, максимально коротко:
Клиент может присылать факты о себе («я решил вот это задание»). Он не должен присылать правила, по которым эти факты интерпретируются («а всего заданий было одно»).
Мне кажется, эту границу легко потерять именно в «тонком сервере». Когда сознательно решаешь, что сервер не знает предметную область, очень трудно потом заметить, что часть предметной области ему всё-таки нужна — не вся, а ровно та, что участвует в проверках.
Как я это чинил
Сервер должен был узнать состав курсов, но так, чтобы не потерять исходное преимущество: добавление курса по-прежнему не должно требовать правок бэкенда.
Данные курсов лежат обычными js-файлами рядом с самим курсом — их читает браузер. Значит, их может прочитать и сервер: тем же кодом, в песочнице node:vm, без выполнения чего-либо опасного (там только объявления данных).
const sandbox = { document: { addEventListener() {} }, console };
vm.createContext(sandbox);
for (const file of dataFiles) vm.runInContext(fs.readFileSync(file, "utf8"), sandbox);
const data = sandbox.COURSE_DATA;
Дальше сервер прогоняет эти данные через тот же самый модуль, что и браузер (shared/tracks.js подключается и там, и там), получает эталонный состав ветки и кэширует его — состав меняется только вместе с выкладкой.
Присланный скелет теперь не принимается, а прикладывается к эталонному: лишние разделы отбрасываются, недостающие уроки добавляются нулями.
function alignToSkeleton(summary, truth) {
const bySection = new Map(summary.map((s) => [s.id, s]));
return truth.map((sec) => {
const got = bySection.get(sec.id);
const byLesson = new Map((got ? got.lessons : []).map((l) => [l.id, l]));
return {
id: sec.id,
hasQuiz: sec.hasQuiz,
quizBest: got ? clampPct(got.quizBest) : 0,
lessons: sec.lessons.map((l) => ({
id: l.id,
score: clampPct((byLesson.get(l.id) || {}).score),
})),
};
});
}
Проверка после правки: тот же запрос из консоли даёт ноль процентов вместо ста. Десять тысяч выдуманных заданий дают ноль опыта и звание «Новичок» вместо «Легенды».
Побочный эффект, о котором стоит предупредить, если будете делать так же: у части людей проценты изменятся. У меня из семнадцати записей поменялись две — одна выросла, другая уменьшилась (17% оказались честными 11%). Я не стал «замораживать» завышенные значения, потому что на проценте висит выдача сертификата, и заморозка вернула бы ту же дыру с другой стороны. Вместо этого написал разовый пересчёт и применил его сразу, чтобы цифра не менялась у человека посреди занятия.
Второй заход на те же грабли — в новой фиче
Самое неприятное открытие: пока я закрывал эту дыру, у меня уже была написана новая функция с ровно той же ошибкой.
Это тренажёр — режим бесконечной практики: человеку подбираются задачи по темам, за верные ответы растёт «уверенность» по каждой теме, копятся очки и трофеи. Клиент проверял ответ сам и отправлял вердикт:
Я написал это буквально за неделю до аудита — и написал так, потому что «проверка ответа же на клиенте, движок уроков всегда так делал». В уроках это некритично: там прогресс всё равно упирается в состав курса. В тренажёре — критично: очки идут в общий опыт платформы.
Теперь вердикт выносит сервер, а клиент присылает сам ответ:
if (kind === "multi") {
const got = [...new Set(answer.map(Number))].sort((a, b) => a - b);
const need = [...(t.answers || [])].map(Number).sort((a, b) => a - b);
return got.length === need.length && got.every((v, i) => v === need[i]);
}
И — что не менее важно — правильные ответы больше не уходят клиенту вовсе. Иначе серверная проверка обходится за две секунды: подсмотреть ответ в ответе запроса и отправить его же.
delete t.answer;
delete t.answers;
if (Array.isArray(t.pairs)) t.pairs = t.pairs.map((p) => ({ left: p.left }));
Разбор ошибки («верный ответ был такой») приходит вместе с вердиктом — то есть уже после того, как человек ответил.
Что, наоборот, себя оправдало
Чтобы рассказ не выглядел как сплошное покаяние, два решения, которые за год ни разу не подвели.
Курс сам режется под возраст ученика. У каждого курса есть ветки: «с нуля» и «подтянуть знания», для 6–14, 15–20, 21+ и так далее. Разница не в том, что детям показывают меньше — а в том, что урок должен помещаться в академический час этого возраста: тридцать минут для младших, сорок пять для взрослых. Поэтому задания отбираются сначала по сложности, потом по времени — пока урок не набрал свой бюджет:
function fitToBudget(parts, budget) { /* берём задания, пока влезают в минуты */ }
Излишки остаются в курсе и достаются другим веткам. Практическое следствие: задания надо писать с запасом, и это не расточительство — один и тот же урок даёт полный час и восьмилетке, и взрослому, просто из разных наборов.
Проверка кода без бэкенда. Python выполняется в браузере через Pyodide, JavaScript — нативно, SQL — через sqlite, собранный в WebAssembly. Сервера для запуска пользовательского кода нет вовсе, а значит нет и целого класса проблем: песочниц, лимитов, очередей, кода, который майнит.
Единственное место, где я от этого отступил, — тренажёр SQL для незарегистрированных: там запрос выполняется на сервере, чтобы страница работала без загрузки многомегабайтного WebAssembly. И этот компромисс немедленно потребовал того, чего не требовало браузерное исполнение: ограничения частоты и отсева тяжёлых запросов, потому что синхронный DatabaseSync на время работы держит весь процесс, а SELECT COUNT(*) FROM t,t,t,t,t пишется одной строкой.
Что осталось в голове после аудита
Три вещи, которые я теперь проверяю в чужом и своём коде первым делом.
Найти все места, где сервер принимает от клиента не факты, а структуру. Список того, из чего состоит сущность; количество элементов; набор допустимых значений. Это и есть предметная область, и она должна жить на сервере, даже если очень хочется «тонкий сервер».
Прочитать комментарии как утверждения и проверить каждое. У меня оба комментария («нельзя нарисовать себе 100%», «нельзя накрутить с клиента») были написаны честно и оба оказались ложными после того, как код рядом чуть изменился. Комментарий, обещающий безопасность, — это тест, который никто не запускает.
Проверять новые фичи тем же списком, что и старые. Дыра в тренажёре появилась не потому, что я о ней не знал, — я закрывал такую же в соседнем файле. Она появилась потому, что новый код писался по образцу старого.
Платформа бесплатная и остаётся бесплатной; если интересно посмотреть, как выглядит описанное — ссылка в профиле. Буду рад, если кто-то из читателей найдёт ещё что-нибудь: об ошибках в собственном коде приятнее узнавать от людей, чем от школьника, которому очень нужен сертификат.










