termux-sync 1.2.2__tar.gz → 1.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,18 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ---
13
+
14
+ ## [1.2.4] - 2026-07-28
15
+
16
+ ### Security
17
+
18
+ - **`restore` — arbitrary file write via attacker-controlled `source` field in `manifest.json` (High, GHSA-259w-wmqg-fp76)** — during restore, `manifest["files"][key]["source"]` was taken from the backup archive itself (fully attacker-controlled) and used directly as the extraction destination, and `extract_archive()` called `tarfile.extract()` on every member with no path-containment check. A malicious or tampered backup could therefore write/overwrite arbitrary files anywhere the Termux user has write access, including shell startup files. Fixed by: (1) deriving the extraction destination only from the app's own known `BACKUP_ITEMS` roots instead of manifest content, refusing unknown manifest keys; (2) validating every tar member's resolved path (and symlink/hardlink targets) stays inside the destination before extraction, and rejecting device/fifo entries; (3) using Python's built-in `filter="data"` extraction filter on 3.12+; (4) sanitizing `backup_name` before it's used to build the temp restore path. Reported privately by [Niranj R Mahaswar](https://github.com/NiranjMahaswar).
19
+
20
+ ### Fixed
21
+
22
+ - **`delete` (GitHub storage) — `Failed to delete backup from GitHub: Expecting value: line 1 column 1 (char 0)`** — `GitHubStorage._api()` unconditionally called `json.loads()` on every API response, but GitHub's REST API returns `204 No Content` with an empty body for `DELETE /releases/{id}` and `DELETE /git/refs/tags/{tag}`, so the empty body failed JSON parsing and the delete was reported as failed (even when it actually succeeded on GitHub's side). `_api()` now returns `{}` for empty/204 responses instead of trying to parse them as JSON.
23
+
12
24
  ### Planned
13
25
 
14
26
  - Archive encryption using a user-provided password
@@ -18,6 +30,14 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
18
30
 
19
31
  ---
20
32
 
33
+ ## [1.2.3] - 2026-07-11
34
+
35
+ ### Fixed
36
+
37
+ - Bump version in `termux-sync.py` and `__init__.py` for pip installer
38
+
39
+ ---
40
+
21
41
  ## [1.2.2] - 2026-07-11
22
42
 
23
43
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termux-sync
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Backup and restore your entire Termux environment across devices
5
5
  Project-URL: Homepage, https://github.com/djunekz/termux-sync
6
6
  Project-URL: Repository, https://github.com/djunekz/termux-sync
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "termux-sync"
7
- version = "1.2.2"
7
+ version = "1.2.4"
8
8
  description = "Backup and restore your entire Termux environment across devices"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -43,7 +43,7 @@ try:
43
43
  except ImportError:
44
44
  RICH_AVAILABLE = False
45
45
 
46
- VERSION = "1.2.1"
46
+ VERSION = "1.2.4"
47
47
  APP_NAME = "termux-sync"
48
48
  CONFIG_DIR = Path.home() / ".config" / APP_NAME
49
49
  CONFIG_FILE = CONFIG_DIR / "config.json"
@@ -277,18 +277,62 @@ def create_archive(
277
277
  return out
278
278
 
279
279
 
280
+ def _safe_extract_member(member: tarfile.TarInfo, dest_path: Path):
281
+ dest_root = dest_path.resolve()
282
+
283
+ member_name = member.name
284
+ if member_name.startswith("/") or member_name.startswith("\\"):
285
+ raise ValueError(f"Unsafe absolute path in archive: {member_name!r}")
286
+ if any(part == ".." for part in Path(member_name).parts):
287
+ raise ValueError(f"Path traversal attempt in archive: {member_name!r}")
288
+
289
+ target = (dest_path / member_name).resolve()
290
+ try:
291
+ target.relative_to(dest_root)
292
+ except ValueError:
293
+ raise ValueError(f"Archive member escapes destination: {member_name!r} -> {target}")
294
+
295
+ if member.issym() or member.islnk():
296
+ link_target = member.linkname
297
+ if os.path.isabs(link_target):
298
+ raise ValueError(f"Unsafe absolute link target in archive: {member_name!r} -> {link_target!r}")
299
+ resolved_link = (target.parent / link_target).resolve()
300
+ try:
301
+ resolved_link.relative_to(dest_root)
302
+ except ValueError:
303
+ raise ValueError(f"Archive link escapes destination: {member_name!r} -> {link_target!r}")
304
+
305
+ if member.ischr() or member.isblk() or member.isfifo():
306
+ raise ValueError(f"Refusing device/fifo entry in archive: {member_name!r}")
307
+
308
+
280
309
  def extract_archive(archive_path: Path, dest_path: Path, label: str, progress: Progress):
281
310
  task = progress.add_task(f"[cyan]{label}", total=None)
282
311
  dest_path.mkdir(parents=True, exist_ok=True)
312
+
313
+ has_data_filter = hasattr(tarfile, "data_filter")
314
+
283
315
  with tarfile.open(archive_path, "r:*") as tf:
284
316
  for member in tf.getmembers():
285
317
  try:
286
- tf.extract(member, dest_path, set_attrs=True)
318
+ _safe_extract_member(member, dest_path)
319
+ except ValueError as e:
320
+ log("WARNING", f"Skipped unsafe archive member during restore: {e}")
321
+ continue
322
+
323
+ try:
324
+ if has_data_filter:
325
+ tf.extract(member, dest_path, set_attrs=True, filter="data")
326
+ else:
327
+ tf.extract(member, dest_path, set_attrs=True)
287
328
  except Exception:
288
329
  try:
289
330
  member.uid = os.getuid()
290
331
  member.gid = os.getgid()
291
- tf.extract(member, dest_path, set_attrs=False)
332
+ if has_data_filter:
333
+ tf.extract(member, dest_path, set_attrs=False, filter="data")
334
+ else:
335
+ tf.extract(member, dest_path, set_attrs=False)
292
336
  except Exception:
293
337
  pass
294
338
  progress.update(task, total=1, completed=1, description=f"[green]{label} ✓")
@@ -485,7 +529,12 @@ class GitHubStorage:
485
529
  req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
486
530
  try:
487
531
  with urllib.request.urlopen(req) as resp:
488
- return json.loads(resp.read())
532
+ if resp.status == 204:
533
+ return {}
534
+ body = resp.read()
535
+ if not body:
536
+ return {}
537
+ return json.loads(body)
489
538
  except urllib.error.HTTPError as e:
490
539
  body = e.read().decode()
491
540
  raise RuntimeError(f"GitHub API error {e.code}: {body}") from e
@@ -930,6 +979,12 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
930
979
  except ValueError:
931
980
  backup_name = choice
932
981
 
982
+ safe_backup_name = os.path.basename(str(backup_name).replace("\\", "/")).strip()
983
+ if not safe_backup_name or safe_backup_name in (".", "..") or "/" in safe_backup_name:
984
+ console.print(f" [red]✗ Invalid backup name: {backup_name!r}[/red]")
985
+ return
986
+ backup_name = safe_backup_name
987
+
933
988
  tmp_dir = Path(os.environ.get("TMPDIR", "/data/data/com.termux/files/usr/tmp")) / f"restore_{backup_name}"
934
989
  tmp_dir.mkdir(parents=True, exist_ok=True)
935
990
 
@@ -1003,9 +1058,26 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
1003
1058
  TimeElapsedColumn(),
1004
1059
  console=console,
1005
1060
  ) as progress:
1061
+ # Build the set of destinations this app is actually willing to restore into.
1062
+ # Manifest-supplied "source" values are attacker-controlled backup content
1063
+ # and must never be trusted as an extraction root — only used to double
1064
+ # check they agree with the known-safe root for that item key.
1065
+ allowed_roots = {}
1066
+ for item_key, _label, item_root in BACKUP_ITEMS:
1067
+ if item_root is not None:
1068
+ allowed_roots[item_key] = Path(item_root).resolve().parent
1069
+
1006
1070
  for key, info in manifest.get("files", {}).items():
1007
1071
  archive = tmp_dir / info["archive"]
1008
- src_dir = Path(info.get("source", str(TERMUX_FILES))).parent
1072
+
1073
+ src_dir = allowed_roots.get(key)
1074
+ if src_dir is None:
1075
+ # Unknown key not part of this app's known backup items — refuse
1076
+ # to derive a destination from untrusted manifest content.
1077
+ console.print(f" [red]✗ Refusing unknown/untrusted restore target for {key!r}[/red]")
1078
+ log("WARNING", f"Refused restore for unknown manifest key: {key!r}")
1079
+ continue
1080
+
1009
1081
  try:
1010
1082
  extract_archive(archive, src_dir, info["archive"], progress)
1011
1083
  log("INFO", f"Extracted {info['archive']} → {src_dir}")
File without changes
File without changes