Skip to content

Commit 70dbacd

Browse files
authored
Harden Durable HTTP trust boundaries (#221)
* 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 * Address Durable HTTP review feedback Strip proxy credentials across origins, preserve non-redirect response semantics, and document function-key polling behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09ea1d35-b7bd-4f4a-a243-763f29ea115f --------- Copilot-Session: 09ea1d35-b7bd-4f4a-a243-763f29ea115f
1 parent b229e69 commit 70dbacd

3 files changed

Lines changed: 250 additions & 14 deletions

File tree

azure-functions-durable/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ CHANGED
2222
- Reused host-driven orchestration and entity workers across invocations,
2323
avoiding repeated allocation of unused worker resources.
2424

25+
FIXED
26+
27+
- Prevented Durable HTTP calls from forwarding managed identity tokens,
28+
authorization headers, cookies, proxy credentials, or function keys to
29+
cross-origin redirect and polling targets. Function keys are now removed from
30+
every `202 Accepted` poll, including same-origin polls, because the initial
31+
function-level key may not authorize the status endpoint. Direct client
32+
invocation of the internal HTTP polling orchestrator is now rejected.
33+
2534
## 2.0.0b1
2635

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

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

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@
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+
82161
def 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

Comments
 (0)