Skip to content

Commit 1242bf1

Browse files
authored
Merge pull request #99 from GeiserX/fix/security-hardening-round2
fix: security hardening round 2 (v7.4.1)
2 parents 0897f54 + ee67761 commit 1242bf1

5 files changed

Lines changed: 77 additions & 33 deletions

File tree

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.4.0"
5+
__version__ = "7.4.1"

src/db/adapter.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -783,22 +783,24 @@ async def insert_reactions(self, message_id: int, chat_id: int, reactions: list[
783783
await session.flush() # Flush each to catch errors early
784784
except Exception as e:
785785
if "duplicate key" in str(e).lower() or "unique" in str(e).lower():
786-
# Sequence out of sync - reset and retry
787-
logger.warning("Reactions sequence out of sync, resetting...")
786+
# Sequence out of sync — rollback undoes ALL flushed inserts
787+
logger.warning("Reactions sequence out of sync, resetting and retrying all...")
788788
await session.rollback()
789789
await self._reset_reactions_sequence()
790-
# Retry the insert
790+
# Retry ALL reactions in a fresh transaction
791791
async with self.db_manager.async_session_factory() as retry_session:
792-
r = Reaction(
793-
message_id=message_id,
794-
chat_id=chat_id,
795-
emoji=reaction["emoji"],
796-
user_id=reaction.get("user_id"),
797-
count=reaction.get("count", 1),
798-
)
799-
retry_session.add(r)
792+
for r_data in reactions:
793+
retry_session.add(
794+
Reaction(
795+
message_id=message_id,
796+
chat_id=chat_id,
797+
emoji=r_data["emoji"],
798+
user_id=r_data.get("user_id"),
799+
count=r_data.get("count", 1),
800+
)
801+
)
800802
await retry_session.commit()
801-
return # Exit after recovery
803+
return
802804
raise
803805

804806
await session.commit()

src/realtime.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,19 @@ async def _notify_http(self, payload: dict):
130130
if not self._http_endpoint:
131131
return
132132

133+
headers: dict[str, str] = {}
134+
push_secret = os.getenv("INTERNAL_PUSH_SECRET")
135+
if push_secret:
136+
headers["Authorization"] = f"Bearer {push_secret}"
137+
133138
try:
134139
import aiohttp
135140

136141
async with (
137142
aiohttp.ClientSession() as session,
138-
session.post(self._http_endpoint, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as response,
143+
session.post(
144+
self._http_endpoint, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=5)
145+
) as response,
139146
):
140147
if response.status != 200:
141148
logger.warning(f"HTTP notification returned {response.status}")
@@ -145,7 +152,7 @@ async def _notify_http(self, payload: dict):
145152
import httpx
146153

147154
async with httpx.AsyncClient() as client:
148-
await client.post(self._http_endpoint, json=payload, timeout=5)
155+
await client.post(self._http_endpoint, json=payload, headers=headers, timeout=5)
149156
except ImportError:
150157
logger.warning("Neither aiohttp nor httpx available for HTTP notifications")
151158
except Exception as e:

src/web/main.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -714,12 +714,23 @@ async def serve_thumbnail(size: int, folder: str, filename: str, user: UserConte
714714
# Chat-level access check
715715
user_chat_ids = get_user_chat_ids(user)
716716
if user_chat_ids is not None:
717-
try:
718-
media_chat_id = int(folder.split("/")[0])
719-
if media_chat_id not in user_chat_ids:
717+
folder_parts = folder.split("/")
718+
if folder_parts[0] == "avatars":
719+
# Avatar thumbnail: folder=avatars/{users|chats}, filename={chat_id}_{photo_id}.jpg
720+
name = filename.rsplit(".", 1)[0] if "." in filename else filename
721+
try:
722+
avatar_chat_id = int(name.split("_")[0])
723+
if avatar_chat_id not in user_chat_ids:
724+
raise HTTPException(status_code=403, detail="Access denied")
725+
except ValueError:
720726
raise HTTPException(status_code=403, detail="Access denied")
721-
except ValueError:
722-
pass
727+
else:
728+
try:
729+
media_chat_id = int(folder_parts[0])
730+
if media_chat_id not in user_chat_ids:
731+
raise HTTPException(status_code=403, detail="Access denied")
732+
except ValueError:
733+
pass
723734

724735
from .thumbnails import ensure_thumbnail
725736

@@ -758,13 +769,24 @@ async def serve_media(path: str, download: int = Query(0), user: UserContext = D
758769
user_chat_ids = get_user_chat_ids(user)
759770
if user_chat_ids is not None:
760771
parts = path.split("/")
761-
if len(parts) >= 2 and parts[0] != "avatars":
762-
try:
763-
media_chat_id = int(parts[0])
764-
if media_chat_id not in user_chat_ids:
772+
if len(parts) >= 2:
773+
if parts[0] == "avatars" and len(parts) >= 3:
774+
# Avatar path: avatars/{users|chats}/{chat_id}_{photo_id}.jpg
775+
# Extract chat_id from filename to enforce per-chat ACL
776+
name = parts[2].rsplit(".", 1)[0] if "." in parts[2] else parts[2]
777+
try:
778+
avatar_chat_id = int(name.split("_")[0])
779+
if avatar_chat_id not in user_chat_ids:
780+
raise HTTPException(status_code=403, detail="Access denied")
781+
except ValueError:
765782
raise HTTPException(status_code=403, detail="Access denied")
766-
except ValueError:
767-
pass
783+
elif parts[0] != "avatars":
784+
try:
785+
media_chat_id = int(parts[0])
786+
if media_chat_id not in user_chat_ids:
787+
raise HTTPException(status_code=403, detail="Access denied")
788+
except ValueError:
789+
pass
768790

769791
if not resolved.is_file():
770792
raise HTTPException(status_code=404, detail="File not found")
@@ -1490,7 +1512,7 @@ async def push_unsubscribe(request: Request, user: UserContext = Depends(require
14901512
if not endpoint:
14911513
raise HTTPException(status_code=400, detail="Missing endpoint")
14921514

1493-
success = await push_manager.unsubscribe(endpoint)
1515+
success = await push_manager.unsubscribe(endpoint, username=user.username)
14941516
return {"status": "unsubscribed" if success else "not_found"}
14951517

14961518
except json.JSONDecodeError:
@@ -1517,13 +1539,15 @@ async def internal_push(request: Request):
15171539
Access is restricted to loopback and private (RFC1918/Docker) IPs.
15181540
Split-container SQLite setups use VIEWER_HOST/VIEWER_PORT to push
15191541
from the backup container to the viewer container over Docker networks.
1542+
1543+
If INTERNAL_PUSH_SECRET is set, it must be provided as a bearer token.
1544+
This prevents co-tenant containers from spoofing live events.
15201545
"""
15211546
import ipaddress
15221547

15231548
client_host = request.client.host if request.client else None
15241549

15251550
# Accept from loopback + private IPs (Docker internal, RFC1918)
1526-
# Split-container SQLite needs this for cross-container push
15271551
is_allowed = False
15281552
if client_host:
15291553
try:
@@ -1536,6 +1560,14 @@ async def internal_push(request: Request):
15361560
logger.warning(f"Rejected /internal/push from non-private IP: {client_host}")
15371561
raise HTTPException(status_code=403, detail="Forbidden")
15381562

1563+
# Optional shared secret for multi-tenant Docker environments
1564+
push_secret = os.getenv("INTERNAL_PUSH_SECRET")
1565+
if push_secret:
1566+
auth_header = request.headers.get("Authorization", "")
1567+
if auth_header != f"Bearer {push_secret}":
1568+
logger.warning(f"Rejected /internal/push: invalid or missing secret from {client_host}")
1569+
raise HTTPException(status_code=403, detail="Forbidden")
1570+
15391571
try:
15401572
payload = await request.json()
15411573
if realtime_listener:

src/web/push.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,20 @@ async def subscribe(
173173
logger.error(f"Failed to store push subscription: {e}")
174174
return False
175175

176-
async def unsubscribe(self, endpoint: str) -> bool:
177-
"""Remove a push subscription."""
176+
async def unsubscribe(self, endpoint: str, username: str | None = None) -> bool:
177+
"""Remove a push subscription. Scoped to the requesting user to prevent cross-user unsubscribe."""
178178
try:
179-
from sqlalchemy import delete
179+
from sqlalchemy import and_, delete
180180

181181
from src.db.models import PushSubscription
182182

183183
async with self.db.db_manager.async_session_factory() as session:
184-
await session.execute(delete(PushSubscription).where(PushSubscription.endpoint == endpoint))
184+
conditions = [PushSubscription.endpoint == endpoint]
185+
if username:
186+
conditions.append(PushSubscription.username == username)
187+
await session.execute(delete(PushSubscription).where(and_(*conditions)))
185188
await session.commit()
186-
logger.info(f"Push subscription removed: {endpoint[:50]}...")
189+
logger.info(f"Push subscription removed for {username or 'anonymous'}: {endpoint[:50]}...")
187190
return True
188191

189192
except Exception as e:

0 commit comments

Comments
 (0)