PST-080: Hard-linked media files are silently left out of backups #809

Closed
opened 2026-10-10 10:56:49 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-080. Severity: Informational.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Informational
  • Category: archive-handling / data-loss edge
  • Location: src/postulo/core/backup.py (386-394,476-483)
  • Found by: file handling

Who can do it, and what they need. No attacker; a correctness edge in the archive writer that affects what a restore can bring back.

How it works.

  1. Two names under MEDIA_ROOT share one inode (an operator de-duplicated with hard links, or a storage layer did).
  2. tarfile.add(..., filter=_plain_files_only) adds the first as a regular file and presents the second as a LNKTYPE member; the filter keeps only isfile()/isdir(), so the second is dropped from the archive.
  3. verify_backup cannot notice (it checks only the database member), and a restore leaves the row pointing at a file that is not there.

Impact. A document missing after a restore, with no warning at backup time. Unlikely with files Django's storage writes itself.

Verification (verdict: confirmed; severity after verification: Informational). Impact confirmed as stated, with one addition: the manifest and the BackupReport both say the media count from _tree_stats (which counts every name), so the archive silently disagrees with its own manifest. Remediation (described): open the TarFile with dereference=True so a hard link is stored as a plain copy (note this also follows symlinks, which the current filter deliberately excludes), or keep the filter but have it turn a LNKTYPE member into a REGTYPE copy of its target, and/or have write_backup count members actually added and raise or warn when that differs from _tree_stats. A regression test with os.link under MEDIA_ROOT belongs in tests/test_backup.py.

Suggested remediation. In the filter, turn a hard-link member into a regular file (info.type = tarfile.REGTYPE with the size and the content of the target) or pass dereference=True-style handling for islnk(); count what the filter dropped and record it in the manifest so verification can mention it.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-080**. Severity: **Informational**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Informational - **Category:** archive-handling / data-loss edge - **Location:** `src/postulo/core/backup.py` (386-394,476-483) - **Found by:** file handling **Who can do it, and what they need.** No attacker; a correctness edge in the archive writer that affects what a restore can bring back. **How it works.** 1. Two names under MEDIA_ROOT share one inode (an operator de-duplicated with hard links, or a storage layer did). 2. `tarfile.add(..., filter=_plain_files_only)` adds the first as a regular file and presents the second as a LNKTYPE member; the filter keeps only `isfile()`/`isdir()`, so the second is dropped from the archive. 3. `verify_backup` cannot notice (it checks only the database member), and a restore leaves the row pointing at a file that is not there. **Impact.** A document missing after a restore, with no warning at backup time. Unlikely with files Django's storage writes itself. **Verification** (verdict: confirmed; severity after verification: Informational). Impact confirmed as stated, with one addition: the manifest and the BackupReport both say the media count from `_tree_stats` (which counts every name), so the archive silently disagrees with its own manifest. Remediation (described): open the TarFile with `dereference=True` so a hard link is stored as a plain copy (note this also follows symlinks, which the current filter deliberately excludes), or keep the filter but have it turn a LNKTYPE member into a REGTYPE copy of its target, and/or have `write_backup` count members actually added and raise or warn when that differs from `_tree_stats`. A regression test with `os.link` under MEDIA_ROOT belongs in tests/test_backup.py. **Suggested remediation.** In the filter, turn a hard-link member into a regular file (`info.type = tarfile.REGTYPE` with the size and the content of the target) or pass `dereference=True`-style handling for `islnk()`; count what the filter dropped and record it in the manifest so verification can mention it.
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:12 +00:00
Author
Owner

Fixed in 672bdbf9c on main.

Fixed in 672bdbf9c on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#809
No description provided.