@@ -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 :
0 commit comments