DAV 26.200.0: LOCK и UNLOCK на сетевом диске начали проверять права на запись
Обновление безопасности
Закрыта уязвимость или ослабленная проверка прав. Ставить в первую очередь.
В dav 26.200.0 заявлены два исправления сетевого диска: при подключении больше не спрашивают пароль повторно, а с файлами на диске снова можно работать (по диффу это правки в PUT, LOCK и UNLOCK). Попутно LOCK и UNLOCK начали проверять права на запись, которых раньше не проверяли вовсе, поэтому обновление нужно всем коробкам Битрикс24, где сотрудники подключают диск по WebDAV. Разработчикам стоит поискать у себя вызовы TokensTable::createToken() с явным токеном и CDav::GetWindowsVersion().
Заблокировать файл теперь можно только с правом на запись
CDavWebDavServer::LOCK() и UNLOCK() перед установкой и снятием блокировки вызывают новый private-метод checkWriteAccessByPath(). Для существующего объекта нужен canUpdate, для нового пути canAdd на родительской папке. Без прав клиент получит 403 Forbidden, а если объект или родительскую папку найти не удалось, 409 Conflict. Раньше проверки прав в этих методах не было, в LOCK она стояла закомментированной.
Токен блокировки getNewLockToken() теперь собирает UUID v4 из random_bytes(16). Раньше токен давал uuid_create() или md5(microtime().getmypid()), то есть хеш от времени и номера процесса. Про uuid_create() в комментарии к коду отдельно сказано, что OSSP-расширение может вернуть int.
Срок блокировки теперь задаёт клиент, но не больше часа. LOCK читает заголовок Timeout: Second-N и зажимает N между CDavWebDavServer::LOCK_TIMEOUT_MIN (60 секунд) и LOCK_TIMEOUT_MAX (3600). На Infinite и некорректное значение ставится LOCK_TIMEOUT_DEFAULT, те же 300 секунд, которые раньше получала любая блокировка.
CDavWebDav::CheckLockStatus() больше не останавливает пользователя его собственной блокировкой, даже если клиент не прислал токен. Владельца сверяют по новому числовому полю LOCK_USER_ID, чужие блокировки запрос по-прежнему не пропускают.
Сетевой диск перестаёт переспрашивать пароль
CDavWebDav для сетевого диска (CDavWebDavServer) отвечает на OPTIONS без авторизации на любом пути, раньше так было только для /. В комментарии к коду сказано, что ответ статичен, и дана ссылка на Mantis 133939 о том, что Office при discovery-запросе заново спрашивал пароль у уже подключённого диска. GroupDAV и CalDAV, как и раньше, пускают OPTIONS без авторизации только на /. Если мониторинг или сканер ждал 401 на OPTIONS к путям сетевого диска, больше он его не получит.
CDav::SetAuthHeader() больше не вызывает CHTTP::SetAuthHeader(). Заголовки собирает новый CDav::buildWwwAuthenticateHeaders(string $realm): array, он ничего не отправляет и только возвращает массив. В [0] всегда Basic, в [1] Digest, если в main включён use_digest_auth и CDav::isDigestEnabled() вернул true. Флаг сессии BX_HTTP_DIGEST_ABSENT намеренно больше не учитывается. По комментарию, из-за него схема авторизации «прыгала», и Windows снова просила пароль.
CDavResponse::Render() отправляет повторные WWW-Authenticate с replace=false. Раньше каждый header() шёл с заменой, и при паре Basic + Digest клиент получал только последний заголовок. Остальные заголовки ответа по-прежнему перезаписываются.
Не рассчитывайте на историю версий Windows по IP
CDav::GetWindowsVersion() получил тип возврата int и смотрит только на User-Agent текущего запроса. Регулярка стала Windows NT (\d+) вместо (\d{1}), так что Windows NT 10 больше не превращается в 1. Если в User-Agent нет Windows NT, метод вернёт 0. Раньше в этом случае версия бралась из истории по IP, которую модуль хранил сериализованной в опции dav/windows_version и перезаписывал при смене значения.
Если ваш код вызывает GetWindowsVersion(), готовьтесь к нулю на запросах без Windows NT. Опция больше не пишется, а её очистки в диффе нет, так что история по IP останется в базе. В ней лежат IP-адреса пользователей, поэтому опцию лучше удалить: \Bitrix\Main\Config\Option::delete('dav', ['name' => 'windows_version']).
Проверьте вызовы createToken() с явным токеном
Bitrix\Dav\TokensTable::createToken() при исключении на add() сначала ищет существующую строку по USER_ID и возвращает её. Новый TOKEN с повторной вставкой генерируется, только если строки нет. На MySQL USER_ID в b_dav_tokens уникален, поэтому если вы передаёте токен явно, а у пользователя уже есть запись, метод вернёт её как есть, со старыми TOKEN и EXPIRED_AT, даже истёкшими. Раньше такой вызов заканчивался исключением, потому что повторный add() падал на том же USER_ID. На PostgreSQL уникального ключа на USER_ID нет, там add() пройдёт, и у пользователя появится вторая строка. Берите токен из результата:
<?php declare(strict_types=1);
namespace Vendor\Sync\Application\Service;
use Bitrix\Dav\TokensTable;
use Bitrix\Main\Error;
use Bitrix\Main\Loader;
use Bitrix\Main\Result;
final class DavTokenIssuer
{
public function issue(int $userId, string $token): Result
{
$result = new Result();
if (!Loader::includeModule('dav')) {
return $result->addError(new Error('Модуль dav не установлен', 'DAV_MODULE_MISSING'));
}
$issued = (string)(TokensTable::createToken($userId, $token)['TOKEN'] ?? '');
if ($issued === '') {
return $result->addError(new Error('Токен не создан', 'DAV_TOKEN_EMPTY'));
}
// с 26.200.0 на MySQL при существующей записи вернётся её токен, переданный не сохранится
return $result->setData([
'token' => $issued,
'reused' => $issued !== $token,
]);
}
}
Параллельный PUT больше не падает на уникальном индексе
В PUT и PutCommit() три правки:
- имя файла нормализуется через
Bitrix\Disk\Ui\Text::correctFilename(), так же как это делает disk перед INSERT; - существующий файл ищет новый private
loadLiveTwin()с фильтромDELETED_TYPE_NONE, и файл из корзины больше не считается существующим; - если параллельный
PUTпроиграл гонку на уникальном индексе(NAME, PARENT_ID), вместо ошибки в найденный файл загружается новая версия, при условии что у пользователя естьcanUpdate.
generateUniqueName не включают специально, чтобы не плодить копии вида «file (1).docx».
Комментарий в PutCommit() утверждает, что MySQL сообщает о дубле обычной ошибкой (1062), а DuplicateEntryException бросает только PostgreSQL. В main той же сборки MysqlCommonConnection::createQueryException() уже превращает код 1062 в DuplicateEntryException, так что проверка подстроки (1062) работает как запасной вариант. В своём коде хватит ловить Bitrix\Main\DB\DuplicateEntryException и перечитывать строку победителя. Похожий приём есть в TokensTable::createToken() этого релиза (там ловится любое \Exception) и в private Driver::addStorageIfNotExistWithCreationStatus() из disk 26.1175.0. Приём подходит любому «создай, если нет»:
<?php declare(strict_types=1);
namespace Vendor\Sync\Infrastructure\Repository;
use Bitrix\Main\DB\DuplicateEntryException;
use Vendor\Sync\Model\DeviceTable;
final class DeviceRepository
{
// в b_vendor_sync_device уникальный ключ (USER_ID, CODE)
public function getOrCreate(int $userId, string $code): array
{
$row = $this->find($userId, $code);
if ($row !== null) {
return $row;
}
try {
DeviceTable::add(['USER_ID' => $userId, 'CODE' => $code]);
} catch (DuplicateEntryException) {
// параллельный запрос вставил строку раньше, берём его результат
}
return $this->find($userId, $code)
?? throw new \RuntimeException("Устройство {$code} не найдено после вставки");
}
private function find(int $userId, string $code): ?array
{
$row = DeviceTable::getList([
'select' => ['ID', 'USER_ID', 'CODE'],
'filter' => ['=USER_ID' => $userId, '=CODE' => $code],
'limit' => 1,
])->fetch();
return $row ?: null;
}
}
Проверьте колонку LOCK_USER_ID и агент очистки
В b_dav_locks добавлено поле LOCK_USER_ID int NULL. Оно есть в install.sql для MySQL и PostgreSQL и в выборке по умолчанию CDavVirtualFileSystem::GetList(). Скрипта ALTER для существующих установок в файлах модуля нет. Updater-скрипты в файлы модуля не попадают, поэтому по диффу не проверить, как колонка появится на живой базе. UpdateLock() записывает LOCK_USER_ID при каждом продлении блокировки, если пользователь известен, поэтому старые строки без владельца дозаполнятся постепенно, без миграции. CAllDavVirtualFileSystem::Lock() и UpdateLock() получили необязательный последний параметр $userId = null, прежние вызовы работают.
Агент CDavVirtualFileSystem::collectExpiredLocks(); с интервалом 900 секунд регистрируется в DoInstall() и удаляется в DoUninstall(). Он вызывает protected purgeExpired(), тот одним DELETE FROM b_dav_locks WHERE EXPIRES < time() сносит все просроченные блокировки, а агент возвращает строку своего вызова. В комментарии агент назван страховкой к «ленивой» очистке. CheckLock() по-прежнему удаляет просроченную блокировку только для запрошенного пути. Других мест регистрации агента в диффе нет.
После обновления проверьте и колонку, и агент. Скрипт рассчитан на подключённое ядро, в командную PHP-строку админки его вставляйте без первой строки:
<?php declare(strict_types=1);
use Bitrix\Main\Application;
$column = Application::getConnection()->getTableField('b_dav_locks', 'LOCK_USER_ID');
echo 'LOCK_USER_ID: ', $column !== null ? 'есть' : 'нет', PHP_EOL;
$agent = \CAgent::GetList([], ['=NAME' => 'CDavVirtualFileSystem::collectExpiredLocks();'])->Fetch();
echo 'агент: ', $agent ? 'следующий запуск ' . $agent['NEXT_EXEC'] : 'не зарегистрирован', PHP_EOL;
Без колонки GetList() падает на L.LOCK_USER_ID, а вместе с ним и CheckLock(), так что блокировки на сетевом диске работать не будут. Если колонки нет, добавьте её с тем же определением, что в install.sql:
ALTER TABLE b_dav_locks ADD LOCK_USER_ID int NULL;
Если агента нет, его добавляет тот же вызов, что в DoInstall(): \CAgent::AddAgent('CDavVirtualFileSystem::collectExpiredLocks();', 'dav', 'N', 900).
Мелочи и находки
- Токен из заголовка
If:при продлении блокировки теперь разбирает privateCDavWebDav::extractLockTokenFromIf(). Он понимает tagged- и untagged-формы, пропускает отрицанияNot <token>, а если разобрать не вышло, режет строку по-старому. - В код ядра попали внутренние ссылки разработки: «See ADR Risk 1» в
CDavResponse::Render()и уже упомянутый Mantis 133939 вwebdav.php.