4646# usable ``Retry-After`` header.
4747_DEFAULT_POLL_INTERVAL_SECONDS = 1
4848
49+ _HTTP_SCHEMES = frozenset (("http" , "https" ))
50+ _CROSS_ORIGIN_SENSITIVE_HEADERS = frozenset ((
51+ "authorization" ,
52+ "cookie" ,
53+ "proxy-authorization" ,
54+ "x-functions-key" ,
55+ ))
56+ _POLL_SENSITIVE_HEADERS = frozenset (("x-functions-key" ,))
57+
4958# Process-wide credential cache. ``DefaultAzureCredential`` is safe to reuse and
5059# caches tokens internally, so a single worker-local instance avoids repeating
5160# credential-chain discovery and token acquisition on every durable HTTP
@@ -79,6 +88,76 @@ def _acquire_bearer_token(resource: str) -> str:
7988 return _get_credential ().get_token (scope ).token
8089
8190
91+ def _http_origin (uri : str ) -> Optional [tuple [str , str , int ]]:
92+ """Return a normalized HTTP origin, or ``None`` for an invalid URL."""
93+ parsed = urlparse (uri )
94+ scheme = parsed .scheme .lower ()
95+ if scheme not in _HTTP_SCHEMES or parsed .hostname is None :
96+ return None
97+ try :
98+ port = parsed .port
99+ except ValueError :
100+ return None
101+ if port is None :
102+ port = 443 if scheme == "https" else 80
103+ return scheme , parsed .hostname .lower (), port
104+
105+
106+ def _is_same_origin (first_uri : str , second_uri : str ) -> bool :
107+ """Return whether two absolute HTTP URLs have the same origin."""
108+ first_origin = _http_origin (first_uri )
109+ return first_origin is not None and first_origin == _http_origin (second_uri )
110+
111+
112+ def _without_headers (
113+ headers : dict [str , str ],
114+ excluded_names : frozenset [str ]) -> dict [str , str ]:
115+ """Copy headers except for case-insensitively excluded names."""
116+ return {
117+ name : value
118+ for name , value in headers .items ()
119+ if name .lower () not in excluded_names
120+ }
121+
122+
123+ class _SecureRedirectHandler (urllib .request .HTTPRedirectHandler ):
124+ """Follow only HTTP(S) redirects without leaking cross-origin secrets."""
125+
126+ def redirect_request (
127+ self ,
128+ req : urllib .request .Request ,
129+ fp : Any ,
130+ code : int ,
131+ msg : str ,
132+ headers : Any ,
133+ newurl : str ) -> Optional [urllib .request .Request ]:
134+ redirected = super ().redirect_request (
135+ req , fp , code , msg , headers , newurl )
136+ if redirected is None :
137+ return None
138+
139+ redirected_url = redirected .full_url
140+ if _http_origin (redirected_url ) is None :
141+ raise urllib .error .URLError (
142+ f"Refusing redirect to non-HTTP(S) URL { redirected_url !r} ." )
143+
144+ if _is_same_origin (req .full_url , redirected_url ):
145+ return redirected
146+
147+ for header_map in (
148+ redirected .headers ,
149+ redirected .unredirected_hdrs ):
150+ for name in list (header_map ):
151+ if name .lower () in _CROSS_ORIGIN_SENSITIVE_HEADERS :
152+ del header_map [name ]
153+ return redirected
154+
155+
156+ def _open_http_request (req : urllib .request .Request ) -> Any :
157+ """Open a request using the credential-safe redirect policy."""
158+ return urllib .request .build_opener (_SecureRedirectHandler ()).open (req )
159+
160+
82161def builtin_http_activity (input : dict [str , Any ]) -> dict [str , Any ]:
83162 """Execute a single HTTP request and return the response payload.
84163
@@ -105,9 +184,9 @@ def builtin_http_activity(input: dict[str, Any]) -> dict[str, Any]:
105184 # Durable HTTP only ever means http(s); reject other schemes (file://,
106185 # ftp://, ...) that urlopen would otherwise honor, closing off local-file
107186 # reads / SSRF to non-HTTP endpoints from orchestration-supplied URLs.
108- if urlparse (str (uri )). scheme . lower () not in ( "http" , "https" ) :
187+ if _http_origin (str (uri )) is None :
109188 raise ValueError (
110- "call_http only supports http/https URLs ; "
189+ "call_http requires an absolute http/https URL ; "
111190 f"got { uri !r} ." )
112191 content = request .get ("content" )
113192 headers : dict [str , str ] = dict (request .get ("headers" ) or {})
@@ -125,7 +204,7 @@ def builtin_http_activity(input: dict[str, Any]) -> dict[str, Any]:
125204 req = urllib .request .Request (url = uri , data = data , method = method , headers = headers )
126205
127206 try :
128- with urllib . request . urlopen (req ) as resp : # noqa: S310 - user-supplied URL is the feature
207+ with _open_http_request (req ) as resp :
129208 status = int (resp .status )
130209 resp_headers = {k : v for k , v in resp .headers .items ()}
131210 body = resp .read ().decode ("utf-8" , errors = "replace" )
@@ -202,6 +281,11 @@ def builtin_http_poll_orchestrator(context: Any) -> Generator[Any, Any, DurableH
202281 is reconstructed type-safely (required under strict typing, which will not
203282 build a custom type from a bare JSON object).
204283 """
284+ if context .parent_instance_id is None :
285+ raise PermissionError (
286+ f"{ BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME } can only run as a "
287+ "sub-orchestration." )
288+
205289 request : dict [str , Any ] = context .get_input () or {}
206290 response : dict [str , Any ] = yield context .call_activity (
207291 BUILTIN_HTTP_ACTIVITY_NAME , request )
@@ -221,20 +305,29 @@ def builtin_http_poll_orchestrator(context: Any) -> Generator[Any, Any, DurableH
221305 # against the current request URI so the next poll targets an absolute
222306 # http(s) URL (the built-in activity rejects non-absolute URIs).
223307 location = urljoin (current_uri , location )
308+ if _http_origin (location ) is None :
309+ raise ValueError (
310+ "Durable HTTP polling requires an absolute http/https "
311+ f"Location URL; got { location !r} ." )
224312
225313 now = context .current_utc_datetime
226314 delay = _retry_after_seconds (headers , now )
227315 fire_at = now + timedelta (seconds = delay )
228316 yield context .create_timer (fire_at )
229317
318+ same_origin = _is_same_origin (current_uri , location )
230319 poll_request : dict [str , Any ] = {"method" : "GET" , "uri" : location }
231- # Preserve auth for the polling requests.
232320 if request .get ("headers" ) is not None :
233- poll_request ["headers" ] = request ["headers" ]
234- if request .get ("tokenSource" ) is not None :
321+ excluded_headers = _POLL_SENSITIVE_HEADERS
322+ if not same_origin :
323+ excluded_headers = _CROSS_ORIGIN_SENSITIVE_HEADERS
324+ poll_request ["headers" ] = _without_headers (
325+ dict (request ["headers" ]), excluded_headers )
326+ if same_origin and request .get ("tokenSource" ) is not None :
235327 poll_request ["tokenSource" ] = request ["tokenSource" ]
236328
237329 current_uri = location
330+ request = poll_request
238331 response = yield context .call_activity (BUILTIN_HTTP_ACTIVITY_NAME , poll_request )
239332
240333 return DurableHttpResponse .from_json (response )
0 commit comments