fix(swap): bind ownership to device+inode, restrict swap.path to root-owned roots [P0]
Two defects that together made the previous ownership model unsafe, and reverse an earlier deviation that the follow-up audit correctly rejected. 1. The sidecar marker identified a path, not a file. Ownership was `<swap.path>.awg2-harness-owned` sitting next to the swapfile, and the check was whether that sidecar existed and started with our marker. Nothing tied it to the file it was supposed to describe. The sequence that loses data: our swapfile is removed by hand, the sidecar survives, an operator puts an unrelated file at the same path - and the next reconcile calls it managed and, if the size differs, deletes it. The claim in the previous commit that a wrong path "can now only fail, never destroy" was wrong. Ownership now lives in /var/lib/awg2-harness/swap-ownership.conf, a root-only directory created 0700, and records version, path, device and inode. A path is not an identity: a file can be deleted and another created in its place, but (device, inode) cannot be reused while our file exists. Classification gained StaleOwnership for "right path, different file", which refuses exactly like Foreign but says which situation the operator is actually in. Only ManagedValid and ManagedWrongSize ever authorise a delete, and that is asserted directly. The record is written after the file exists, from its real metadata, so until it lands the swapfile is not considered managed. The old sidecar is now only ever removed, never read. classify_ownership is a pure function taking the recorded state and the observed identity, so the cases that matter - stale record, record for another path, no record - are unit-testable rather than requiring a Linux filesystem. 2. An arbitrary swap.path was still exploitable. The previous commit kept arbitrary absolute paths and argued the ownership marker was sufficient protection. It is not, and the audit is right to reject it. Between assert_swap_path_is_safe and fallocate/dd/mkswap running, a user who can write the parent directory replaces the final component with a symlink and redirects a root-owned operation. That is a TOCTOU; stat-ing before use cannot close it, because the check and the use are separate syscalls. Paths like /tmp/awg.swap or /home/user/awg.swap made this reachable by any local user. swap.path is now restricted to /swapfile or a file directly under /var/lib/awg2-harness/. Both are root-owned, so the window does not exist rather than being detected. /swapfile is kept so existing deployments need no config change. The deny-list it replaces only ruled out the paths someone had thought of; what actually matters is who can write the parent. Eleven tests: the five classification states, a stale record, a record for another path, the invariant that no refusal state is managed, record round-trip, malformed and unsupported-version records, the newly allowed and newly rejected path shapes. cargo test --workspace: 176 + 4 passed. clippy -D warnings clean.
This commit is contained in:
@@ -593,14 +593,27 @@ Production-путь перезагрузки — внешний бинарь/с
|
||||
|
||||
### Владение swapfile
|
||||
|
||||
Пакет никогда не трогает файл, который не создавал. Рядом с управляемым swapfile лежит sidecar-маркер `<swap.path>.awg2-harness-owned` с первой строкой `# Managed by awg2-harness swap`. Без него отличить наш файл от созданного администратором невозможно, и правило «размер не совпал — пересоздать» превратилось бы в «молча уничтожить чужой файл».
|
||||
Пакет никогда не трогает файл, который не создавал. Состояние владения хранится в `/var/lib/awg2-harness/swap-ownership.conf` — каталог доступен только root (`0700`):
|
||||
|
||||
```text
|
||||
# Managed by awg2-harness swap-ownership
|
||||
version = 1
|
||||
path = /swapfile
|
||||
device = 2049
|
||||
inode = 123456
|
||||
```
|
||||
|
||||
Ключевое здесь — `device` и `inode`, а не путь. Путь не является идентичностью: файл можно удалить, а на его месте создать другой. Прежняя схема с sidecar-маркером рядом со swapfile этого не различала, и последовательность «наш swapfile удалили вручную → маркер остался → оператор создал по тому же пути другой файл» приводила к тому, что чужой файл признавался управляемым и при несовпадении размера удалялся. Sidecar удалён; при обнаружении он только вычищается и никогда не используется как доказательство владения.
|
||||
|
||||
| Состояние | Условие | Действие |
|
||||
| --- | --- | --- |
|
||||
| `Missing` | файла нет | создать |
|
||||
| `ManagedValid` | маркер есть, размер совпадает | переиспользовать |
|
||||
| `ManagedWrongSize` | маркер есть, размер отличается | пересоздать |
|
||||
| `Foreign` | файл есть, маркера нет | **ошибка**, файл не трогается |
|
||||
| `ManagedValid` | запись есть, `device`+`inode`+путь совпали, размер совпал | переиспользовать |
|
||||
| `ManagedWrongSize` | запись совпала, размер отличается | пересоздать |
|
||||
| `Foreign` | файл есть, записи нет или запись про другой путь | **ошибка**, файл не трогается |
|
||||
| `StaleOwnership` | запись есть, но `device`/`inode` не совпали — файл подменили | **ошибка**, файл не трогается |
|
||||
|
||||
Запись создаётся **после** появления файла, по его фактическим metadata. До этого момента файл управляемым не считается.
|
||||
|
||||
Чужие активные swap только выключаются через `swapoff`. Файлы не удаляются — ни обычные, ни блочные устройства. Удаление чужого swapfile не является решением этого пакета: эксклюзивное runtime-состояние достигается связкой `swapoff` плюс вычистка `/etc/fstab`, а файл оператор уберёт сам, когда сочтёт нужным.
|
||||
|
||||
@@ -629,7 +642,11 @@ Production-путь перезагрузки — внешний бинарь/с
|
||||
- путь абсолютный и не оканчивается на `/`;
|
||||
- нет компонентов `.` и `..` — поэтому строка уже нормализована и список запрещённых путей нельзя обойти записью вида `/etc/../etc/fstab`;
|
||||
- нет пробелов, управляющих символов и обратных слэшей;
|
||||
- путь не входит в список запрещённых (`/`, `/etc`, `/etc/fstab`, `/etc/passwd`, `/etc/shadow`, `/dev`, `/proc`, `/sys`, `/run`, `/boot`) и не лежит под `/proc`, `/sys`, `/dev`.
|
||||
- путь равен `/swapfile` **либо** лежит непосредственно в `/var/lib/awg2-harness/` (без вложенных каталогов).
|
||||
|
||||
Это allow-list, а не deny-list, и это принципиально. Список запрещённых путей закрывает только те случаи, которые кто-то придумал; значение имеет другое — может ли родительский каталог писать кто-то кроме root. Между preflight-проверкой и запуском `fallocate`/`dd`/`mkswap` непривилегированный пользователь с правом записи в родительский каталог успевает подменить финальный компонент на symlink и перенаправить операцию, выполняемую от root. Это классический TOCTOU, и никакое количество `stat` перед использованием его не закрывает — окно устраняется только тем, что запись в родительский каталог доступна лишь root.
|
||||
|
||||
Оба разрешённых корня root-owned. `/swapfile` оставлен, чтобы уже развёрнутые конфигурации не требовали правки.
|
||||
|
||||
Файловая часть выполняется непосредственно перед использованием пути: каждый родительский компонент обязан существовать и не быть symlink. Проверки только финального компонента недостаточно — symlink в середине цепочки перенаправляет операцию на файл, который никогда не настраивался.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user