Skip to content

feat(BA-2257): Forward client Date header through Webserver proxy#11335

Open
rapsealk wants to merge 3 commits intomainfrom
fix/BA-2257-forward-date-header
Open

feat(BA-2257): Forward client Date header through Webserver proxy#11335
rapsealk wants to merge 3 commits intomainfrom
fix/BA-2257-forward-date-header

Conversation

@rapsealk
Copy link
Copy Markdown
Member

Summary

  • Adds an optional headers= keyword on Request.fetch() / connect_websocket() / connect_events() so callers can override the auto-populated request headers. When Date is overridden, the value is parsed back into Request.date so request signing stays consistent with what the upstream server sees on the wire.
  • Updates the three Webserver proxy entry points (web_handler, web_handler_with_jwt, web_plugin_handler) to forward the frontend's original Date header through this new override when present.
  • Unblocks signature-based authentication schemes that bind the signature to the Date header from working through the Webserver proxy. Previously Request.fetch() always refreshed Date to now(), 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 / check on the changed files.
  • Manual: send a Date-signed request through Webserver to a Manager with strict signature validation and confirm it is accepted upstream (please verify in staging before merge).

🤖 Generated with Claude Code

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>
@rapsealk rapsealk added this to the 26.5 milestone Apr 27, 2026
Copilot AI review requested due to automatic review settings April 27, 2026 04:48
@github-actions github-actions Bot added size:L 100~500 LoC comp:client Related to Client component comp:webserver Related to Web Server component labels Apr 27, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to Request.fetch(), Request.connect_websocket(), and Request.connect_events(), with special handling for Date.
  • Update Webserver proxy handlers to forward the frontend request’s Date via the new override mechanism.
  • Add unit tests covering fetch() default vs overridden Date behavior 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.

Comment thread src/ai/backend/web/proxy.py Outdated
Comment on lines +258 to +264
# 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:
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/ai/backend/web/proxy.py Outdated
Comment on lines +258 to +264
# 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:
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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:

Copilot uses AI. Check for mistakes.
Comment on lines 374 to +451
@@ -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)
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/ai/backend/client/request.py Outdated
Comment on lines +285 to +293
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)
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread src/ai/backend/client/request.py Outdated
for key, value in overrides.items():
self.headers[key] = value
if key.lower() == "date":
self.date = parse_datetime(value)
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
@rapsealk rapsealk changed the title fix(BA-2257): preserve client Date header through Webserver proxy fix(BA-2257): Forward client Date header through Webserver proxy Apr 27, 2026
rapsealk and others added 2 commits April 27, 2026 13:58
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>
@rapsealk rapsealk changed the title fix(BA-2257): Forward client Date header through Webserver proxy feat(BA-2257): Forward client Date header through Webserver proxy Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp:client Related to Client component comp:webserver Related to Web Server component size:L 100~500 LoC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support headers override in Request.fetch() to enable Date header–based signature auth with proxy

2 participants