Адрес вложения открыт в другом браузере без входа. Интерфейс кабинета при этом честно скрывал чужую запись: в списке её не было, кнопка «Скачать» не рисовалась, раздел выглядел образцово. А файл лежал по прямой ссылке и отдавался любому, кто эту ссылку получил. Картинка и правда была аккуратной, только к защите она отношения не имела.

Эта история хорошо показывает главное различие, с которого стоит начинать проверку клиентского кабинета на PocketBase: есть невидимая кнопка и есть запрещённый запрос. Первое — свойство интерфейса, второе — свойство сервера. Ниже разберём, как устроены правила коллекций, где в эту схему встраиваются защищённые файлы, чем отличается временная ссылка от обычного адреса и какие полномочия нельзя переносить в код клиентского приложения.

Невидимая кнопка и запрещённый запрос

Клиентское приложение — это код, который выполняется у пользователя. Всё, что в нём написано, пользователь может прочитать, изменить или просто обойти: открыть вкладку сети в браузере, скопировать запрос и отправить его вручную. Если единственная причина, по которой клиент А не видит запись клиента Б, — условие в компоненте интерфейса, то граница держится на вежливости. Сервер получит корректный запрос с корректным токеном и, не имея собственных ограничений, ответит.

Поэтому вопрос надо формулировать иначе. Не «показывает ли кабинет чужое», а «что ответит сервер, если спросить напрямую». Для PocketBase ответ определяется двумя механизмами: правилами API у коллекции и способом, которым отдаются файлы. Оба проверяются запросами без участия интерфейса.

Мысленный опыт: две записи, два владельца, одно вложение

Возьмём условный кабинет, где клиенты хранят договоры. Коллекция documents содержит поля: title, owner (связь с пользователем) и scan (файл). Есть два пользователя: Анна и Борис. У Анны одна запись с вложением scan, у Бориса — другая. Имена и структура выдуманы для опыта, но схема типична для любого кабинета, где данные принадлежат конкретному клиенту.

Нам нужны четыре заранее записанных ожидания, прежде чем мы что-то запустим. Анна видит свою запись и скачивает свой файл. Борис не видит запись Анны, не может её прочитать по идентификатору и не может скачать её вложение. Анонимный посетитель не получает ничего. Если ожидания записаны до проверки, результат не получится «подогнать»: любое расхождение сразу бросается в глаза.

Пять действий, пять отдельных правил

У коллекции в PocketBase есть отдельные правила для пяти действий: list, view, create, update и delete (документация по API rules). Это не формальность. Просмотр списка и просмотр одной записи — разные запросы, и правила на них задаются независимо. Можно по ошибке закрыть список, но оставить открытым view: тогда записи не перечисляются, зато читаются любым, кто знает или угадает идентификатор. Идентификаторы не секрет: они попадают в адреса, логи, письма и скриншоты.

Три значения правила определяют поведение. Значение null означает «закрыто» (locked): действие доступно только суперпользователю. Пустая строка разрешает действие всем, включая неавторизованных. Непустое выражение работает как фильтр: действие разрешено только для тех записей, которые под него подходят. Первое, что стоит проверить в нашем опыте, — нет ли у документов пустых строк там, где их быть не должно. Пустая строка в правиле view — самый короткий путь к открытому файлу.

Правило как фильтр: что это меняет на практике

В документации сказано, что API rules работают так же, как фильтры записей. Из этого следуют два практических вывода. Во-первых, правило можно мыслить как условие отбора: запись попадает в ответ, если условие для неё истинно. Во-вторых, чужие записи не «запрещаются» с громким отказом, а просто не подходят под фильтр. На списке это выглядит как пустая выдача, на запросе одной записи — как ответ, что такой записи нет. Поэтому по ответу сервера Борису трудно даже понять, существует ли запись Анны. Для кабинета это полезное свойство.

Для нашей коллекции естественный набор выглядит так. Чтение и изменение ограничены владельцем, создание доступно авторизованному пользователю, удаление — по усмотрению продукта, но не шире изменения:

list:   @request.auth.id != "" && owner = @request.auth.id
view:   @request.auth.id != "" && owner = @request.auth.id
create: @request.auth.id != ""
update: @request.auth.id != "" && owner = @request.auth.id
delete: @request.auth.id != "" && owner = @request.auth.id

Первая часть условия отсекает анонимов, вторая сопоставляет владельца записи с текущим пользователем. Имена owner и documents здесь наши, синтаксис ссылки на авторизованного пользователя через @request.auth стоит сверить с текущей версией документации на вашем сервере перед применением: при расхождении правило нужно приводить к тому, что описано для вашей установки.

Где правила чтения и правила изменения расходятся

Самая тихая ошибка — разрешить читать только владельцу, но оставить create или update слишком мягкими. Тогда Борис не прочтёт запись Анны, но при создании своей записи сможет указать в поле owner чужой идентификатор. Или при изменении своей записи перепишет владельца на другого пользователя и «подбросит» ему документ. Чтение защищено, целостность нет.

Поэтому в опыте стоит проверять не только чтение. Борис пытается: создать запись с владельцем Анной; обновить свою запись, подставив ей владельца; обновить запись Анны; удалить запись Анны. Каждое из этих действий должно закончиться отказом или пустым результатом. Правило создания, которое проверяет лишь факт входа, может оказаться слишком щедрым: если поле владельца приходит из тела запроса, его значение нужно ограничивать условием, а не доверять клиенту. Какими именно выражениями это сделать, зависит от версии и от того, как построена ваша схема, поэтому итоговую формулировку нужно подтвердить запросом, а не уверенностью в синтаксисе.

Матрица ожиданий

Чтобы не держать всё в голове, соберите таблицу прямо перед проверкой. Строки — действия, столбцы — участники опыта. В ячейках записывается ожидаемый ответ сервера, а потом рядом — фактический.

Матрица проверки для коллекции documents (ожидания условного опыта)
ДействиеАнна (своя запись)Борис (запись Анны)Без входа
listзапись в выдачезаписи Анны нет в выдачепусто или отказ
view по идентификаторузапись отдаётсяотказ или «не найдено»отказ или «не найдено»
updateразрешеноотказотказ
deleteпо правилу продуктаотказотказ
файл scanскачиваетсяне скачиваетсяне скачивается

Последняя строка — самая коварная, и ей посвящён следующий раздел. Все остальные строки опираются на правила коллекции. Файл живёт по своим законам.

Файл — отдельный адрес со своими условиями

У вложения есть собственный URL. По документации обычные файлы доступны по известному полному адресу (работа с файлами в PocketBase). Ключевое слово здесь — «известному». Адрес составляется из компонентов, и если он однажды попал в письмо, чат, лог прокси или историю браузера, то любой, у кого он оказался, получит содержимое. Скрытая запись в списке ничего в этом не меняет: сервер отдаёт файл по адресу, а не по тому, видна ли запись в интерфейсе. Именно это и произошло в сцене из начала статьи.

Отсюда осторожный вывод, который нельзя пропускать: правило записи само по себе не гарантирует, что любой публичный URL файла станет закрытым. Если поле файла обычное, то знание адреса достаточно. Чтобы файл подчинялся правилам, его нужно сделать защищённым.

Что на самом деле делает флаг Protected

Для защищённого файла PocketBase проверяет выполнение View API rule записи, к которой файл относится. Иными словами, запрос на файл проходит тот же фильтр, что и запрос на саму запись: если Борис не проходит правило view для записи Анны, он не получит и её файл. Для опыта это значит, что защита файла фактически делегирована правилу view, и качество защиты определяется качеством этого правила.

Отсюда прямое следствие, о котором в документации сказано отдельно: если View API rule разрешает доступ всем, один флаг Protected не делает файл приватным. Проверяется условие, а условие пустое. Это легко упустить, когда правило view когда-то открыли «временно, для отладки» и забыли закрыть. Флажок стоит, галочка радует глаз, а фактически файл читается кем угодно, кто знает адрес.

Проверка одного защищённого файла двумя пользователями

Теперь соберём сам опыт. Включаем у поля scan защиту. Убеждаемся, что правило view — то самое, с проверкой владельца, а не пустая строка. Загружаем вложение в запись Анны. Дальше последовательность такая.

  1. Анна входит и получает запись. Из неё берём имя файла и формируем адрес вложения. Просим файл с авторизацией Анны. Ожидание: файл отдаётся.
  2. Тот же адрес запрашиваем без входа. Ожидание: отказ.
  3. Входим как Борис и запрашиваем тот же адрес. Ожидание: отказ, поскольку для записи Анны правило view у Бориса не выполняется.
  4. Возвращаемся к обычному не защищённому состоянию поля (на тестовой коллекции) и повторяем пункт 2. Ожидание уже другое: файл доступен по адресу. Этот шаг нужен как контрольный: он показывает, что разница в ответе действительно вызвана флагом, а не чем-то посторонним, например кэшем или прокси.

Контрольный шаг стоит дополнить вопросом: что произойдёт, если вы забудете, что защита включена, и откроете кабинет Анны с уже загруженными файлами? Для защищённого поля браузерный тег с адресом файла без нужной авторизации получит отказ, поэтому интерфейсу потребуется способ получить доступ законно. Здесь мы и подходим к временным ссылкам.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Выдача ссылки и дальнейший доступ по ней

Простой запрос с заголовком авторизации работает в коде, но не в разметке: у обычной ссылки на файл или у тега изображения нет возможности приложить токен входа. Для этого у защищённых файлов предусмотрен краткоживущий file token. Его выдаёт сервер авторизованному пользователю, а клиент добавляет в адрес файла. Подробности запроса токена и параметра адреса уточняйте в разделе о файлах в документации PocketBase: имена параметров лучше взять оттуда, а не из памяти.

Теперь главное. Ссылка с токеном — это уже не «защищённый файл». Это пропуск, и он работает у любого, кто им владеет, пока не истёк. Поэтому проверка выдачи ссылки — отдельный опыт, а не продолжение предыдущего. Пока в нём два вопроса: кому сервер выдаёт токен и что происходит со ссылкой после выдачи.

Кому выдаётся токен

Токен привязан к авторизованному пользователю. Анна получает токен, подставляет его в адрес своего файла, и файл открывается. Но токен Анны не должен открывать файл Бориса: при запросе проверяется тот же View API rule, только теперь от лица владельца токена. Проверьте это явно. Возьмите токен Анны, подставьте его в адрес файла из записи Бориса и убедитесь, что ответ — отказ. Если файл открылся, значит, правило view у коллекции шире, чем вы думали, и дело не в токене.

Что происходит после выдачи

Токен краткоживущий, и в этом его смысл. Ссылка с ним может утечь: попасть в историю браузера, в журнал обратного прокси, в скриншот или в пересланное сообщение. Срок жизни ограничивает последствия такой утечки: когда токен истёк, ссылка перестаёт работать. Именно поэтому временную ссылку нельзя хранить постоянно: сохранённый в базе или в закладке адрес с токеном через некоторое время просто окажется мёртвым, а если его всё же кому-то показали, он перестаёт быть безопасным лишь по мере истечения срока, а не мгновенно.

Практическое правило для кабинета: храните в базе только запись и имя файла, а ссылку с токеном собирайте в момент показа страницы или при нажатии кнопки. Запрашивать новый токен перед каждой выдачей дешевле, чем разбираться с мёртвыми ссылками в письмах клиентам.

Срок жизни токена берите из текущей конфигурации вашего экземпляра, а не из чужих примеров. Он зависит от настроек и версии, и опыт без этой цифры теряет смысл. Проверка выглядит так.

  1. Получить токен от имени Анны и открыть файл Анны по адресу с токеном. Ожидание: файл отдаётся.
  2. Открыть ту же ссылку в другом браузере без входа, пока токен не истёк. Ожидание: файл отдаётся, ведь токен и есть пропуск. Это неприятное, но честное наблюдение: на время жизни токена ссылка работает для всякого, у кого она есть.
  3. Дождаться истечения срока из вашей конфигурации и повторить запрос. Ожидание: отказ.
  4. Запросить новый токен и убедиться, что новая ссылка снова открывает файл.

Второй пункт стоит запомнить: он определяет, как обращаться с такими адресами. Не вставляйте их в письма, которые будут храниться годами, не пишите в журналы приложения полным адресом, не пересылайте в мессенджеры как постоянный способ доступа. Ссылка с токеном — расходный материал.

Что видит клиентское приложение, а что должно оставаться у сервера

Пока мы говорили об обычных пользователях. Но в PocketBase есть ещё одна роль, которая работает совсем по другим законам: суперпользователь. В документации прямо сказано, что он обходит API rules. Для него не существует ни фильтра владельца, ни закрытых коллекций с null: значение null как раз означает, что действие доступно только ему.

Это удобно администратору и опасно, если учётные данные суперпользователя оказываются там, где им нечего делать. Если клиентское приложение каким-то образом использует права суперпользователя — например, в код встроен его токен или логин и пароль, — то все выстроенные правила перестают иметь значение для любого, кто откроет код или перехватит запрос. Борис не получает чужую запись через правила, но получает её через вшитый административный токен.

Практически это значит следующее. Браузерный код и мобильное приложение работают только от имени самих пользователей. Всё, что требует административных полномочий, выносится на сторону сервера и выполняется там, где посетитель не может заглянуть в исходник. Что именно туда выносить, определяется содержанием задачи: массовые операции, служебные отчёты, обработка событий от внешних систем, выдача чего-либо в обход пользовательских правил. Правило простое: если действие нельзя описать фильтром от лица пользователя, значит, оно не должно жить в его приложении.

Как заметить утечку административных полномочий

Проверка здесь тоже делается без интерфейса. Откройте собранный клиентский пакет и посмотрите, нет ли в нём строк, похожих на административные данные. Откройте вкладку сети кабинета и посмотрите, под какой учётной записью уходят запросы. Если любой запрос идёт от имени суперпользователя, а не от пользователя, это сразу нужно исправлять, и никакие правила коллекций этого не компенсируют. Ещё один признак: коллекции, у которых правило null, внезапно читаются из браузера. Значит, браузер работает под суперпользователем.

Порядок правок, чтобы не закрыть дверь вместе с клиентами

Закрывать правила на живом кабинете нужно по очереди, иначе вместе с чужими записями пропадут и свои. Работает такой порядок.

  • Сначала зафиксируйте текущее состояние: выпишите правила всех пяти действий для каждой коллекции и отметьте поля файлов, у которых включена защита. Это позволит вернуть всё, как было, если что-то пойдёт не так.
  • Затем включайте ограничения на тестовой копии коллекции, где две записи принадлежат Анне и Борису. Прогоните матрицу ожиданий целиком.
  • Потом меняйте правила на рабочей коллекции по одному: сначала list и view, затем update и delete, затем create. После каждого шага повторите ключевые запросы Анны и Бориса.
  • В последнюю очередь включайте защиту у поля файла и проверяйте, что интерфейс получает токены и открывает вложения законным путём. Если приложение ссылалось на файлы прямыми адресами, оно перестанет их показывать, поэтому правку интерфейса нужно выпускать вместе с включением защиты.

Если после включения ограничений у Анны перестали открываться собственные документы, возврат делается к выписанным значениям правил. Поэтому первый пункт — не бюрократия, а страховка: на пять правил у каждой коллекции вы потратите несколько минут, а восстанавливать их по памяти пришлось бы гораздо дольше.

Где всё это живёт и какую роль играет сервер

Проверка прав не требует тяжёлых ресурсов: два пользователя, несколько записей и пара файлов. Для тестовой установки PocketBase хватает небольшой виртуальной машины, и удобнее всего поднять отдельный экземпляр, на котором можно безопасно ломать правила и возвращать их обратно. Для такого опыта подойдёт начальный VPS MAATRIX: тариф Ba с 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe, как и остальные, даёт root-доступ, а значит, можно самому настроить обратный прокси, TLS и хранение файлов. Указанные ресурсы описывают конфигурацию, но не обещают конкретную нагрузку: сколько пользователей и файлов выдержит ваш кабинет, покажет замер на вашем приложении. Список локаций и условия оплаты смотрите на странице аренды VPS MAATRIX; доступность конкретных ОС и образов проверяется в форме заказа.

Отдельный тестовый экземпляр полезен ещё и тем, что ему не жалко удалить базу, если опыт пошёл не по плану. Когда правила проверены, их можно переносить на рабочий сервер с тем же списком выписанных значений. Если вы ещё присматриваетесь к самому PocketBase и хотите понять, где он заканчивается как бэкенд, пригодится материал о том, что выясняется на второй неделе работы с PocketBase.

Вложение в чужом браузере

Вернёмся к началу. Анна загрузила договор, интерфейс показывает его только ей, и всё выглядит надёжно. Дальше достаточно открыть адрес вложения в браузере, где никто не вошёл. Если защита настроена так, как описано выше — правило view проверяет владельца, поле файла защищено, а ссылки с токеном выдаются на короткое время, — сервер откажет. Если нет, он отдаст файл, и никакая аккуратная вёрстка этого не исправит.

Поэтому итоговый критерий проверки не про внешний вид. Он про то, что ответит сервер на запрос Бориса, запрос без входа и запрос по просроченной ссылке. Пока на все три получается отказ, кабинет защищён там, где защита вообще возможна. Откройте адрес вложения в чужом браузере и посмотрите, что сказал сервер.