Skip to content

Commit a0bf1d5

Browse files
authored
fix(media): correct legacy folder resolution, ACL bypass, and review findings
* fix(media): correct fallback logic, ACL bypass, and migration cleanup - Extract shared media_utils.py with proper arithmetic for Telegram marked-ID resolution (fixes channel fallback producing wrong paths) - Fix ACL bypass: enforce access control against resolved path, not URL - Fix entrypoint crash when media table doesn't exist on pre-v6 databases - Remove dead _BATCH_SIZE constant and identical dialect branches in migration - Add try/except for RuntimeError (symlink loops) in fallback paths - Add debug logging to fallback and ACL legacy grant paths - Fix except syntax to use parenthesized tuple form - Add 25 unit tests for media_utils with roundtrip consistency checks - Bump version to 7.10.15 * fix(media): handle non-numeric folder names in legacy_folder_alternates Return empty list for non-numeric folders (e.g., "chat1") instead of raising ValueError. Fixes test_web_thumbnails failure on CI. * style: format test_media_utils.py * fix(media): address review findings — PII logs, thumbnail ACL, boundary tests - Strip chat IDs from all debug log statements (PII compliance) - Fix thumbnail ACL bypass: ensure_thumbnail now returns resolved folder so serve_thumbnail checks ACL on the actual resolved path - Simplify serve_media path reconstruction (str(relative_to) vs manual join) - Guard folder_int <= 0 in legacy_folder_alternates - Fix import style in thumbnails.py (absolute → relative) - Add boundary tests for exact CHANNEL_ID_OFFSET inflection point - Add integration tests for ACL bypass scenario (deny + allow) * fix(media): restore early ACL check in serve_thumbnail for defense-in-depth Keep the pre-resolution ACL check (prevents file existence leakage), and add a secondary check only when the resolved folder differs from the requested folder (prevents bypass via legacy fallback).
1 parent 04dce12 commit a0bf1d5

10 files changed

Lines changed: 389 additions & 100 deletions

File tree

alembic/versions/20260524_013_fix_media_file_paths.py

Lines changed: 34 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
branch_labels = None
2424
depends_on = None
2525

26-
_BATCH_SIZE = 5000
26+
# Can't import from src.web.media_utils in migrations (different runtime context)
27+
# Define locally to keep migration self-contained
28+
_CHANNEL_ID_OFFSET = 1_000_000_000_000
2729

2830

2931
def _derive_stale_folder(chat_id: int) -> str | None:
@@ -36,71 +38,46 @@ def _derive_stale_folder(chat_id: int) -> str | None:
3638
if chat_id >= 0:
3739
return None
3840
raw = -chat_id
39-
if raw > 1000000000000:
40-
return str(raw - 1000000000000)
41+
if raw > _CHANNEL_ID_OFFSET:
42+
return str(raw - _CHANNEL_ID_OFFSET)
4143
return str(raw)
4244

4345

4446
def upgrade():
4547
conn = op.get_bind()
46-
dialect = conn.dialect.name
47-
48-
if dialect == "postgresql":
49-
# Get all distinct negative chat_ids that have media
50-
result = conn.execute(sa.text("SELECT DISTINCT chat_id FROM media WHERE chat_id < 0 AND file_path IS NOT NULL"))
51-
chat_ids = [row[0] for row in result]
52-
53-
for chat_id in chat_ids:
54-
stale_folder = _derive_stale_folder(chat_id)
55-
if stale_folder is None:
56-
continue
57-
correct_folder = str(chat_id)
58-
59-
# Only update rows where file_path contains the stale folder
60-
# Use pattern: .../<stale_folder>/... → .../<correct_folder>/...
61-
stale_pattern = f"%/{stale_folder}/%"
62-
conn.execute(
63-
sa.text(
64-
"UPDATE media SET file_path = REPLACE(file_path, :old_seg, :new_seg) "
65-
"WHERE chat_id = :cid AND file_path LIKE :pattern"
66-
),
67-
{
68-
"old_seg": f"/{stale_folder}/",
69-
"new_seg": f"/{correct_folder}/",
70-
"cid": chat_id,
71-
"pattern": stale_pattern,
72-
},
73-
)
74-
75-
elif dialect == "sqlite":
76-
result = conn.execute(sa.text("SELECT DISTINCT chat_id FROM media WHERE chat_id < 0 AND file_path IS NOT NULL"))
77-
chat_ids = [row[0] for row in result]
78-
79-
for chat_id in chat_ids:
80-
stale_folder = _derive_stale_folder(chat_id)
81-
if stale_folder is None:
82-
continue
83-
correct_folder = str(chat_id)
84-
85-
stale_pattern = f"%/{stale_folder}/%"
86-
conn.execute(
87-
sa.text(
88-
"UPDATE media SET file_path = REPLACE(file_path, :old_seg, :new_seg) "
89-
"WHERE chat_id = :cid AND file_path LIKE :pattern"
90-
),
91-
{
92-
"old_seg": f"/{stale_folder}/",
93-
"new_seg": f"/{correct_folder}/",
94-
"cid": chat_id,
95-
"pattern": stale_pattern,
96-
},
97-
)
48+
49+
# Get all distinct negative chat_ids that have media
50+
result = conn.execute(sa.text("SELECT DISTINCT chat_id FROM media WHERE chat_id < 0 AND file_path IS NOT NULL"))
51+
chat_ids = [row[0] for row in result]
52+
53+
for chat_id in chat_ids:
54+
stale_folder = _derive_stale_folder(chat_id)
55+
if stale_folder is None:
56+
continue
57+
correct_folder = str(chat_id)
58+
59+
# Only update rows where file_path contains the stale folder
60+
# Use pattern: .../<stale_folder>/... → .../<correct_folder>/...
61+
stale_pattern = f"%/{stale_folder}/%"
62+
conn.execute(
63+
sa.text(
64+
"UPDATE media SET file_path = REPLACE(file_path, :old_seg, :new_seg) "
65+
"WHERE chat_id = :cid AND file_path LIKE :pattern"
66+
),
67+
{
68+
"old_seg": f"/{stale_folder}/",
69+
"new_seg": f"/{correct_folder}/",
70+
"cid": chat_id,
71+
"pattern": stale_pattern,
72+
},
73+
)
9874

9975

10076
def downgrade():
101-
# Reversible: swap the folder components back
77+
# WARNING: This reverses ALL negative-folder paths to positive, including rows
78+
# created after the upgrade. This is intentional — old code expects positive
79+
# folders in file_path. The runtime fallback handles disk resolution.
10280
conn = op.get_bind()
103-
dialect = conn.dialect.name
10481

10582
result = conn.execute(sa.text("SELECT DISTINCT chat_id FROM media WHERE chat_id < 0 AND file_path IS NOT NULL"))
10683
chat_ids = [row[0] for row in result]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "telegram-archive"
7-
version = "7.10.14"
7+
version = "7.10.15"
88
description = "Automated Telegram backup with Docker. Performs incremental backups of messages and media on a configurable schedule."
99
readme = "README.md"
1010
requires-python = ">=3.14"

scripts/entrypoint.sh

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,24 @@ if has_tables and not has_alembic:
9393
);
9494
\"\"\")
9595
# Check artifact from migration 013: file_path values use negative chat_id folders
96-
# If any media row for a negative chat_id has a correctly-negative folder, 013 has run
96+
# Guard: media table may not exist on very old databases
9797
cur.execute(\"\"\"
9898
SELECT EXISTS (
99-
SELECT 1 FROM media
100-
WHERE chat_id < 0 AND file_path LIKE '%/' || CAST(chat_id AS TEXT) || '/%'
101-
LIMIT 1
99+
SELECT FROM information_schema.tables
100+
WHERE table_name = 'media'
102101
);
103102
\"\"\")
104-
has_013_paths = cur.fetchone()[0]
103+
has_media_table = cur.fetchone()[0]
104+
has_013_paths = False
105+
if has_media_table:
106+
cur.execute(\"\"\"
107+
SELECT EXISTS (
108+
SELECT 1 FROM media
109+
WHERE chat_id < 0 AND file_path LIKE '%/' || CAST(chat_id AS TEXT) || '/%'
110+
LIMIT 1
111+
);
112+
\"\"\")
113+
has_013_paths = cur.fetchone()[0]
105114
106115
# Check artifact from migration 012: idx_media_chat_type index
107116
cur.execute(\"\"\"
@@ -300,8 +309,13 @@ if has_tables and not has_alembic:
300309
''')
301310
302311
# Check artifact from migration 013: file_path values use negative chat_id folders
303-
cur.execute(\"SELECT EXISTS(SELECT 1 FROM media WHERE chat_id < 0 AND file_path LIKE '%/' || CAST(chat_id AS TEXT) || '/%' LIMIT 1)\")
304-
has_013_paths = cur.fetchone()[0]
312+
# Guard: media table may not exist on very old databases
313+
cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='media'\")
314+
has_media_table = cur.fetchone() is not None
315+
has_013_paths = False
316+
if has_media_table:
317+
cur.execute(\"SELECT EXISTS(SELECT 1 FROM media WHERE chat_id < 0 AND file_path LIKE '%/' || CAST(chat_id AS TEXT) || '/%' LIMIT 1)\")
318+
has_013_paths = cur.fetchone()[0]
305319
306320
# Check artifact from migration 012: idx_media_chat_type index
307321
cur.execute(\"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_media_chat_type'\")

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Telegram Backup Automation - Main Package
33
"""
44

5-
__version__ = "7.10.14"
5+
__version__ = "7.10.15"

src/web/main.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from ..config import Config
3232
from ..db import DatabaseAdapter, close_database, get_db_manager, init_database
3333
from ..realtime import RealtimeListener
34+
from .media_utils import legacy_folder_alternates, legacy_marked_chat_ids
3435

3536
if TYPE_CHECKING:
3637
from .push import PushNotificationManager
@@ -821,8 +822,8 @@ def _enforce_media_acl(path: str, user: UserContext, *, thumbnail: bool = False)
821822
raise HTTPException(status_code=403, detail="Access denied")
822823
if media_chat_id not in user_chat_ids:
823824
# Legacy fallback: positive folder may correspond to negative marked ID
824-
if media_chat_id > 0 and (-media_chat_id in user_chat_ids or -(1000000000000 + media_chat_id) in user_chat_ids):
825-
pass
825+
if media_chat_id > 0 and any(mid in user_chat_ids for mid in legacy_marked_chat_ids(media_chat_id)):
826+
logger.debug("ACL legacy grant: positive folder mapped to allowed chat via marked-ID convention")
826827
else:
827828
raise HTTPException(status_code=403, detail="Access denied")
828829

@@ -885,7 +886,7 @@ async def serve_thumbnail(size: int, folder: str, filename: str, user: UserConte
885886
if user.no_download and not folder.startswith("avatars/"):
886887
raise HTTPException(status_code=403, detail="Downloads disabled for this account")
887888

888-
# Chat-level access check
889+
# Early ACL check on requested path (prevents existence leakage)
889890
_enforce_media_acl(f"{folder}/{filename}", user, thumbnail=True)
890891

891892
from .thumbnails import ensure_thumbnail, resolve_cache_dir
@@ -894,10 +895,15 @@ async def serve_thumbnail(size: int, folder: str, filename: str, user: UserConte
894895
if _thumb_cache_dir is None:
895896
_thumb_cache_dir = resolve_cache_dir(_media_root)
896897

897-
thumb_path = await ensure_thumbnail(_media_root, size, folder, filename, cache_dir=_thumb_cache_dir)
898-
if not thumb_path:
898+
result = await ensure_thumbnail(_media_root, size, folder, filename, cache_dir=_thumb_cache_dir)
899+
if not result:
899900
raise HTTPException(status_code=404, detail="Thumbnail not available")
900901

902+
thumb_path, resolved_folder = result
903+
# Secondary ACL on resolved path if it differs (prevents bypass via legacy fallback)
904+
if resolved_folder != folder:
905+
_enforce_media_acl(f"{resolved_folder}/{filename}", user, thumbnail=True)
906+
901907
return FileResponse(thumb_path, media_type="image/webp", headers={"Cache-Control": "public, max-age=86400"})
902908

903909

@@ -928,23 +934,20 @@ async def serve_media(path: str, download: int = Query(0), user: UserContext = D
928934
resolved = None
929935
if len(parts) == 2:
930936
folder, rest = parts
931-
alt_folders = []
932-
if not folder.startswith("-"):
933-
alt_folders = [f"-{folder}", f"-100{folder}"]
934-
else:
935-
alt_folders = [folder[1:]]
937+
alt_folders = legacy_folder_alternates(folder)
936938
for alt in alt_folders:
937939
try:
938940
resolved = (_media_root / alt / rest).resolve(strict=True)
941+
logger.debug("Legacy fallback: served media via alternate folder resolution")
939942
break
940-
except OSError, ValueError:
943+
except OSError, ValueError, RuntimeError:
941944
continue
942945
if resolved is None:
943946
raise HTTPException(status_code=404, detail="File not found")
944947
if not resolved.is_relative_to(_media_root):
945948
raise HTTPException(status_code=403, detail="Access denied")
946949

947-
_enforce_media_acl(path, user)
950+
_enforce_media_acl(str(resolved.relative_to(_media_root)), user)
948951

949952
if not resolved.is_file():
950953
raise HTTPException(status_code=404, detail="File not found")

src/web/media_utils.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Shared utilities for legacy media path resolution.
2+
3+
Centralizes the Telegram marked-ID convention so it's defined once
4+
and used consistently across serve_media, thumbnails, and ACL checks.
5+
"""
6+
7+
CHANNEL_ID_OFFSET: int = 1_000_000_000_000
8+
9+
10+
def legacy_folder_alternates(folder: str) -> list[str]:
11+
"""Return alternate folder names for legacy positive/negative ID paths.
12+
13+
Forward (positive folder → possible negative marked IDs on disk):
14+
"1234567890" → ["-1234567890", "-1001234567890"]
15+
16+
Reverse (negative folder → possible old positive folder on disk):
17+
"-1234567890" → ["1234567890"] (basic group)
18+
"-1001234567890" → ["1234567890"] (channel)
19+
"""
20+
try:
21+
if not folder.startswith("-"):
22+
folder_int = int(folder)
23+
if folder_int <= 0:
24+
return []
25+
return [f"-{folder}", str(-(CHANNEL_ID_OFFSET + folder_int))]
26+
folder_int = int(folder)
27+
except ValueError:
28+
return []
29+
raw = -folder_int
30+
if raw > CHANNEL_ID_OFFSET:
31+
return [str(raw - CHANNEL_ID_OFFSET)]
32+
return [str(raw)]
33+
34+
35+
def legacy_marked_chat_ids(positive_id: int) -> list[int]:
36+
"""Return possible marked chat_ids for a legacy positive folder ID.
37+
38+
Used by ACL checks to determine if a user has access to a chat
39+
referenced by its old positive folder name.
40+
"""
41+
return [-positive_id, -(CHANNEL_ID_OFFSET + positive_id)]
42+
43+
44+
def derive_stale_folder(chat_id: int) -> str | None:
45+
"""Derive the old positive folder name from a marked chat_id.
46+
47+
Basic groups: chat_id = -X → old folder = "X"
48+
Channels: chat_id = -(10^12 + X) → old folder = "X"
49+
Users: chat_id > 0 → no mismatch possible, return None
50+
"""
51+
if chat_id >= 0:
52+
return None
53+
raw = -chat_id
54+
if raw > CHANNEL_ID_OFFSET:
55+
return str(raw - CHANNEL_ID_OFFSET)
56+
return str(raw)

src/web/thumbnails.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
from PIL import Image
1717

18+
from .media_utils import legacy_folder_alternates
19+
1820
logger = logging.getLogger(__name__)
1921

2022
# Limit decompression to prevent pixel-bomb OOM attacks (~50 megapixels)
@@ -87,11 +89,12 @@ def _generate_sync(source: Path, dest: Path, size: int) -> bool:
8789

8890
async def ensure_thumbnail(
8991
media_root: Path, size: int, folder: str, filename: str, *, cache_dir: Path | None = None
90-
) -> Path | None:
91-
"""Return the path to a cached thumbnail, generating it if needed.
92+
) -> tuple[Path, str] | None:
93+
"""Return (thumb_path, resolved_folder) or None.
9294
93-
Returns None when the request is invalid or generation fails.
94-
Includes path traversal protection.
95+
resolved_folder is the actual folder the source was found in (may differ
96+
from the requested folder due to legacy ID fallback). Callers use this
97+
for ACL enforcement on the resolved path.
9598
9699
When cache_dir is provided, thumbnails are written there instead of
97100
under {media_root}/.thumbs/ — this supports read-only media volumes.
@@ -120,28 +123,29 @@ async def ensure_thumbnail(
120123
if not dest.is_relative_to(thumbs_root):
121124
return None
122125

126+
resolved_folder = folder
127+
123128
if dest.exists():
124-
return dest
129+
return dest, resolved_folder
125130

126131
if not source.exists():
127-
# Legacy fallback: pre-v4.0.5 paths used positive IDs, disk uses negative marked IDs.
128-
# Try alternate folder names: X→-X (basic group), X→-100X (channel/supergroup)
129-
alt_folders = []
130-
if not folder.startswith("-"):
131-
alt_folders = [f"-{folder}", f"-100{folder}"]
132-
else:
133-
alt_folders = [folder[1:]]
132+
alt_folders = legacy_folder_alternates(folder)
134133
found = False
135134
for alt in alt_folders:
136-
alt_source = (media_root / alt / filename).resolve()
137-
if alt_source.is_relative_to(media_root_resolved) and alt_source.exists():
138-
source = alt_source
139-
found = True
140-
break
135+
try:
136+
alt_source = (media_root / alt / filename).resolve()
137+
if alt_source.is_relative_to(media_root_resolved) and alt_source.exists():
138+
logger.debug("Thumbnail legacy fallback resolved via alternate folder")
139+
source = alt_source
140+
resolved_folder = alt
141+
found = True
142+
break
143+
except OSError, RuntimeError:
144+
continue
141145
if not found:
142146
return None
143147

144148
async with _generation_semaphore:
145149
loop = asyncio.get_running_loop()
146150
ok = await loop.run_in_executor(None, _generate_sync, source, dest, size)
147-
return dest if ok else None
151+
return (dest, resolved_folder) if ok else None

0 commit comments

Comments
 (0)