p3_wp14_secure_io.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """Fail-closed descriptor I/O: trusted root dirfd, openat parent chain and atomic output."""
  2. from __future__ import annotations
  3. import fcntl, json, os, secrets, stat, threading
  4. from contextlib import contextmanager
  5. from pathlib import Path
  6. from typing import Iterator
  7. import hashlib
  8. MAX_JSON_BYTES = 2 * 1024 * 1024
  9. TRUSTED_ROOT = Path(__file__).resolve().parents[1]
  10. _DIRECTORY_LOCKS: dict[tuple[int, int], threading.RLock] = {}
  11. _DIRECTORY_LOCKS_GUARD = threading.Lock()
  12. _DIRECTORY_LOCK_STATE = threading.local()
  13. def _safe(st: os.stat_result, name: str) -> None:
  14. if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1: raise ValueError(f"unsafe path: {name}")
  15. def _parent(path: Path) -> tuple[int, str]:
  16. if ".." in path.parts: raise ValueError("parent-traversal paths are forbidden")
  17. absolute = path if path.is_absolute() else TRUSTED_ROOT / path
  18. try: absolute.relative_to(TRUSTED_ROOT)
  19. except ValueError: raise ValueError("path outside trusted root")
  20. # Refuse a user-controlled leaf/parent before opening; every opened parent is O_NOFOLLOW.
  21. fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
  22. parts = absolute.parts[1:-1]
  23. try:
  24. for part in parts:
  25. nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), dir_fd=fd)
  26. os.close(fd); fd = nxt
  27. return fd, absolute.name
  28. except Exception:
  29. os.close(fd); raise
  30. @contextmanager
  31. def _locked_trusted_parent(parent: int) -> Iterator[None]:
  32. """Serialize a trusted directory across processes and re-entrantly in-process."""
  33. directory = os.fstat(parent)
  34. key = (directory.st_dev, directory.st_ino)
  35. with _DIRECTORY_LOCKS_GUARD:
  36. process_lock = _DIRECTORY_LOCKS.setdefault(key, threading.RLock())
  37. process_lock.acquire()
  38. held = getattr(_DIRECTORY_LOCK_STATE, "held", {})
  39. outermost = held.get(key, 0) == 0
  40. try:
  41. if outermost:
  42. fcntl.flock(parent, fcntl.LOCK_EX)
  43. held[key] = held.get(key, 0) + 1
  44. _DIRECTORY_LOCK_STATE.held = held
  45. yield
  46. finally:
  47. remaining = held.get(key, 1) - 1
  48. if remaining:
  49. held[key] = remaining
  50. else:
  51. held.pop(key, None)
  52. if outermost:
  53. fcntl.flock(parent, fcntl.LOCK_UN)
  54. process_lock.release()
  55. def read_bytes_once(path: Path, *, limit: int = MAX_JSON_BYTES) -> bytes:
  56. parent, leaf = _parent(path)
  57. try:
  58. with _locked_trusted_parent(parent):
  59. before = os.stat(leaf, dir_fd=parent, follow_symlinks=False); _safe(before, str(path))
  60. fd = os.open(leaf, os.O_RDONLY | getattr(os,"O_NOFOLLOW",0), dir_fd=parent)
  61. try:
  62. opened=os.fstat(fd); _safe(opened,str(path))
  63. if (opened.st_dev,opened.st_ino)!=(before.st_dev,before.st_ino): raise ValueError("leaf swapped")
  64. chunks=[]; total=0
  65. while (chunk:=os.read(fd,65536)):
  66. total+=len(chunk)
  67. if total>limit: raise ValueError("input too large")
  68. chunks.append(chunk)
  69. after=os.fstat(fd)
  70. if any(getattr(opened,k)!=getattr(after,k) for k in ("st_dev","st_ino","st_mode","st_nlink","st_size","st_mtime_ns","st_ctime_ns")): raise ValueError("input changed")
  71. return b"".join(chunks)
  72. finally: os.close(fd)
  73. finally: os.close(parent)
  74. def read_json_once(path: Path, *, limit: int=MAX_JSON_BYTES)->object: return json.loads(read_bytes_once(path,limit=limit))
  75. def sha256_file_once(path: Path, *, limit: int = MAX_JSON_BYTES) -> str:
  76. """Stream a stable regular file through SHA-256 with the same FD checks."""
  77. parent, leaf = _parent(path)
  78. try:
  79. with _locked_trusted_parent(parent):
  80. before = os.stat(leaf, dir_fd=parent, follow_symlinks=False); _safe(before, str(path))
  81. fd = os.open(leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
  82. try:
  83. opened = os.fstat(fd); _safe(opened, str(path))
  84. if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): raise ValueError("leaf swapped")
  85. digest = hashlib.sha256(); total = 0
  86. while (chunk := os.read(fd, 65536)):
  87. total += len(chunk)
  88. if total > limit: raise ValueError("input too large")
  89. digest.update(chunk)
  90. after = os.fstat(fd)
  91. if any(getattr(opened,k)!=getattr(after,k) for k in ("st_dev","st_ino","st_mode","st_nlink","st_size","st_mtime_ns","st_ctime_ns")): raise ValueError("input changed")
  92. return digest.hexdigest()
  93. finally: os.close(fd)
  94. finally: os.close(parent)
  95. def _verify_temp_matches_open_fd(parent: int, temporary: str, fd: int) -> None:
  96. """Refuse a name swap before publishing a temp file by rename."""
  97. expected = os.fstat(fd)
  98. _safe(expected, temporary)
  99. verify = os.open(temporary, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
  100. try:
  101. observed = os.fstat(verify)
  102. _safe(observed, temporary)
  103. if (expected.st_dev, expected.st_ino, expected.st_nlink, stat.S_IFMT(expected.st_mode)) != (
  104. observed.st_dev, observed.st_ino, observed.st_nlink, stat.S_IFMT(observed.st_mode)
  105. ):
  106. raise ValueError("temp swapped before atomic replace")
  107. finally:
  108. os.close(verify)
  109. def _verify_published_leaf(parent: int, leaf: str, expected: os.stat_result, expected_digest: str) -> None:
  110. """Verify that rename published the exact checked temp inode, not a swapped name."""
  111. fd = os.open(leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
  112. try:
  113. observed = os.fstat(fd); _safe(observed, leaf)
  114. if (expected.st_dev, expected.st_ino, stat.S_IFMT(expected.st_mode)) != (observed.st_dev, observed.st_ino, stat.S_IFMT(observed.st_mode)):
  115. raise ValueError("published leaf swapped after atomic replace")
  116. digest = hashlib.sha256()
  117. while (chunk := os.read(fd, 65536)): digest.update(chunk)
  118. if digest.hexdigest() != expected_digest:
  119. raise ValueError("published leaf digest mismatch")
  120. finally:
  121. os.close(fd)
  122. def atomic_write_bytes(path: Path,data: bytes)->None:
  123. """Cooperatively publish only a fully fsynced new or previous record.
  124. Readers and writers take the same trusted-parent lock. A temporary inode
  125. is therefore never observable by cooperative readers. An uncooperative
  126. same-UID pathname mutation is detected after publication; it is not a
  127. trusted publisher, and the previous verified inode is restored before the
  128. lock is released.
  129. """
  130. parent, leaf = _parent(path)
  131. temporary = f".{leaf}.{secrets.token_hex(16)}.tmp"
  132. backup = f".{leaf}.{secrets.token_hex(16)}.bak"
  133. temp_exists = False
  134. backup_exists = False
  135. published = False
  136. old_expected: os.stat_result | None = None
  137. old_digest: str | None = None
  138. try:
  139. with _locked_trusted_parent(parent):
  140. try:
  141. old_expected = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
  142. _safe(old_expected, leaf)
  143. old_fd = os.open(leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
  144. try:
  145. if (os.fstat(old_fd).st_dev, os.fstat(old_fd).st_ino) != (old_expected.st_dev, old_expected.st_ino):
  146. raise ValueError("previous leaf swapped before publication")
  147. digest = hashlib.sha256()
  148. while chunk := os.read(old_fd, 65536): digest.update(chunk)
  149. old_digest = digest.hexdigest()
  150. finally:
  151. os.close(old_fd)
  152. except FileNotFoundError:
  153. old_expected = None
  154. fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=parent)
  155. temp_exists = True
  156. try:
  157. offset = 0
  158. while offset < len(data):
  159. written = os.write(fd, data[offset:])
  160. if written <= 0: raise OSError("short write")
  161. offset += written
  162. os.fsync(fd)
  163. expected = os.fstat(fd); _safe(expected, temporary)
  164. _verify_temp_matches_open_fd(parent, temporary, fd)
  165. finally:
  166. os.close(fd)
  167. try:
  168. if old_expected is not None:
  169. os.replace(leaf, backup, src_dir_fd=parent, dst_dir_fd=parent)
  170. backup_exists = True
  171. _verify_published_leaf(parent, backup, old_expected, old_digest or "")
  172. os.replace(temporary, leaf, src_dir_fd=parent, dst_dir_fd=parent)
  173. temp_exists = False
  174. published = True
  175. _verify_published_leaf(parent, leaf, expected, hashlib.sha256(data).hexdigest())
  176. os.fsync(parent)
  177. except Exception:
  178. if backup_exists and old_expected is not None:
  179. os.replace(backup, leaf, src_dir_fd=parent, dst_dir_fd=parent)
  180. backup_exists = False
  181. _verify_published_leaf(parent, leaf, old_expected, old_digest or "")
  182. os.fsync(parent)
  183. elif published:
  184. os.unlink(leaf, dir_fd=parent)
  185. os.fsync(parent)
  186. raise
  187. if backup_exists:
  188. os.unlink(backup, dir_fd=parent)
  189. backup_exists = False
  190. os.fsync(parent)
  191. finally:
  192. # A failure before publication leaves the old final record untouched.
  193. if temp_exists:
  194. try:
  195. os.unlink(temporary, dir_fd=parent)
  196. os.fsync(parent)
  197. except FileNotFoundError:
  198. pass
  199. if backup_exists:
  200. try:
  201. os.replace(backup, leaf, src_dir_fd=parent, dst_dir_fd=parent)
  202. os.fsync(parent)
  203. except FileNotFoundError:
  204. pass
  205. os.close(parent)
  206. @contextmanager
  207. def exclusive_lock(path: Path)->Iterator[str]:
  208. parent,_leaf=_parent(path)
  209. try:
  210. # The directory inode, never a replaceable marker filename, is the lock identity.
  211. with _locked_trusted_parent(parent):
  212. yield secrets.token_urlsafe(24)
  213. finally:
  214. os.close(parent)