Skip to content

Commit a64a93d

Browse files
committed
Harden Durable HTTP trust boundaries
Prevent credentials from crossing origins during redirects and 202 polling, and reject direct starts of the internal poll orchestrator. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09ea1d35-b7bd-4f4a-a243-763f29ea115f
1 parent b871df8 commit a64a93d

3 files changed

Lines changed: 229 additions & 14 deletions

File tree

azure-functions-durable/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ the synchronous client into synchronous functions and the asynchronous client
1414
into coroutine functions. Both clients support scheduled-task and history-export
1515
APIs without an async-to-sync bridge.
1616

17+
FIXED
18+
19+
- Prevented Durable HTTP calls from forwarding managed identity tokens,
20+
authorization headers, cookies, or function keys to cross-origin redirect and
21+
polling targets. Direct client invocation of the internal HTTP polling
22+
orchestrator is now rejected.
23+
1724
## 2.0.0b1
1825

1926
First preview (beta) release of `azure-functions-durable` 2.x — a ground-up

azure-functions-durable/azure/durable_functions/http/builtin.py

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@
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+
"x-functions-key",
54+
))
55+
_POLL_SENSITIVE_HEADERS = frozenset(("x-functions-key",))
56+
4957
# Process-wide credential cache. ``DefaultAzureCredential`` is safe to reuse and
5058
# caches tokens internally, so a single worker-local instance avoids repeating
5159
# credential-chain discovery and token acquisition on every durable HTTP
@@ -79,6 +87,72 @@ def _acquire_bearer_token(resource: str) -> str:
7987
return _get_credential().get_token(scope).token
8088

8189

90+
def _http_origin(uri: str) -> Optional[tuple[str, str, int]]:
91+
"""Return a normalized HTTP origin, or ``None`` for an invalid URL."""
92+
parsed = urlparse(uri)
93+
scheme = parsed.scheme.lower()
94+
if scheme not in _HTTP_SCHEMES or parsed.hostname is None:
95+
return None
96+
try:
97+
port = parsed.port
98+
except ValueError:
99+
return None
100+
if port is None:
101+
port = 443 if scheme == "https" else 80
102+
return scheme, parsed.hostname.lower(), port
103+
104+
105+
def _is_same_origin(first_uri: str, second_uri: str) -> bool:
106+
"""Return whether two absolute HTTP URLs have the same origin."""
107+
first_origin = _http_origin(first_uri)
108+
return first_origin is not None and first_origin == _http_origin(second_uri)
109+
110+
111+
def _without_headers(
112+
headers: dict[str, str],
113+
excluded_names: frozenset[str]) -> dict[str, str]:
114+
"""Copy headers except for case-insensitively excluded names."""
115+
return {
116+
name: value
117+
for name, value in headers.items()
118+
if name.lower() not in excluded_names
119+
}
120+
121+
122+
class _SecureRedirectHandler(urllib.request.HTTPRedirectHandler):
123+
"""Follow only HTTP(S) redirects without leaking cross-origin secrets."""
124+
125+
def redirect_request(
126+
self,
127+
req: urllib.request.Request,
128+
fp: Any,
129+
code: int,
130+
msg: str,
131+
headers: Any,
132+
newurl: str) -> Optional[urllib.request.Request]:
133+
if _http_origin(newurl) is None:
134+
raise urllib.error.URLError(
135+
f"Refusing redirect to non-HTTP(S) URL {newurl!r}.")
136+
137+
redirected = super().redirect_request(
138+
req, fp, code, msg, headers, newurl)
139+
if redirected is None or _is_same_origin(req.full_url, newurl):
140+
return redirected
141+
142+
for header_map in (
143+
redirected.headers,
144+
redirected.unredirected_hdrs):
145+
for name in list(header_map):
146+
if name.lower() in _CROSS_ORIGIN_SENSITIVE_HEADERS:
147+
del header_map[name]
148+
return redirected
149+
150+
151+
def _open_http_request(req: urllib.request.Request) -> Any:
152+
"""Open a request using the credential-safe redirect policy."""
153+
return urllib.request.build_opener(_SecureRedirectHandler()).open(req)
154+
155+
82156
def builtin_http_activity(input: dict[str, Any]) -> dict[str, Any]:
83157
"""Execute a single HTTP request and return the response payload.
84158
@@ -105,9 +179,9 @@ def builtin_http_activity(input: dict[str, Any]) -> dict[str, Any]:
105179
# Durable HTTP only ever means http(s); reject other schemes (file://,
106180
# ftp://, ...) that urlopen would otherwise honor, closing off local-file
107181
# reads / SSRF to non-HTTP endpoints from orchestration-supplied URLs.
108-
if urlparse(str(uri)).scheme.lower() not in ("http", "https"):
182+
if _http_origin(str(uri)) is None:
109183
raise ValueError(
110-
"call_http only supports http/https URLs; "
184+
"call_http requires an absolute http/https URL; "
111185
f"got {uri!r}.")
112186
content = request.get("content")
113187
headers: dict[str, str] = dict(request.get("headers") or {})
@@ -125,7 +199,7 @@ def builtin_http_activity(input: dict[str, Any]) -> dict[str, Any]:
125199
req = urllib.request.Request(url=uri, data=data, method=method, headers=headers)
126200

127201
try:
128-
with urllib.request.urlopen(req) as resp: # noqa: S310 - user-supplied URL is the feature
202+
with _open_http_request(req) as resp:
129203
status = int(resp.status)
130204
resp_headers = {k: v for k, v in resp.headers.items()}
131205
body = resp.read().decode("utf-8", errors="replace")
@@ -202,6 +276,11 @@ def builtin_http_poll_orchestrator(context: Any) -> Generator[Any, Any, DurableH
202276
is reconstructed type-safely (required under strict typing, which will not
203277
build a custom type from a bare JSON object).
204278
"""
279+
if context.parent_instance_id is None:
280+
raise PermissionError(
281+
f"{BUILTIN_HTTP_POLL_ORCHESTRATOR_NAME} can only run as a "
282+
"sub-orchestration.")
283+
205284
request: dict[str, Any] = context.get_input() or {}
206285
response: dict[str, Any] = yield context.call_activity(
207286
BUILTIN_HTTP_ACTIVITY_NAME, request)
@@ -221,20 +300,29 @@ def builtin_http_poll_orchestrator(context: Any) -> Generator[Any, Any, DurableH
221300
# against the current request URI so the next poll targets an absolute
222301
# http(s) URL (the built-in activity rejects non-absolute URIs).
223302
location = urljoin(current_uri, location)
303+
if _http_origin(location) is None:
304+
raise ValueError(
305+
"Durable HTTP polling requires an absolute http/https "
306+
f"Location URL; got {location!r}.")
224307

225308
now = context.current_utc_datetime
226309
delay = _retry_after_seconds(headers, now)
227310
fire_at = now + timedelta(seconds=delay)
228311
yield context.create_timer(fire_at)
229312

313+
same_origin = _is_same_origin(current_uri, location)
230314
poll_request: dict[str, Any] = {"method": "GET", "uri": location}
231-
# Preserve auth for the polling requests.
232315
if request.get("headers") is not None:
233-
poll_request["headers"] = request["headers"]
234-
if request.get("tokenSource") is not None:
316+
excluded_headers = _POLL_SENSITIVE_HEADERS
317+
if not same_origin:
318+
excluded_headers = _CROSS_ORIGIN_SENSITIVE_HEADERS
319+
poll_request["headers"] = _without_headers(
320+
dict(request["headers"]), excluded_headers)
321+
if same_origin and request.get("tokenSource") is not None:
235322
poll_request["tokenSource"] = request["tokenSource"]
236323

237324
current_uri = location
325+
request = poll_request
238326
response = yield context.call_activity(BUILTIN_HTTP_ACTIVITY_NAME, poll_request)
239327

240328
return DurableHttpResponse.from_json(response)

tests/azure-functions-durable/test_http_builtin_compat.py

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
from datetime import datetime, timedelta, timezone
55
from types import SimpleNamespace
66
from unittest.mock import MagicMock, patch
7+
import urllib.error
8+
import urllib.request
79

810
import pytest
911

1012
from 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

98101
def 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

210272
def 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+
246366
def 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

Comments
 (0)