1212import logging
1313import pathlib
1414import platform
15+ import ipaddress
16+ import socket
17+ from urllib .parse import urlparse
1518
1619# Check for Meta app credentials in environment
1720META_APP_ID = os .environ .get ("META_APP_ID" , "" )
@@ -144,37 +147,153 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
144147 return unique_urls
145148
146149
150+ # --- Server-side request forgery (SSRF) guard for outbound image fetches ---
151+ #
152+ # upload_ad_image and the image-viewing tools fetch a caller-supplied URL
153+ # server-side. Without validation an attacker could point the URL at internal
154+ # services (http://127.0.0.1/...), private networks (10.x/192.168.x/172.16.x),
155+ # or the cloud metadata endpoint (http://169.254.169.254/) and use the server
156+ # as a proxy. See GHSA-45gf-fjxp-cjpq.
157+ #
158+ # Known residual: a hostname that resolves to a public IP at validation time
159+ # but to a private IP at connection time (DNS rebinding) is not fully closed,
160+ # since httpx resolves independently when it connects. The practical vectors
161+ # (a directly-internal URL, and a public URL that redirects inward) are blocked.
162+
163+ class BlockedURLError (Exception ):
164+ """Raised when a URL targets a disallowed (non-public) address."""
165+
166+
167+ _ALLOWED_URL_SCHEMES = ("http" , "https" )
168+
169+
170+ def _ip_is_disallowed (ip ) -> bool :
171+ """Return True if `ip` is not a public, routable address.
172+
173+ Blocks private, loopback, link-local (incl. 169.254.169.254 cloud
174+ metadata), reserved, multicast, and unspecified addresses. IPv4-mapped
175+ IPv6 addresses (e.g. ::ffff:127.0.0.1) are unwrapped first so they can't
176+ be used to smuggle a private IPv4 target past the check.
177+ """
178+ mapped = getattr (ip , "ipv4_mapped" , None )
179+ if mapped is not None :
180+ ip = mapped
181+ return (
182+ ip .is_private
183+ or ip .is_loopback
184+ or ip .is_link_local
185+ or ip .is_reserved
186+ or ip .is_multicast
187+ or ip .is_unspecified
188+ )
189+
190+
191+ def validate_public_url (url : str ) -> None :
192+ """Validate that `url` is safe to fetch from the server (SSRF guard).
193+
194+ Raises BlockedURLError if the URL is not http(s), has no host, or resolves
195+ to any non-public address. A literal-IP host is checked directly; a
196+ hostname is resolved and every returned address must be public.
197+ """
198+ if not url or not isinstance (url , str ):
199+ raise BlockedURLError ("No URL provided" )
200+
201+ parsed = urlparse (url .strip ())
202+ scheme = (parsed .scheme or "" ).lower ()
203+ if scheme not in _ALLOWED_URL_SCHEMES :
204+ raise BlockedURLError (
205+ f"URL scheme '{ parsed .scheme } ' is not allowed; "
206+ "only http and https URLs can be fetched"
207+ )
208+
209+ host = parsed .hostname
210+ if not host :
211+ raise BlockedURLError ("URL has no host" )
212+
213+ try :
214+ candidate_ips = [ipaddress .ip_address (host )]
215+ except ValueError :
216+ # Not a literal IP — resolve the hostname and check every address.
217+ try :
218+ infos = socket .getaddrinfo (host , None )
219+ except socket .gaierror as e :
220+ raise BlockedURLError (f"Could not resolve host '{ host } ': { e } " )
221+ candidate_ips = []
222+ for info in infos :
223+ ip_text = info [4 ][0 ].split ("%" )[0 ] # strip any IPv6 scope id
224+ try :
225+ candidate_ips .append (ipaddress .ip_address (ip_text ))
226+ except ValueError :
227+ continue
228+ if not candidate_ips :
229+ raise BlockedURLError (f"Could not resolve host '{ host } ' to any IP address" )
230+
231+ for ip in candidate_ips :
232+ if _ip_is_disallowed (ip ):
233+ raise BlockedURLError (
234+ f"Refusing to fetch '{ host } ': it resolves to a non-public address "
235+ f"({ ip } ). Private, loopback, link-local, and cloud-metadata "
236+ "addresses are blocked to prevent server-side request forgery."
237+ )
238+
239+
240+ async def _ssrf_guard_request_hook (request : "httpx.Request" ) -> None :
241+ """httpx request event hook that re-validates every outbound request.
242+
243+ Fires for the initial request and for each redirect hop, so a public URL
244+ cannot redirect into a private/internal address.
245+ """
246+ validate_public_url (str (request .url ))
247+
248+
147249async def download_image (url : str ) -> Optional [bytes ]:
148250 """
149251 Download an image from a URL.
150-
252+
151253 Args:
152254 url: Image URL
153-
255+
154256 Returns:
155257 Image data as bytes if successful, None otherwise
156258 """
259+ # SSRF guard: refuse non-public targets before opening any connection.
260+ try :
261+ validate_public_url (url )
262+ except BlockedURLError as e :
263+ logger .warning ("Refusing to download image from disallowed URL: %s" , e )
264+ print (f"Refusing to download image from disallowed URL: { e } " )
265+ return None
266+
157267 try :
158268 print (f"Attempting to download image from URL: { url } " )
159-
269+
160270 # Use minimal headers like curl does
161271 headers = {
162272 "User-Agent" : "curl/8.4.0" ,
163273 "Accept" : "*/*"
164274 }
165-
166- async with httpx .AsyncClient (follow_redirects = True , timeout = 30.0 ) as client :
275+
276+ async with httpx .AsyncClient (
277+ follow_redirects = True ,
278+ timeout = 30.0 ,
279+ event_hooks = {"request" : [_ssrf_guard_request_hook ]},
280+ ) as client :
167281 # Simple GET request just like curl
168282 response = await client .get (url , headers = headers )
169-
283+
170284 # Check response
171285 if response .status_code == 200 :
172286 print (f"Successfully downloaded image: { len (response .content )} bytes" )
173287 return response .content
174288 else :
175289 print (f"Failed to download image: HTTP { response .status_code } " )
176290 return None
177-
291+
292+ except BlockedURLError as e :
293+ # A redirect pointed at a disallowed (non-public) address.
294+ logger .warning ("Blocked SSRF redirect during image download: %s" , e )
295+ print (f"Blocked image download (redirect to disallowed address): { e } " )
296+ return None
178297 except httpx .HTTPStatusError as e :
179298 print (f"HTTP Error when downloading image: { e } " )
180299 return None
@@ -195,33 +314,48 @@ async def try_multiple_download_methods(url: str) -> Optional[bytes]:
195314
196315 Returns:
197316 Image data as bytes if successful, None otherwise
317+
318+ Raises:
319+ BlockedURLError: if `url` targets a non-public address (SSRF guard),
320+ raised up-front so callers can surface a clear rejection message.
198321 """
322+ # SSRF guard: validate once up-front and propagate a clear error. Each
323+ # client below also re-validates every request (including redirect hops)
324+ # via _ssrf_guard_request_hook, so a public URL cannot redirect inward.
325+ validate_public_url (url )
326+
199327 # Method 1: Direct download with custom headers
200328 image_data = await download_image (url )
201329 if image_data :
202330 return image_data
203-
331+
204332 print ("Direct download failed, trying alternative methods..." )
205-
333+
206334 # Method 2: Try adding Facebook cookie simulation
207335 try :
208336 headers = {
209337 "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" ,
210338 "Accept" : "image/webp,image/apng,image/*,*/*;q=0.8" ,
211339 "Cookie" : "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC" # Fake cookie
212340 }
213-
214- async with httpx .AsyncClient (follow_redirects = True ) as client :
341+
342+ async with httpx .AsyncClient (
343+ follow_redirects = True ,
344+ event_hooks = {"request" : [_ssrf_guard_request_hook ]},
345+ ) as client :
215346 response = await client .get (url , headers = headers , timeout = 30.0 )
216347 response .raise_for_status ()
217348 print (f"Method 2 succeeded with cookie simulation: { len (response .content )} bytes" )
218349 return response .content
219350 except Exception as e :
220351 print (f"Method 2 failed: { str (e )} " )
221-
352+
222353 # Method 3: Try with session that keeps redirects and cookies
223354 try :
224- async with httpx .AsyncClient (follow_redirects = True ) as client :
355+ async with httpx .AsyncClient (
356+ follow_redirects = True ,
357+ event_hooks = {"request" : [_ssrf_guard_request_hook ]},
358+ ) as client :
225359 # First visit Facebook to get cookies
226360 await client .get ("https://www.facebook.com/" , timeout = 30.0 )
227361 # Then try the image URL
@@ -231,7 +365,7 @@ async def try_multiple_download_methods(url: str) -> Optional[bytes]:
231365 return response .content
232366 except Exception as e :
233367 print (f"Method 3 failed: { str (e )} " )
234-
368+
235369 return None
236370
237371
0 commit comments