Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 60: [PATCH 60/68] fix(release): validate internal macOS framework symlinks

ci/release/archive.py

Before: ci/release/archive.py · After: ci/release/archive.py

File metadata
index 6e2091bb7b68ac6c05297ecf5f7d3c2b5ad7766f..26227cc03a77c0acda9a4226d83858298a7ba819 100755

Download original patch · Release notes

Diff rows 1–96 of 96. Line numbers refer to the original before/after files.

+ Added− RemovedUnmarked lines are context.

Scroll the code area horizontally to read long lines. The two line-number columns refer to before and after.

ci/release/archive.py CHANGED
@@ -4,6 +4,7 @@
4
4
  # file COPYING or http://www.opensource.org/licenses/mit-license.php.
5
5
 
6
6
  import argparse
7
+ from collections import deque
7
8
  import os
8
9
  from pathlib import Path
9
10
  import re
@@ -44,6 +45,8 @@ def validate_archive_root(archive, expected_root):
44
45
  raise ValueError(f"Release archive is empty: {archive.name}")
45
46
 
46
47
  has_payload = False
48
+ paths = set()
49
+ symlinks = {}
47
50
  for member in members:
48
51
  name = member.name if isinstance(member, tarfile.TarInfo) else member.filename
49
52
  normalized = name.rstrip("/")
@@ -52,21 +55,79 @@ def validate_archive_root(archive, expected_root):
52
55
  parts = normalized.split("/")
53
56
  if "\\" in normalized or any(part in {"", ".", ".."} for part in parts):
54
57
  raise ValueError(f"Unsafe path in {archive.name}: {name}")
58
+ if normalized in paths:
59
+ raise ValueError(f"Duplicate archive member in {archive.name}: {name}")
60
+ paths.add(normalized)
55
61
  if isinstance(member, tarfile.TarInfo):
56
62
  if not (member.isfile() or member.isdir()):
57
63
  raise ValueError(f"Unsupported archive member in {archive.name}: {name}")
58
64
  is_regular_file = member.isfile()
59
65
  else:
60
66
  member_type = (member.external_attr >> 16) & 0o170000
61
- if member_type not in {0, 0o100000, 0o40000}:
67
+ if member_type not in {0, 0o100000, 0o40000, 0o120000}:
62
68
  raise ValueError(f"Unsupported archive member in {archive.name}: {name}")
63
- is_regular_file = not member.is_dir()
69
+ if member_type == 0o120000:
70
+ symlinks[normalized] = member
71
+ is_regular_file = not member.is_dir() and member_type != 0o120000
64
72
  if parts[0] != expected_root:
65
73
  raise ValueError(
66
74
  f"Expected root directory {expected_root} in {archive.name}, found: {name}"
67
75
  )
68
76
  has_payload |= len(parts) > 1 and is_regular_file
69
77
 
78
+ if symlinks:
79
+ with zipfile.ZipFile(archive) as package:
80
+ for name, member in symlinks.items():
81
+ if not 0 < member.file_size <= 4096:
82
+ raise ValueError(f"Invalid symlink target in {archive.name}: {name}")
83
+ try:
84
+ target = package.read(member).decode("utf-8")
85
+ except UnicodeDecodeError as error:
86
+ raise ValueError(f"Invalid symlink target in {archive.name}: {name}") from error
87
+ if target.startswith("/") or "\\" in target or "\0" in target or re.match(r"^[A-Za-z]:", target):
88
+ raise ValueError(f"Unsafe symlink target in {archive.name}: {name}")
89
+ symlinks[name] = target
90
+
91
+ def resolve_target(path):
92
+ pending = deque(path.split("/"))
93
+ parts = []
94
+ expanding = set()
95
+ while pending:
96
+ part = pending.popleft()
97
+ # End markers limit cycle detection to active target expansion.
98
+ # A later path component may legitimately traverse the same link.
99
+ if isinstance(part, tuple):
100
+ expanding.remove(part[0])
101
+ continue
102
+ if part in {"", "."}:
103
+ continue
104
+ if part == "..":
105
+ if len(parts) <= 1:
106
+ raise ValueError(f"Symlink escapes archive root in {archive.name}: {path}")
107
+ parts.pop()
108
+ continue
109
+ parts.append(part)
110
+ if parts[0] != expected_root:
111
+ raise ValueError(f"Symlink escapes archive root in {archive.name}: {path}")
112
+ prefix = "/".join(parts)
113
+ if prefix in symlinks:
114
+ if prefix in expanding:
115
+ raise ValueError(f"Symlink cycle in {archive.name}: {prefix}")
116
+ expanding.add(prefix)
117
+ parts.pop()
118
+ pending.extendleft(reversed([*symlinks[prefix].split("/"), (prefix,)]))
119
+ resolved = "/".join(parts)
120
+ if not parts or (resolved not in paths and not any(name.startswith(resolved + "/") for name in paths)):
121
+ raise ValueError(f"Missing symlink target in {archive.name}: {path}")
122
+ return resolved
123
+
124
+ for name in symlinks:
125
+ resolve_target(name)
126
+ # Member paths may themselves pass through a link. Validate those too,
127
+ # so extraction cannot redirect an otherwise valid-looking payload.
128
+ for name in paths:
129
+ resolve_target(name)
130
+
70
131
  if not has_payload:
71
132
  raise ValueError(f"Release archive has no files below {expected_root}: {archive.name}")
72
133