feat(BA-2257): Forward client Date header through Webserver proxy#11335
feat(BA-2257): Forward client Date header through Webserver proxy#11335
Conversation
Webserver's proxy could not forward a client's original `Date` header because `Request.fetch()` (and `connect_websocket` / `connect_events`) unconditionally refreshed it. This broke any signature scheme that binds the signature to the `Date` header end-to-end through the proxy. - Add a `headers` keyword override on `Request.fetch()`, `connect_websocket()`, and `connect_events()` that is applied after the auto-populated headers. When `Date` is supplied, parse it back into `self.date` so the signing helper uses the same value the upstream server will see. - In `web/proxy.web_handler`, `web/proxy.web_handler_with_jwt`, and `web/proxy.web_plugin_handler`, forward the frontend `Date` header to the backend request via the new override when present. Closes #5737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds per-call request header override support to the client Request APIs and uses it in the Webserver proxy to preserve an incoming client Date header end-to-end, intended to keep Date-bound signature authentication working through the proxy.
Changes:
- Add
headers=keyword-only overrides toRequest.fetch(),Request.connect_websocket(), andRequest.connect_events(), with special handling forDate. - Update Webserver proxy handlers to forward the frontend request’s
Datevia the new override mechanism. - Add unit tests covering
fetch()default vs overriddenDatebehavior and arbitrary header pass-through.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/ai/backend/client/request.py |
Introduces per-call header overrides and parses overridden Date into Request.date. |
src/ai/backend/web/proxy.py |
Forwards client Date through proxy by passing fetch header overrides. |
tests/unit/client/test_request.py |
Adds unit tests for fetch() header override behavior. |
changes/5737.fix.md |
Adds changelog entry describing the fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Preserve the client's original Date header so that signature-based | ||
| # auth schemes that bind the signature to Date keep working through | ||
| # the proxy. fetch() otherwise refreshes Date unconditionally. | ||
| fetch_header_overrides: dict[str, str] = {} | ||
| if (client_date := frontend_rqst.headers.get("Date")) is not None: | ||
| fetch_header_overrides["Date"] = client_date | ||
| async with backend_rqst.fetch(headers=fetch_header_overrides) as backend_resp: |
There was a problem hiding this comment.
This forwards an unvalidated client-supplied Date header into Request.fetch(), which now parses it. If the header is malformed, parse_datetime() will raise and fall into the generic except Exception path here, turning a client input issue into a 500. Consider validating/parsing the header in the proxy layer (and returning a 400 on failure), or only applying the override when you explicitly expect a pre-signed request (otherwise ignore the client Date).
| # Preserve the client's original Date header so that signature-based | ||
| # auth schemes that bind the signature to Date keep working through | ||
| # the proxy. fetch() otherwise refreshes Date unconditionally. | ||
| fetch_header_overrides: dict[str, str] = {} | ||
| if (client_date := frontend_rqst.headers.get("Date")) is not None: | ||
| fetch_header_overrides["Date"] = client_date | ||
| async with backend_rqst.fetch(headers=fetch_header_overrides) as backend_resp: |
There was a problem hiding this comment.
The same fetch_header_overrides construction is duplicated in web_handler, web_handler_with_jwt, and web_plugin_handler. Consider factoring this into a small helper (e.g., build a per-request override dict from frontend_rqst.headers) to reduce repetition and keep future header-forwarding changes consistent across handlers.
| # Preserve the client's original Date header so that signature-based | |
| # auth schemes that bind the signature to Date keep working through | |
| # the proxy. fetch() otherwise refreshes Date unconditionally. | |
| fetch_header_overrides: dict[str, str] = {} | |
| if (client_date := frontend_rqst.headers.get("Date")) is not None: | |
| fetch_header_overrides["Date"] = client_date | |
| async with backend_rqst.fetch(headers=fetch_header_overrides) as backend_resp: | |
| def _build_fetch_header_overrides(headers: CIMultiDict[str]) -> dict[str, str]: | |
| # Preserve the client's original Date header so that signature-based | |
| # auth schemes that bind the signature to Date keep working through | |
| # the proxy. fetch() otherwise refreshes Date unconditionally. | |
| fetch_header_overrides: dict[str, str] = {} | |
| if (client_date := headers.get("Date")) is not None: | |
| fetch_header_overrides["Date"] = client_date | |
| return fetch_header_overrides | |
| async with backend_rqst.fetch( | |
| headers=_build_fetch_header_overrides(frontend_rqst.headers) | |
| ) as backend_resp: |
| @@ -354,6 +385,9 @@ def connect_websocket( | |||
|
|
|||
| This method only works with | |||
| :class:`~ai.backend.client.session.AsyncSession`. | |||
|
|
|||
| :param headers: Header overrides applied after the auto-populated | |||
| headers. See :meth:`fetch` for details. | |||
| """ | |||
| if not isinstance(self.session, AsyncSession): | |||
| raise RuntimeError("Cannot use websockets with sessions in the synchronous mode") | |||
| @@ -365,6 +399,8 @@ def connect_websocket( | |||
| self.headers["Date"] = self.date.isoformat() | |||
| # websocket is always a "binary" stream. | |||
| self.content_type = "application/octet-stream" | |||
| if headers: | |||
| self._apply_header_overrides(headers) | |||
|
|
|||
| def _ws_ctx_builder() -> _WSRequestContextManager: | |||
| full_url = self._build_url() | |||
| @@ -385,14 +421,22 @@ def _ws_ctx_builder() -> _WSRequestContextManager: | |||
|
|
|||
| return WebSocketContextManager(self.session, _ws_ctx_builder, **kwargs) | |||
|
|
|||
| def connect_events(self, **kwargs: Any) -> SSEContextManager: | |||
| def connect_events( | |||
| self, | |||
| *, | |||
| headers: Mapping[str, str] | None = None, | |||
| **kwargs: Any, | |||
| ) -> SSEContextManager: | |||
| """ | |||
| Creates a Server-Sent Events connection. | |||
|
|
|||
| .. warning:: | |||
|
|
|||
| This method only works with | |||
| :class:`~ai.backend.client.session.AsyncSession`. | |||
|
|
|||
| :param headers: Header overrides applied after the auto-populated | |||
| headers. See :meth:`fetch` for details. | |||
| """ | |||
| if not isinstance(self.session, AsyncSession): | |||
| raise RuntimeError("Cannot use event streams with sessions in the synchronous mode") | |||
| @@ -403,6 +447,8 @@ def connect_events(self, **kwargs: Any) -> SSEContextManager: | |||
| raise RuntimeError("Failed to set request date") | |||
| self.headers["Date"] = self.date.isoformat() | |||
| self.content_type = "application/octet-stream" | |||
| if headers: | |||
| self._apply_header_overrides(headers) | |||
There was a problem hiding this comment.
New behavior was added for connect_websocket(..., headers=...) and connect_events(..., headers=...), but the unit tests added here only cover fetch(). Since these are public entry points and apply overrides (including special-casing Date), consider adding small unit tests that assert header overrides are applied and self.date is updated (or not) for WebSocket/SSE as well.
| If ``Date`` is overridden, parse it back into ``self.date`` so that | ||
| request signing uses the same value the upstream server will see. | ||
| Required for use cases like proxying a pre-signed request where the | ||
| original ``Date`` must be preserved end-to-end. | ||
| """ | ||
| for key, value in overrides.items(): | ||
| self.headers[key] = value | ||
| if key.lower() == "date": | ||
| self.date = parse_datetime(value) |
There was a problem hiding this comment.
Overriding the on-the-wire Date header can break HMAC signing when the override is not already in ISO-8601 format: generate_signature() signs with date.isoformat(), while the Manager verifies signatures using the raw Date header string (see src/ai/backend/manager/api/rest/middleware/auth.py:396-405, which uses request["raw_date"]). With the current override behavior, a value like RFC1123 (Tue, 02 Sep ... GMT) will produce a different signed string than what the server reconstructs. Consider adjusting the signing path to use the exact header value that will be sent (e.g., thread the raw Date string into signing), or normalize the overridden header to the canonical format expected by the signature scheme.
| If ``Date`` is overridden, parse it back into ``self.date`` so that | |
| request signing uses the same value the upstream server will see. | |
| Required for use cases like proxying a pre-signed request where the | |
| original ``Date`` must be preserved end-to-end. | |
| """ | |
| for key, value in overrides.items(): | |
| self.headers[key] = value | |
| if key.lower() == "date": | |
| self.date = parse_datetime(value) | |
| If ``Date`` is overridden, normalize it to the canonical ISO-8601 | |
| representation used by request signing so the on-the-wire header value | |
| exactly matches what is included in the HMAC signature. | |
| """ | |
| for key, value in overrides.items(): | |
| if key.lower() == "date": | |
| self.date = parse_datetime(value) | |
| self.headers[key] = self.date.isoformat() | |
| else: | |
| self.headers[key] = value |
| for key, value in overrides.items(): | ||
| self.headers[key] = value | ||
| if key.lower() == "date": | ||
| self.date = parse_datetime(value) |
There was a problem hiding this comment.
parse_datetime() can return a naive datetime when the overridden Date value has no timezone info. Elsewhere in the codebase the server normalizes missing tzinfo to UTC before using the date (e.g., manager/api/rest/middleware/auth.py:356-359). To keep client-side signing and date handling consistent, consider normalizing self.date to tzutc() when parse_datetime(value).tzinfo is None, and (optionally) failing fast with a clearer error if the Date override is unparsable.
| self.date = parse_datetime(value) | |
| try: | |
| parsed_date = parse_datetime(value) | |
| except (TypeError, ValueError) as exc: | |
| raise ValueError(f"Invalid Date header override: {value!r}") from exc | |
| if parsed_date.tzinfo is None: | |
| parsed_date = parsed_date.replace(tzinfo=tzutc()) | |
| self.date = parsed_date |
Restrict the client `Date`-header forwarding added in the previous commit to the anonymous (signature pass-through) path. In the re-signing path the proxy signs with its own keypair, so a client-supplied `Date` would let the caller dictate the timestamp signed on the wire — keep `fetch()`'s auto-refreshed value there. - Extract `_pass_through_date_header` in `web/proxy.py` that returns an empty override unless `api_session.config.is_anonymous` is True. - Use it from `web_handler`, `web_handler_with_jwt`, and `web_plugin_handler`. - Make `Request._apply_header_overrides` resilient to unparseable `Date` values: forward the header but leave `self.date` alone so signing (when enabled) stays internally consistent and the proxy cannot be DoS'd via a malformed client `Date`. - Add unit tests for the helper covering all three branches and for the unparseable-Date fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The change adds a new public `headers=` keyword on `Request.fetch()` / `connect_websocket()` / `connect_events()` — a permanent additive SDK surface — and uses it to enable Date-header signature pass-through that the proxy never previously supported. That is a feature, not a regression repair, so the towncrier fragment moves from `.fix.md` to `.feature.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
headers=keyword onRequest.fetch()/connect_websocket()/connect_events()so callers can override the auto-populated request headers. WhenDateis overridden, the value is parsed back intoRequest.dateso request signing stays consistent with what the upstream server sees on the wire.web_handler,web_handler_with_jwt,web_plugin_handler) to forward the frontend's originalDateheader through this new override when present.Dateheader from working through the Webserver proxy. PreviouslyRequest.fetch()always refreshedDatetonow(), breaking the upstream signature.Closes #5737.
Test plan
pants test tests/unit/client/test_request.py— three new cases cover override propagation, default behavior, and arbitrary header pass-through.pants fmt/lint/checkon the changed files.🤖 Generated with Claude Code