44from datetime import datetime , timedelta , timezone
55from types import SimpleNamespace
66from unittest .mock import MagicMock , patch
7+ import urllib .error
8+ import urllib .request
79
810import pytest
911
1012from azure .durable_functions .http .builtin import (
1113 BUILTIN_HTTP_ACTIVITY_NAME ,
1214 BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME ,
15+ _SecureRedirectHandler ,
1316 _retry_after_seconds ,
1417 builtin_http_activity ,
1518 builtin_http_poll_orchestrator ,
@@ -97,7 +100,7 @@ def _fake_urlopen_response(status, headers, body):
97100
98101def test_activity_executes_request ():
99102 fake_resp = _fake_urlopen_response (200 , {"Content-Type" : "application/json" }, "ok" )
100- with patch ("azure.durable_functions.http.builtin.urllib.request.urlopen " ,
103+ with patch ("azure.durable_functions.http.builtin._open_http_request " ,
101104 return_value = fake_resp ):
102105 result = builtin_http_activity ({"method" : "GET" , "uri" : "http://example.com" })
103106 assert result ["status_code" ] == 200
@@ -129,7 +132,7 @@ def _capture(req):
129132 fake_credential = MagicMock ()
130133 fake_credential .get_token .return_value = SimpleNamespace (token = "THE_TOKEN" )
131134 with patch ("azure.durable_functions.http.builtin._cached_credential" , None ), \
132- patch ("azure.durable_functions.http.builtin.urllib.request.urlopen " ,
135+ patch ("azure.durable_functions.http.builtin._open_http_request " ,
133136 side_effect = _capture ), \
134137 patch ("azure.identity.DefaultAzureCredential" ,
135138 return_value = fake_credential ):
@@ -155,7 +158,7 @@ def _ok(req):
155158 return _fake_urlopen_response (200 , {}, "ok" )
156159
157160 with patch ("azure.durable_functions.http.builtin._cached_credential" , None ), \
158- patch ("azure.durable_functions.http.builtin.urllib.request.urlopen " ,
161+ patch ("azure.durable_functions.http.builtin._open_http_request " ,
159162 side_effect = _ok ), \
160163 patch ("azure.identity.DefaultAzureCredential" ,
161164 return_value = fake_credential ) as credential_ctor :
@@ -172,11 +175,69 @@ def _ok(req):
172175 assert fake_credential .get_token .call_count == 2
173176
174177
178+ @pytest .mark .parametrize ("target" , [
179+ "https://other.example/next" ,
180+ "http://example.com/next" ,
181+ ])
182+ def test_redirect_strips_credentials_when_origin_changes (target ):
183+ request = urllib .request .Request (
184+ "https://example.com/start" ,
185+ headers = {
186+ "Authorization" : "******" ,
187+ "Cookie" : "session=secret" ,
188+ "x-functions-key" : "function-secret" ,
189+ "X-Custom" : "preserved" ,
190+ })
191+
192+ redirected = _SecureRedirectHandler ().redirect_request (
193+ request , None , 302 , "Found" , {}, target )
194+
195+ assert redirected is not None
196+ redirected_headers = {
197+ name .lower (): value
198+ for name , value in redirected .headers .items ()
199+ }
200+ assert "authorization" not in redirected_headers
201+ assert "cookie" not in redirected_headers
202+ assert "x-functions-key" not in redirected_headers
203+ assert redirected_headers ["x-custom" ] == "preserved"
204+
205+
206+ def test_redirect_preserves_credentials_for_same_origin ():
207+ request = urllib .request .Request (
208+ "https://example.com:443/start" ,
209+ headers = {
210+ "Authorization" : "******" ,
211+ "Cookie" : "session=secret" ,
212+ "x-functions-key" : "function-secret" ,
213+ })
214+
215+ redirected = _SecureRedirectHandler ().redirect_request (
216+ request , None , 302 , "Found" , {}, "https://example.com/next" )
217+
218+ assert redirected is not None
219+ redirected_headers = {
220+ name .lower (): value
221+ for name , value in redirected .headers .items ()
222+ }
223+ assert redirected_headers ["authorization" ] == "******"
224+ assert redirected_headers ["cookie" ] == "session=secret"
225+ assert redirected_headers ["x-functions-key" ] == "function-secret"
226+
227+
228+ def test_redirect_rejects_non_http_scheme ():
229+ request = urllib .request .Request ("https://example.com/start" )
230+
231+ with pytest .raises (urllib .error .URLError , match = "non-HTTP" ):
232+ _SecureRedirectHandler ().redirect_request (
233+ request , None , 302 , "Found" , {}, "ftp://example.com/next" )
234+
235+
175236# ---------------------------------------------------------------------------
176237# Built-in poll orchestrator
177238# ---------------------------------------------------------------------------
178239
179- def _fake_orchestration_context (request ):
240+ def _fake_orchestration_context (request , parent_instance_id = "parent" ):
180241 activity_calls = []
181242
182243 def call_activity (name , inp ):
@@ -191,6 +252,7 @@ def create_timer(fire_at):
191252 call_activity = call_activity ,
192253 create_timer = create_timer ,
193254 current_utc_datetime = datetime (2020 , 1 , 1 , tzinfo = timezone .utc ),
255+ parent_instance_id = parent_instance_id ,
194256 _activity_calls = activity_calls ,
195257 )
196258 return ctx
@@ -208,8 +270,12 @@ def test_poll_orchestrator_returns_non_202_immediately():
208270
209271
210272def test_poll_orchestrator_polls_until_complete ():
211- request = {"method" : "GET" , "uri" : "http://x" ,
212- "headers" : {"h" : "v" }, "tokenSource" : {"resource" : "r" }}
273+ request = {
274+ "method" : "GET" ,
275+ "uri" : "http://x/start" ,
276+ "headers" : {"h" : "v" , "x-functions-key" : "secret" },
277+ "tokenSource" : {"resource" : "r" },
278+ }
213279 ctx = _fake_orchestration_context (request )
214280 gen = builtin_http_poll_orchestrator (ctx )
215281
@@ -219,7 +285,7 @@ def test_poll_orchestrator_polls_until_complete():
219285 # A 202 with a Location + Retry-After schedules a durable timer.
220286 timer = gen .send ({
221287 "status_code" : 202 ,
222- "headers" : {"Location" : "http:/ /poll" , "Retry-After" : "5" },
288+ "headers" : {"Location" : "/poll" , "Retry-After" : "5" },
223289 "content" : None ,
224290 })
225291 assert timer [0 ] == "timer"
@@ -231,7 +297,7 @@ def test_poll_orchestrator_polls_until_complete():
231297 assert poll_name == BUILTIN_HTTP_ACTIVITY_NAME
232298 assert poll_input == {
233299 "method" : "GET" ,
234- "uri" : "http://poll" ,
300+ "uri" : "http://x/ poll" ,
235301 "headers" : {"h" : "v" },
236302 "tokenSource" : {"resource" : "r" },
237303 }
@@ -243,6 +309,60 @@ def test_poll_orchestrator_polls_until_complete():
243309 assert stop .value .value .content == "done"
244310
245311
312+ def test_poll_orchestrator_does_not_forward_cross_origin_credentials ():
313+ request = {
314+ "method" : "GET" ,
315+ "uri" : "https://example.com/start" ,
316+ "headers" : {
317+ "Authorization" : "******" ,
318+ "Cookie" : "session=secret" ,
319+ "x-functions-key" : "function-secret" ,
320+ "X-Custom" : "preserved" ,
321+ },
322+ "tokenSource" : {"resource" : "https://management.azure.com" },
323+ }
324+ ctx = _fake_orchestration_context (request )
325+ gen = builtin_http_poll_orchestrator (ctx )
326+
327+ assert next (gen ) == ("activity_task" , 1 )
328+ gen .send ({
329+ "status_code" : 202 ,
330+ "headers" : {"Location" : "https://other.example/operations/1" },
331+ "content" : None ,
332+ })
333+ assert gen .send (None ) == ("activity_task" , 2 )
334+ assert ctx ._activity_calls [1 ][1 ] == {
335+ "method" : "GET" ,
336+ "uri" : "https://other.example/operations/1" ,
337+ "headers" : {"X-Custom" : "preserved" },
338+ }
339+
340+ # Credentials removed at the trust boundary must not reappear on a later
341+ # same-origin poll.
342+ gen .send ({
343+ "status_code" : 202 ,
344+ "headers" : {"Location" : "/operations/2" },
345+ "content" : None ,
346+ })
347+ assert gen .send (None ) == ("activity_task" , 3 )
348+ assert ctx ._activity_calls [2 ][1 ] == {
349+ "method" : "GET" ,
350+ "uri" : "https://other.example/operations/2" ,
351+ "headers" : {"X-Custom" : "preserved" },
352+ }
353+
354+
355+ def test_poll_orchestrator_rejects_top_level_invocation ():
356+ ctx = _fake_orchestration_context (
357+ {"method" : "GET" , "uri" : "https://example.com" },
358+ parent_instance_id = None )
359+ gen = builtin_http_poll_orchestrator (ctx )
360+
361+ with pytest .raises (PermissionError , match = "sub-orchestration" ):
362+ next (gen )
363+ assert ctx ._activity_calls == []
364+
365+
246366def test_poll_orchestrator_stops_when_202_has_no_location ():
247367 ctx = _fake_orchestration_context ({"method" : "GET" , "uri" : "http://x" })
248368 gen = builtin_http_poll_orchestrator (ctx )
0 commit comments