1515from html import escape , unescape
1616from itertools import chain
1717from typing import TYPE_CHECKING , ClassVar
18- from urllib .parse import quote
18+ from urllib .parse import quote , urlparse
1919
20+ from django .conf import settings
2021from django .core .cache import cache
2122from django .core .exceptions import ValidationError
2223from django .utils .functional import cached_property
2829from weblate .machinery .forms import BaseMachineryForm
2930from weblate .utils .docs import DocVersionsMixin
3031from weblate .utils .errors import report_error
32+ from weblate .utils .forms import WeblateServiceURLField
3133from weblate .utils .hash import calculate_dict_hash , calculate_hash , hash_to_checksum
32- from weblate .utils .requests import http_request
34+ from weblate .utils .outbound import is_allowlisted_hostname
35+ from weblate .utils .requests import http_request , validate_request_url
3336from weblate .utils .similarity import Comparer
3437from weblate .utils .site import get_site_url
3538
@@ -108,20 +111,21 @@ class BatchMachineTranslation(DocVersionsMixin):
108111
109112 validate_source_language = "en"
110113 validate_target_language = "de"
114+ trusted_error_hosts : ClassVar [set [str ]] = set ()
111115
112116 @classmethod
113117 def get_rank (cls ):
114118 return cls .max_score + cls .rank_boost
115119
116- def __init__ (self , settings : SettingsDict ) -> None :
120+ def __init__ (self , configuration : SettingsDict ) -> None :
117121 """Create new machine translation object."""
118122 self .mtid = self .get_identifier ()
119123 self .rate_limit_cache = f"{ self .mtid } -rate-limit"
120124 self .languages_cache = f"{ self .mtid } -languages"
121125 self .comparer = Comparer ()
122126 self .supported_languages_error : Exception | None = None
123127 self .supported_languages_error_age : float = 0
124- self .settings = settings
128+ self .settings = configuration
125129
126130 def delete_cache (self ) -> None :
127131 cache .delete_many ([self .rate_limit_cache , self .languages_cache ])
@@ -187,29 +191,108 @@ def check_failure(self, response: Response) -> None:
187191 try :
188192 response .raise_for_status ()
189193 except HTTPError as error :
190- detail = response .text
191- try :
192- payload = response .json ()
193- except JSONDecodeError :
194- pass
195- else :
196- if isinstance (payload , dict ) and payload :
197- if detail_error := payload .get ("error" ):
198- if isinstance (detail_error , str ):
199- detail = detail_error
200- elif isinstance (detail_error , dict ):
201- if "message" in detail_error :
202- detail = detail_error ["message" ]
203- else :
204- detail = str (detail_error )
205- else :
206- detail = str (payload )
207-
208- if detail :
194+ if detail := self .get_error_detail (response ):
209195 message = f"{ error .args [0 ]} : { detail [:200 ]} "
210196 raise HTTPError (message , response = response ) from error
211197 raise
212198
199+ @property
200+ def allow_private_targets (self ) -> bool :
201+ return "_project" not in self .settings
202+
203+ def validate_runtime_url (self , url : str ) -> None :
204+ validate_request_url (url , allow_private_targets = self .allow_private_targets )
205+
206+ @staticmethod
207+ def get_host_from_setting (value : object ) -> str | None :
208+ if not isinstance (value , str ):
209+ return None
210+ if "://" in value :
211+ return urlparse (value ).hostname
212+ return value or None
213+
214+ def get_trusted_error_hosts (self ) -> set [str ]:
215+ hosts = set (settings .ALLOWED_MACHINERY_DOMAINS )
216+ hosts .update (self .trusted_error_hosts )
217+ if self .allow_private_targets or self .settings_form is None :
218+ return hosts
219+
220+ form = self .settings_form (self .__class__ )
221+ for field_name , field in form .fields .items ():
222+ values : set [str ] = set ()
223+ if initial := getattr (field , "initial" , None ):
224+ values .add (initial )
225+ values .update (value for value , _label in getattr (field , "choices" , ()))
226+
227+ current_value = self .settings .get (field_name )
228+ if current_value in values and (
229+ host := self .get_host_from_setting (current_value )
230+ ):
231+ hosts .add (host )
232+
233+ if isinstance (field , WeblateServiceURLField ):
234+ for value in values :
235+ if host := self .get_host_from_setting (value ):
236+ hosts .add (host )
237+ return hosts
238+
239+ @classmethod
240+ def has_configurable_outbound_target (cls ) -> bool :
241+ if cls .settings_form is None :
242+ return False
243+
244+ form = cls .settings_form (cls )
245+ for field_name , field in form .fields .items ():
246+ if isinstance (field , WeblateServiceURLField ):
247+ return True
248+ if field_name in form .network_host_fields :
249+ return True
250+ return False
251+
252+ def can_display_error_detail (self , response : Response ) -> bool :
253+ if self .allow_private_targets :
254+ return True
255+ return self .is_trusted_error_host (response )
256+
257+ def is_trusted_error_host (self , response : Response ) -> bool :
258+ if (
259+ self .settings_form is not None
260+ and not self .has_configurable_outbound_target ()
261+ ):
262+ return True
263+ hostname = urlparse (response .url ).hostname or ""
264+ return is_allowlisted_hostname (hostname , list (self .get_trusted_error_hosts ()))
265+
266+ def get_error_detail (self , response : Response ) -> str | None :
267+ if not self .can_display_error_detail (response ):
268+ return None
269+ trusted_host = self .is_trusted_error_host (response )
270+
271+ try :
272+ payload = response .json ()
273+ except JSONDecodeError :
274+ if trusted_host :
275+ return response .text or None
276+ return None
277+
278+ if isinstance (payload , dict ):
279+ if (message := payload .get ("message" )) and isinstance (message , str ):
280+ return message
281+ if (detail := payload .get ("detail" )) and isinstance (detail , str ):
282+ return detail
283+ if error := payload .get ("error" ):
284+ if isinstance (error , str ):
285+ return error
286+ if isinstance (error , dict ):
287+ detail_message = error .get ("message" )
288+ if isinstance (detail_message , str ):
289+ return detail_message
290+ elif isinstance (payload , str ) and trusted_host :
291+ return payload
292+ if trusted_host :
293+ return response .text or None
294+ return None
295+
213296 def request (self , method , url , skip_auth = False , ** kwargs ):
214297 """Perform JSON request."""
215298 # Create custom headers
@@ -231,6 +314,8 @@ def request(self, method, url, skip_auth=False, **kwargs):
231314 timeout = self .request_timeout ,
232315 auth = self .get_auth (),
233316 raise_for_status = False ,
317+ validate_url = True ,
318+ allow_private_targets = self .allow_private_targets ,
234319 ** kwargs ,
235320 )
236321
0 commit comments