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.
- {termux_sync-1.2.2 → termux_sync-1.2.4}/CHANGELOG.md +20 -0
- {termux_sync-1.2.2 → termux_sync-1.2.4}/PKG-INFO +1 -1
- {termux_sync-1.2.2 → termux_sync-1.2.4}/pyproject.toml +1 -1
- {termux_sync-1.2.2 → termux_sync-1.2.4}/src/termux_sync/__init__.py +77 -5
- {termux_sync-1.2.2 → termux_sync-1.2.4}/LICENSE +0 -0
- {termux_sync-1.2.2 → termux_sync-1.2.4}/README.md +0 -0
|
@@ -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.
|
|
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
|
|
@@ -43,7 +43,7 @@ try:
|
|
|
43
43
|
except ImportError:
|
|
44
44
|
RICH_AVAILABLE = False
|
|
45
45
|
|
|
46
|
-
VERSION = "1.2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|