fix(annotations): deny case and Unicode spellings of a listed file

On macOS (case- and normalization-insensitive APFS) a crafted target such
as A.PNG or an NFD name opened the listed a.png or NFC file while
realpath kept the requested spelling, so the tags were stored under a
second locator the Images filter never shows. Capture and verify now
require each path part to be spelled as its directory lists it and deny
any other case or Unicode spelling as an alias (409 source-changed, like
a symlink alias). A missing file with no other spelling keeps its usual
outcome.
This commit is contained in:
Vito Sansevero
2026-10-22 09:59:05 -07:00
parent 913b4689db
commit c6729927e6
3 changed files with 78 additions and 0 deletions
+12
View File
@@ -282,6 +282,18 @@ class TestObserve(AnnotationAPITestCase):
self.assertEqual(response.status, 400)
capture.assert_not_called()
async def test_observe_denies_case_spellings_of_the_listed_file(self):
make_png(self.output_dir / "sub" / "a.png")
for alias in ("sub/A.PNG", "SUB/a.png"):
with self.subTest(alias=alias):
status, body = await self.observe({"target": self.target(alias)})
# Denied as an alias, like a symlink to the listed file.
self.assertEqual((status, body["code"]), (409, "source-changed"))
self.assert_no_paths(body)
store = self.api._captures
self.assertFalse(any(Path(store.directory).iterdir()))
await self.observe_ok("sub/a.png")
async def test_observe_returns_saved_tags_for_exact_bytes_only(self):
path = make_png(self.output_dir / "sub" / "a.png")
original = path.read_bytes()
+36
View File
@@ -7,6 +7,7 @@ import struct
import tempfile
import threading
import time
import unicodedata
import unittest
import warnings
import zlib
@@ -240,6 +241,41 @@ class CaptureStoreTestCase(unittest.TestCase):
self.capture(store, "alias.png")
self.assert_empty(store)
def test_case_and_unicode_spellings_of_a_listed_file_are_denied(self):
# On a case- or normalization-insensitive filesystem (macOS APFS,
# Windows) these spellings open the listed file while realpath keeps
# the requested spelling, which would store its tags under a second
# locator. Every OS denies them; the listed spelling is the control.
nfc = unicodedata.normalize("NFC", "caf\u00e9.png")
nfd = unicodedata.normalize("NFD", nfc)
self.assertNotEqual(nfc, nfd)
self.write("a.png", png_bytes(1, 1))
self.write(nfc, png_bytes(1, 1))
self.write("Sub/b.png", png_bytes(1, 1))
store = self.make_store()
for relative in ("a.png", nfc, "Sub/b.png"):
with self.subTest(listed=relative):
cap = self.capture(store, relative)
self.assertEqual(
cap.locator,
os.path.normcase(
os.path.join(os.path.realpath(self.root), *relative.split("/"))
),
)
self.assertTrue(store.release(cap.token))
for alias in ("A.PNG", "A.png", nfd, "sub/b.png", "SUB/B.PNG", "Sub/B.png"):
with self.subTest(alias=alias):
with self.assertRaises(CaptureDenied):
self.capture(store, alias)
with self.assertRaises(CaptureDenied):
store.verify(self.root, alias)
# A missing file with no other spelling keeps its usual outcome.
with self.assertRaises(CaptureChanged):
self.capture(store, "Sub/missing.png")
self.assert_empty(store)
assert_closed(self, self.parent)
def test_locator_matches_listing_identity_for_symlinked_root(self):
real_root = self.parent / "real-output"
real_root.mkdir()
+30
View File
@@ -26,6 +26,7 @@ import stat as stat_module
import tempfile
import threading
import time
import unicodedata
from dataclasses import dataclass
from typing import Callable
@@ -652,4 +653,33 @@ def _resolve(root, parts: list[str]) -> tuple[str, str]:
locator = os.path.normcase(candidate)
if os.path.normcase(os.path.realpath(candidate)) != locator:
raise CaptureDenied("The file is not a listed rooted file")
_deny_spelling_aliases(real_root, parts)
return candidate, locator
def _spelling_key(name: str) -> str:
return unicodedata.normalize("NFC", name).casefold()
def _deny_spelling_aliases(real_root: str, parts: list[str]) -> None:
"""Deny a case or Unicode spelling of an entry the directory spells otherwise.
On a case- or normalization-insensitive filesystem (macOS APFS, Windows)
``A.PNG`` or an NFD name opens the listed ``a.png`` or NFC name, while
``realpath`` keeps the requested spelling (macOS), so the tags would be
stored under a second locator. Each part must be spelled as its directory
lists it. A part with no entry of any spelling is left to the open that
follows (a missing file keeps its usual outcome).
"""
directory = real_root
for part in parts:
try:
names = os.listdir(directory)
except OSError:
return
if part not in names:
key = _spelling_key(part)
if any(_spelling_key(name) == key for name in names):
raise CaptureDenied("The file is not a listed rooted file")
return
directory = os.path.join(directory, part)