Роли и владение: когда чужая строка отвечает 404, а когда 403
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Личность у нас уже есть: страж на каждом запросе достал её из токена и перепроверил в таблице. Гостю он ответил 401, и на этом его работа кончилась. Остались два вопроса, и они разные. Что этому человеку можно вообще — вопрос про роль: удалять чужое, смотреть журнал, менять чужие роли. Над чем именно ему можно — вопрос про владение, и ответ на него не лежит ни в токене, ни в таблице пользователей: он зависит от пары «человек и строка». Половина ответа уже написана: одна дверь к строкам решает, какие строки этому читателю видно. Вторая половина — про запись, и условием выборки её не закрыть. Осталось решить, чем отказывать, — и тут выяснится, что даже код ответа это данные: 403 на чужой черновик рассказывает, что такой черновик есть, а перебором номеров из этого складывается карта.
Роль — свойство человека: она лежит в токене, и в прошлой статье её перепроверили в таблице пользователей. По роли решают то, что от строк не зависит вовсе: пускать ли в служебный раздел, отдавать ли журнал, разрешать ли удаление чужих заметок. Проверка такой роли — одно сравнение, и место ей в страже: он работает раньше всех и ничего, кроме личности, не требует.
Владение так не устроено. «Можно ли Бобу править эту заметку» — вопрос про пару: про Боба и про строку номер такой-то. А строки у стража нет. В нашем каркасе страж получает ctx с запросом, параметрами адреса и именами контроллера с обработчиком — номер заметки в нём есть, самой заметки нет. Страж мог бы сходить в базу сам, но тогда строку прочитают дважды: один раз он, второй раз обработчик. Поэтому владение проверяют не в страже, а в сервисе — сразу после того, как строка прочитана.
И ещё одно правило, которое сейчас понадобится целиком: подробный отказ тому, кому нельзя, — это утечка. Там подробностью были поля входа, здесь ею окажется сам код ответа.
Ниже приложение с настоящим стражем роли на удалении: удалять разрешено только роли admin, и модератор для этого добавлен в ту же таблицу пользователей, которую спрашивает страж личности. А правка заголовка написана так, как её пишут в первый день: нашли строку по номеру, записали новый заголовок. Боб — обычный пользователь с честным токеном. Прогноз: какие из его запросов сервер выполнит над чужими заметками?
Страж роли сработал: Бобу удалить не дал, модератору дал. Он вообще не виноват — он ответил на свой вопрос. А правка заголовка ответила 200 на все три запроса, и два из них переписали чужую опубликованную заметку и чужой черновик; ни один сторож при этом не сработал: сторожа для этого и не было. Так выглядит самая частая дыра в API — не «забыли проверку личности», а «личность проверили, владение нет».
Про саму проверку роли стоит заметить ещё одно, мелкое и живучее: сравнивают роль с той, которой можно, а не с той, которой нельзя. Проверка role !== "user" читается как «все, кто не обычный пользователь», — и в день, когда в перечислении появится третья роль, она молча выдаст ей права администратора. Список ролей закрытый, и сравнение должно называть разрешённых по именам.
Роль — свойство человека, владение — свойство пары «человек и строка». Первое лежит в токене, второе — только в самой строке.
Хорошо, проверку владения добавили. Чем отказывать? Первым в голову приходит 403: человек известен, заметка не его — «доступ закрыт». Ответ честный, вежливый и ровно поэтому опасный. 403 говорит не только «нельзя»: он говорит «есть что запрещать». Для одного номера это ничего не значит. Для всех номеров подряд это карта.
Обещание разобрать это шло с части IV: заметка на полях в статье о безопасности действий признавалась, что на чужое и на несуществующее лучше отвечать одинаково, потому что разница в ответах — готовый способ выяснить перебором, какие записи у вас есть. Пора это показать.
Ниже один и тот же сервис с двумя правилами про чужой черновик: в первом он отвечает 403, во втором — тем же, чем несуществующая заметка. Боб перебирает номера с первого по восьмой своим настоящим токеном; два номера в таблице освободились после удаления, два не выдавались никогда. Прогноз: сколько заметок Боб насчитает в каждом случае?
При первом правиле Боб узнал не только то, что у кого-то есть два скрытых от него черновика. Он узнал их номера, узнал, что доступных ему заметок в таблице вдвое меньше, чем существующих, и узнал, до какого номера дорос счётчик: пятый занят, а значит, первые пять выдавались. Из этого следует и судьба четвёртого — он выдавался и его больше нет. Ничего из этого Бобу не показывали: всё это рассказали коды ответов. При втором правиле его перебор не отличил чужой черновик от номера, которого не было никогда.
Скрывать при этом надо не всё. Опубликованная заметка лежит в общей ленте, её видно поиском, на неё ссылаются — её существование не тайна ни для кого. Тайна — черновик: сам факт, что человек что-то пишет и пока не показал. Поэтому правило звучит так: существование скрывают у того, чьё существование и так скрыто.
И сразу снимем вопрос «а не врём ли мы читателю». 404 отвечает не на вопрос «есть ли такая строка в таблице», а на вопрос «есть ли такая заметка для вас». Для автора та же заметка отвечает 200 — и это тот же самый честный ответ на тот же самый вопрос, просто заданный другим человеком.
В боевой базе учебника у заметок не счётчик, а uuid со значением по умолчанию из gen_random_uuid(): 122 случайных бита. Перебирать их бессмысленно — столько запросов не сделать никому. Значит ли это, что правило про 404 нужно только моделям с номерами подряд? Нет, и по трём причинам.
Первая: идентификаторы утекают. Ссылку на черновик отправляют в чат, показывают на скриншоте, оставляют в журнале прокси и в истории браузера на чужом ноутбуке. Тот, кто получил её однажды, проверит её и через год — и ответ 403 скажет ему, что черновик всё ещё существует, а 404 не скажет ничего. Вторая: неугадываемость — не проверка доступа. Это уже разбиралось в части IV на идентификаторе серверного действия: непредсказуемый идентификатор мешает угадать чужой и не мешает прочитать свой. Третья: случайный идентификатор есть не у всего. Номер заказа, короткий код в ссылке, номер страницы в выгрузке — всё это остаётся коротким, потому что его читают и диктуют люди.
404 — это не ложь. Это ответ на вопрос «что у вас есть для меня», а не на вопрос «что у вас есть».
Соберём правило целиком. Чужой черновик отвечает как несуществующая заметка — это уже решено. А чужая опубликованная? 404 на неё был бы не защитой, а путаницей: человек видит заметку в ленте, жмёт «править» и получает «нет такой». Скрывать там нечего, а сбить с толку — можно. Значит, честный 403: «заметка есть, она не ваша».
Ниже сервис заметок целиком: чтение по номеру, правка заголовка и удаление. Проверка владения — одна функция, и оба пишущих обработчика ходят через неё. Каждый случай спрашивается тремя дверями подряд. Прогноз: в какой строке вывода чтение и правка ответят разными кодами?
Разными кодами ответила одна строка — чужая опубликованная: читать можно, править нельзя. Расхождение законное: существование опубликованной заметки и так открыто — она лежит в общей ленте. Зато правка и удаление совпали на всех пяти строках, и это не совпадение, а требование: обе двери спрашивают одно и то же. Разойдутся — и разница снова превращается в тот самый бит, потому что спрашивающему довольно найти дверь, которая отвечает иначе.
Отсюда и «одна функция» вместо проверки в каждом обработчике. Обработчиков со временем становится больше: опубликовать, снять с публикации, приложить фото, перенести в папку, поделиться ссылкой. Каждый из них пишут отдельно и обычно в спешке, а правило — одно, и у него есть имя. Ровно тем же приёмом в главе про данные закрывалась видимость: условие живёт в одном месте, а обработчики ходят через него.
| Случай | Ответ | Что узнал спрашивающий |
|---|---|---|
| Токена нет или он не годится | 401 | Что надо представиться |
| Роль не та | 403 | Что дело не в токене, а в правах |
| Чужая опубликованная заметка | 403 | Что заметка есть — но это и так открыто |
| Чужой черновик | 404 | Ничего |
| Номера нет в таблице | 404 | Ничего |
Правка и удаление на одну и ту же строку обязаны отвечать одинаково. Разошлись — значит, проверок владения у вас две, а не одна.
Есть способ проверить владение, не читая строку: дописать владельца в условие самой правки — UPDATE notes SET title = $1 WHERE id = $2 AND author = $3. Приём знаком: так же поступают с версией строки, когда правка должна проверить, что строка не менялась. Ответом будет не строка, а число изменённых строк.
Ниже та же правка двумя способами: условием внутри и чтением с решением. Прогноз: сколько строк изменит запрос с условием в каждом из четырёх случаев и какой из ответов разойдётся у двух способов?
Ноль изменённых строк — снова несколько случаев в одном числе, и здесь их три: строки нет, строка чужая опубликованная, строка чужой черновик. Отвечать по такому числу можно только одинаково, и одинаковый ответ здесь — 404. Для черновика и для несуществующего номера это ровно то, что нужно; для чужой опубликованной — та самая путаница, от которой мы уходили. Значит, честный 403 покупается отдельным чтением.
Та же пара способов на настоящем Postgres — с настоящим rowCount и настоящим окном между двумя запросами.
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); // два клиента — два соединения: второй понадобится, чтобы влезть между запросами первого const api = new pg.Client({ connectionString: process.env.DATABASE_URL }); const other = new pg.Client({ connectionString: process.env.DATABASE_URL }); await api.connect(); await other.connect(); await api.query(`CREATE TABLE notes ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, author text NOT NULL, title text NOT NULL, status text NOT NULL )`); // три заметки с известными номерами: две чужие, одна своя await api.query(`INSERT INTO notes (author, title, status) VALUES ('ann', 'Хлеб и молоко', 'published'), ('ann', 'План побега', 'draft'), ('bob', 'Отчёт за март', 'published')`); const VIEWER = 'bob'; let queries = 0; const ask = (text, values) => { queries += 1; return api.query(text, values); }; // Одним запросом: владение уехало в условие правки async function renameByOne(id) { const result = await ask('UPDATE notes SET title = $1 WHERE id = $2 AND author = $3 RETURNING id', [ 'новый заголовок', id, VIEWER, ]); return { changed: result.rowCount, answer: result.rowCount === 0 ? '404 NOT_FOUND' : '200' }; } // Двумя: сначала прочитать строку, потом решить, что ответить async function renameByTwo(id, between) { const { rows } = await ask('SELECT author, status FROM notes WHERE id = $1', [id]); const row = rows[0]; if (row === undefined) return { changed: 0, answer: '404 NOT_FOUND' }; if (row.author !== VIEWER) { return { changed: 0, answer: row.status === 'draft' ? '404 NOT_FOUND' : '403 NOT_YOUR_RESOURCE' }; } if (between !== undefined) await between(); const result = await ask('UPDATE notes SET title = $1 WHERE id = $2', ['новый заголовок', id]); return { changed: result.rowCount, answer: '200' }; } const cases = [ ['своя опубликованная', 3], ['чужая опубликованная', 1], ['чужой черновик', 2], ['номер, которого нет', 9], ]; for (const [name, id] of cases) { const one = await renameByOne(id); const two = await renameByTwo(id); console.log(`${name} (${id}): одним запросом изменено ${one.changed} → ${one.answer}; двумя → ${two.answer}`); } console.log('запросов к базе всего:', queries); // Окно между чтением и записью: пока сервис решал, заметку удалили из другого соединения const stolen = await renameByTwo(3, async () => { await other.query('DELETE FROM notes WHERE id = 3'); console.log(' между запросами: другое соединение удалило заметку 3'); }); console.log('своя заметка, удалённая в окне: изменено', stolen.changed, '→', stolen.answer); const after = await renameByOne(3); console.log('тот же случай одним запросом: изменено', after.changed, '→', after.answer); console.log('строк в таблице осталось:', (await api.query('SELECT count(*)::int AS n FROM notes')).rows[0].n); await api.end(); await other.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app своя опубликованная (3): одним запросом изменено 1 → 200; двумя → 200 чужая опубликованная (1): одним запросом изменено 0 → 404 NOT_FOUND; двумя → 403 NOT_YOUR_RESOURCE чужой черновик (2): одним запросом изменено 0 → 404 NOT_FOUND; двумя → 404 NOT_FOUND номер, которого нет (9): одним запросом изменено 0 → 404 NOT_FOUND; двумя → 404 NOT_FOUND запросов к базе всего: 9 между запросами: другое соединение удалило заметку 3 своя заметка, удалённая в окне: изменено 0 → 200 тот же случай одним запросом: изменено 0 → 404 NOT_FOUND строк в таблице осталось: 2
В строках про четыре случая числа и коды у базы те же, что у модели; разошлись только слова — в прогоне отказ несёт доменный код, как в боевом API. И видно, чем оплачено различение случаев: до хвоста вывода ушло девять запросов на восемь попыток правки. У первого способа по одному на попытку, у второго — чтение, а если оно разрешило, ещё и запись. А хвост вывода — про то, чего у модели не было вовсе. Между чтением и правкой есть окно, и в прогоне в него влезло другое соединение и удалило заметку. Способ «двумя запросами» ответил 200, не изменив ни одной строки: он решил по снимку, которого к моменту записи уже не было. Способ «одним запросом» на том же случае ответил 404 — у него проверка и запись неразделимы.
Поэтому в боевом сервисе после записи строку перечитывают, и в комментарии сказано прямо: её отсутствие означало бы гонку удаления. А выбор между способами простой. Если 403 не нужен вовсе — все чужие строки скрыты одинаково, — довольно одного запроса, и окна у него нет. Если 403 нужен, читают строку ради ответа, а записывают тем же условием владения: чтение решает, что сказать, условие решает, что записать. Стартер учебника написан не так: его репозиторий (notes.repository.ts) пишет update и delete по одному id, без владельца в условии, — владение остаётся в прочитанной строке, а окно закрывает перечитывание: строки после записи нет, значит NOT_FOUND, а не 200, за которым ничего не записано.
Ноль изменённых строк — это не «нет такой». Это «ни одна строка не подошла под условие», а в условии было и владение.
У API учебника заметки принадлежат авторам, и проверка владения живёт в сервисе — там, где строка только что прочитана. Ниже выдержка из apps/api/src/notes/notes.service.ts: три метода из восьми, остальное убрано, комментарии дословны.
Тернарник в mustOwn — это ровно та развилка, которую мы собирали: черновик уходит в NOT_FOUND, опубликованная — в NOT_YOUR_RESOURCE. Комментарий над методом называет обе половины правила, а комментарий в byId — причину: по перебору идентификаторов читалась бы карта черновиков. И обратите внимание, что remove не проверяет ничего сам: вся проверка — в mustOwn, а та вместе с разрешением возвращает и строку, если обработчику она нужна. Так же устроен и update, которого в выдержке нет.
Куда именно превращаются эти два кода, разбиралось в главе про свой конвейер: таблица доменных кодов того же приложения переводит NOT_FOUND в NOT_FOUND tRPC, то есть в 404, а NOT_YOUR_RESOURCE — в FORBIDDEN, то есть в 403. Доменный код при этом едет и в message, потому что клиенты разбирают именно его: кодов у tRPC мало, а причин отказа много.
Роль в том же API проверяется отдельно от владения — звеном, которое можно повесить на процедуру или на весь роутер. Ниже оно целиком, убраны только импорты.
Звено тонкое: вся работа в authenticate — той самой общей функции, которая проверяет подпись, находит пользователя и сверяет роль из токена с ролью в базе; её выдержку и разбор даёт статья о звеньях. Дальше одно сравнение и FORBIDDEN с доменным словом WRONG_ROLE — не 401, потому что личность известна и новый токен не поможет.
Комментарий у этого звена стоит прочитать целиком: админских процедур в стартере нет, и объяснено, почему. Модерация ломала бы правило «править и удалять может только автор» — то есть роль admin в таком API нужна не для того, чтобы обходить владение, а для служебных ручек, где строк с авторами нет вовсе. Звено оставлено образцом, и его поведение закреплено тестом.
То же правило ждёт нас в главе про синхронизацию, и там оно опаснее: в протоколе части V идентификатор строки рождается на клиенте, значит клиент может прислать чужой. Владение в push и pull закрывает тот же угон, только ответом служит не код HTTP, а вердикт в пачке. А форма восстановления пароля доводит правило до предела: там одинаковыми обязаны быть и ответ, и время ответа, потому что скрывается не заметка, а сам факт регистрации.
Правило про 404 пишут не в каждом обработчике, а в одном месте, у которого есть имя. У автора это mustOwn.
- Роль отвечает на вопрос «что этому человеку можно вообще» и живёт в токене; владение — свойство пары «человек и строка», и проверить его можно только после чтения строки. Поэтому роль спрашивает страж, а владение — сервис.
- Отказ 403 подтверждает существование записи. Перебор номеров превращает эти подтверждения в карту: занятые номера, число скрытых записей, верхнюю границу счётчика. Поэтому чужой черновик отвечает так же, как несуществующая заметка.
- Чужая опубликованная заметка — наоборот, 403: её существование и так открыто, а 404 сбил бы с толку того, кто видит её в ленте. Скрывают существование только того, чьё существование скрыто.
- Ответы всех дверей, которые спрашивают владение, обязаны совпадать — и кодом, и текстом, и формой тела. Пока хоть одна отвечает иначе, перебор идёт через неё, поэтому проверка владения — одна функция на все пишущие обработчики.
- Условие владения внутри правки (
WHERE id = … AND author = …) атомарно и коротко, но отвечает одним числом на три разных случая: 403 покупается отдельным чтением, а между чтением и правкой появляется окно. - В боевом API это
mustOwnс тернарникомdraft ? NOT_FOUND : NOT_YOUR_RESOURCE, таблица доменных кодов и отдельное звено для роли:FORBIDDENсWRONG_ROLE, а не 401.
1/4Почему на чужую опубликованную заметку отвечают 403, а не 404, как на чужой черновик?
2/4Сервис отвечает 403 на чужой черновик и 404 на номер, которого нет. Что даст перебор номеров?
3/4Запрос UPDATE notes SET title = $1 WHERE id = $2 AND author = $3 изменил ноль строк. Что это значит?
4/4Чтение отвечает на чужой черновик 404, а удаление той же заметки — 403. Правило в чтении соблюдено. Почему это всё равно утечка?
Удаление написали отдельно
Чтение и правка ходят через одну проверку владения, и на чужой черновик обе отвечают как на несуществующую заметку. Удаление написали позже и отдельно — своя проверка владельца в одну строку, выглядит безобидно. Запустите, посмотрите на три ответа и почините, не меняя правил для чужой опубликованной заметки.
Третья дверь отвечает иначе, чем две первые, и этого довольно: 403 подтверждает, что заметка 2 существует. Отдельная проверка в remove не хуже написана — она просто не знает про черновики.
После правки все три двери отвечают 404 нет такой, и последняя строка вывода говорит нет. Заметьте, что дело не в тексте проверки, а в том, что она вторая: даже написанная правильно, через месяц она разойдётся с первой. Проверка владения — одна функция, и все пишущие обработчики зовут её.
Полезно и то, что mustOwn возвращает строку: обработчику она всё равно нужна, и второе чтение той же строки не понадобится.
Автор приехал во входе
Проверка владения здесь есть, и на честном клиенте она работает: попробуйте вторую строку вывода. Первый и третий запросы отличаются только полем author во входе. Запустите и почините так, чтобы чужая заметка не правилась ни при каком содержимом входа.
Сравнение row.author !== input.author проверяет не право, а согласованность запроса: оба числа прислал тот, у кого мы спрашиваем разрешения. Подставьте под чужую заметку её настоящего автора — и проверка пройдёт. Личность в функцию приехала (viewer из токена), но в проверке не участвует.
Одна строка: сравнивать надо с тем, что сказал токен, а не с тем, что сказал запрос. Вывод после правки — 200, потом три 403 это не ваша заметка: ни одно содержимое входа больше не помогает. Это то же правило, что в части IV: личность берут из токена или сессии, а аргументы отвечают только на вопрос «над чем работаем».
Дальше правку стоит довести до правила этой главы: чужой черновик не должен отвечать 403.
Теперь последняя строка вывода — 404 нет такой. А поле author после этого из входа убирают вовсе, а не просто обходят: пока оно в типе, кто-нибудь им ещё воспользуется, а валидатор границы — как раз то место, где лишнему полю не дают войти.
Код один, слова разные
Правило соблюдено: и на чужой черновик, и на несуществующий номер сервис отвечает 404. Запустите и посмотрите на две последние строки вывода.
Код совпал, а ответ — нет: текст рассказывает то самое, что код скрывал. Спрашивающему довольно читать не код, а тело ответа.
Вывод после правки: обе строки — 404 заметка не найдена, и обе последние отвечают да. Оба отказа теперь собирает одна функция — так их и не разведёшь по неосторожности. Одинаковыми обязаны быть код, текст и форма тела; если в ответе есть ещё что-нибудь — заголовок с версией строки, подсказка для клиента, — одинаковым обязано быть и оно.
А то, чем два ответа всё-таки отличаются, пишут в журнал сервера: там и «чужой черновик», и номер заметки, и кто спрашивал. Журнал читают свои, ответ читает кто угодно.