Skip to content

fix: forward request headers when checking existing - #201

Closed
vincentsarago wants to merge 1 commit into
mainfrom
fix/forward-headers-in-validate-transactions
Closed

vincentsarago wants to merge 1 commit into
mainfrom
fix/forward-headers-in-validate-transactions

Conversation

@vincentsarago

Copy link
Copy Markdown
Member

disclaimer: This PR was mostly done by Claude but I reviewed all the steps

I hit an issue when trying to create/update/delete collections (using PUT requests) where I got 502 errors 👇

re-running ingest_data.py against an already-seeded database hits a 502 Bad Gateway on the PUT (update) fallback. That's in the external stac-auth-proxy dependency, not this repo — Cql2ValidateTransactionMiddleware._fetch_existing (Cql2ValidateTransactionMiddleware.py:123-129) does an internal GET to fetch the existing record for update-validation but never forwards the caller's Authorization header, so it 401s against a private endpoint and that gets mapped to a 502. Worth a heads-up since your ingest script is idempotent-by-design (POST-then-PUT-on-409) and will hit this on every re-run

@vincentsarago
vincentsarago requested a review from alukach July 28, 2026 02:36
@claude

claude Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

Claude finished @vincentsarago's task in 2m 2s —— View job


Claude's Task List

  • Gather context (diff, files, CLAUDE.md)
  • Review correctness / security
  • Apply ponytail-review for over-engineering
  • Post final review

✅ No blocking issues — safe to merge.

The fix is correct and well-scoped: EnforceAuthMiddleware runs before Cql2ValidateTransactionMiddleware in the middleware stack (app.py — last-added-runs-first), so the caller is already authenticated by the time this middleware's internal _fetch_existing GET fires. Forwarding the caller's Authorization/Cookie headers to that internal call is therefore appropriate, not a privilege-escalation concern. The header-stripping list (host, content-length, content-type, transfer-encoding) correctly prevents the original bodied PUT/PATCH's Content-Length/Content-Type from leaking onto the bodyless internal GET, which the regression test (test_does_not_forward_incoming_content_length) confirms.

Non-blocking observations:

  • Cql2ValidateTransactionMiddleware.py:25-27 — _forward_headers builds a dict via {k: v for k, v in headers.items() ...}. Starlette's Headers.items() can yield repeated keys for multi-valued headers (e.g. multiple Forwarded/X-Forwarded-For from an upstream proxy); the dict comprehension silently keeps only the last occurrence. Unlikely to matter for the Authorization/Cookie case this PR targets, so not blocking.
  • Cql2ValidateTransactionMiddleware.py:133-139 — _fetch_existing's headers parameter is typed Optional[Headers] = None with a ... if headers else None fallback, but both call sites (_handle_update, _handle_delete) always pass a real Headers object now — the None path is unreachable in practice.

Simplify (ponytail)

  • Cql2ValidateTransactionMiddleware.py:133-139 — drop the now-dead Optional[...] = None default and the if headers else None ternary on _fetch_existing; make headers: Headers required and pass it straight to _forward_headers, since every real caller already supplies it.

💰 Estimated review cost: $0.75 · 2m01s · 21 turns

@github-actions github-actions Bot added the fix label Jul 28, 2026
@vincentsarago

Copy link
Copy Markdown
Member Author

@alukach could you review this one 🙈 ?

@alukach

alukach commented Sep 21, 2026

Copy link
Copy Markdown
Member

never forwards the caller's Authorization header, so it 401s against a private endpoint and that gets mapped to a 502.

@vincentsarago This doesn't make sense to me. Why does the private STAC API require the authorization header?

@vincentsarago

Copy link
Copy Markdown
Member Author

@alukach

Why does the private STAC API require the authorization header

🤔 well, stac-auth-proxy will call the API itself to check the existing items, so if the API requires authorization, stac-auth-proxy should have the headers

@alukach

alukach commented Sep 22, 2026

Copy link
Copy Markdown
Member

🤔 well, stac-auth-proxy will call the API itself to check the existing items, so if the API requires authorization, stac-auth-proxy should have the headers

And this is an experience that you're having? You have a STAC API that requires authentication in addition to the STAC Auth Proxy (which is commonly where authentication requirements are imposed)?

@vincentsarago

Copy link
Copy Markdown
Member Author

We use stac-auth-proxy middlewares integrated within our application, so yes the endpoints requires authentication

alukach added a commit that referenced this pull request Oct 3, 2026
## Problem

`Cql2ValidateTransactionMiddleware` fetches the existing record for
PUT/PATCH/DELETE with its own `httpx` client pointed at `upstream_url`.
When stac-auth-proxy is used **as middleware** around a STAC API,
`upstream_url` points back at the same app, so that GET re-enters
`EnforceAuthMiddleware` without credentials and fails (a 401 surfaced to
the caller as a 502).

#201 fixes this by forwarding the caller's headers onto the GET, but
forwarding everything also carries `If-None-Match`/`Range` (→ 304/206
with no JSON body → uncaught 500) and `Accept-Encoding` (e.g. `zstd` →
undecodable body).

## Change

Issue the GET **in-process** to the downstream ASGI app (`self.app`)
instead of over HTTP:

- **Middleware mode:** reaches the STAC API's routes directly. No
network hop, no auth needed.
- **Proxy mode:** goes through `ReverseProxyHandler` to the upstream
exactly like any other request (same client, timeout, `Forwarded`
headers).
- The sub-request sits below the auth middleware in both modes, so no
credentials are forwarded. Only `host` and `accept: application/json`
are sent, which closes off the conditional and encoding problems above.
- Removes the middleware's own `httpx.AsyncClient` and its
`upstream_url` argument.
- Non-200/404 responses and non-JSON bodies raise `UpstreamError`, which
is mapped to the existing 502 `UpstreamError` response.

**Behavior note:** in the full stack the sub-request passes through
`Cql2ValidateResponseBodyMiddleware` with the caller's filter, so a
record the caller can't read is reported as 404, matching the existing
"existing record doesn't match filter → 404" behavior.

## Tests

- `TestFetchExistingMiddlewareMode`: an outer auth stand-in rejects
requests without `Authorization`. PUT/DELETE succeed, and the in-process
GET arrives with no `authorization`/`if-none-match`/`accept-encoding`.
Also covers denied-existing → 404, missing → 404, and downstream 500 →
502.
- `TestFetchExistingProxyMode`: real `ReverseProxyHandler` over
`httpx.MockTransport`. The upstream sees a clean GET followed by the PUT
(with the caller's auth). An upstream 503 on the GET → 502 and the PUT
is never forwarded.
- Full suite: 489 passed, 92% coverage.

Supersedes #201.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@alukach

alukach commented Oct 3, 2026

Copy link
Copy Markdown
Member

Closed in favor of #214

@alukach alukach closed this Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants