Skip to content

Stop fabricating 200 for every HEAD at the origins - #1578

Merged
feruzm merged 1 commit into
developfrom
fix/origin-head-and-header-always
Aug 20, 2026
Merged

Stop fabricating 200 for every HEAD at the origins#1578
feruzm merged 1 commit into
developfrom
fix/origin-head-and-header-always

Conversation

@feruzm

@feruzm feruzm commented Aug 20, 2026

Copy link
Copy Markdown
Member

Closes #1575.

location / short-circuited HEAD with a bare return 200, so a HEAD request never reached the app and reported a live page whatever the real status would have been.

Reproduced against both origins before the change:

EU origin      GET=307  HEAD=200  HEAD-HSTS=0
US origin      GET=307  HEAD=200  HEAD-HSTS=0

and after:

EU origin      GET=307  HEAD=307  HEAD-HSTS=1
US origin      GET=307  HEAD=307  HEAD-HSTS=1

The always flags are the same fix, not a tidy-up

While every HEAD was a fabricated 200, the security headers applied because 200 is in add_header's default status list. Deleting the block alone would therefore have made HEAD reach real 404/429/5xx responses with the headers gone — a security regression that looks correct in a 200/307 spot check. always on its own fixes nothing either, since an add_header nested inside the if suppresses inheritance regardless of it. Both halves ship together.

Verified after the change:

eu.ecency.com  HEAD 404 -> 404, security headers present
us.ecency.com  HEAD 404 -> 404, security headers present

X-Cache-Status deliberately keeps its default status list: it is a cache diagnostic, not something we owe an error response.

Behaviour change worth stating

Error responses now carry CORS and HSTS where they previously did not. That is intended, and matches what X-Origin-Region already did.

Verification notes

Both boxes were edited and reloaded in one window before this commit, so the tracked copies match what is running; one origin fixed and the other not would be the same bypass-by-routing failure the README's newsletter example describes.

Probing through ecency.com is a false negative for this bug — the edge normalises the request and already returned 307 with the headers. It only reproduces direct to the origin.

origin-config-audit passes unchanged: 0 findings, 34 self-test cases.

Summary by CodeRabbit

  • Bug Fixes
    • Security and cross-origin headers are now consistently included, including on error responses.
    • Removed special handling that returned empty responses for HEAD requests.

location / short-circuited HEAD with a bare return 200, so a HEAD never
reached the app and reported a live page whatever the real status was.
Reproduced against both origins before the change: GET /@good-karma/points
answered 307 while HEAD answered 200. Link checkers, uptime probes and
crawlers were all told the wrong thing.

The 'always' flags are the other half of the same fix rather than a
separate tidy-up. Those statuses were unreachable via HEAD while every
response was a fabricated 200, which is in add_header's default status
list, so deleting the block on its own would have quietly dropped HSTS
from HEAD on 404, 429 and 5xx. 'always' on its own fixes nothing either:
an add_header nested inside the if suppresses inheritance regardless.

X-Cache-Status keeps its default status list on purpose. It is a cache
diagnostic, not something we owe an error response.

Applied to both boxes and reloaded in one window before committing, so
the tracked copies match what is running. Verified direct to each origin,
since through Cloudflare the bug is invisible: the edge normalises the
request and already answered 307 with the headers.
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 20, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Fix origin HEAD responses and preserve security headers on non-2xx

🐞 Bug fix ⚙️ Configuration changes 🕐 10-20 Minutes

Grey Divider

AI Description

• Remove nginx origin HEAD short-circuit that incorrectly returned 200 for any path.
• Apply add_header ... always so CORS/HSTS/security headers remain on 4xx/5xx.
• Keep X-Cache-Status on default status list as a cache-only diagnostic.
Diagram

graph TD
  C["Client / Prober"] --> O["Origin Nginx (EU/US)"] --> A["Upstream app"] --> R["Real status (e.g., 307/404/5xx)"]
  O --> H["Security headers (always)"]
  O --> X["X-Cache-Status (default)"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Move security headers to server-level context
  • ➕ Reduces risk of location-level inheritance quirks (e.g., nested if blocks).
  • ➕ Ensures consistent header policy across all locations by default.
  • ➖ May unintentionally apply headers to locations that previously opted out (e.g., static assets, special endpoints).
  • ➖ May require careful auditing of other locations to avoid behavior changes.
2. Keep a HEAD block but proxy upstream (no `return 200`)
  • ➕ Allows special-casing HEAD behavior (e.g., caching controls) without lying about status.
  • ➕ Can be used to tune HEAD-specific headers safely.
  • ➖ More configuration complexity than necessary; nginx already handles HEAD correctly when proxying.
  • ➖ Higher chance of future regressions from conditional/header inheritance.

Recommendation: The PR’s approach is the best default: remove the HEAD short-circuit so HEAD reflects upstream status, and add always to security headers to avoid a silent regression on non-2xx/3xx responses. Alternatives mainly trade simplicity for extra policy control and would require additional auditing to avoid unintended header scope changes.

Files changed (2) +12 / -20

Bug fix (2) +12 / -20
eu.ecency.com.confStop returning fabricated 200 on HEAD; make security headers unconditional +6/-10

Stop returning fabricated 200 on HEAD; make security headers unconditional

• Removes the 'if ($request_method = HEAD) { return 200; }' block so HEAD requests reach the upstream app and return real statuses. Adds 'always' to CORS/HSTS/security header directives to keep them present on error responses.

infra/origin/eu.ecency.com.conf

us.ecency.com.confStop returning fabricated 200 on HEAD; make security headers unconditional +6/-10

Stop returning fabricated 200 on HEAD; make security headers unconditional

• Removes the origin-level HEAD short-circuit that forced a 200 response regardless of upstream status. Updates security header 'add_header' directives with 'always' to ensure consistent header presence across non-2xx responses.

infra/origin/us.ecency.com.conf

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 65bae7d3-d806-4358-9fea-c44db201ad93

📥 Commits

Reviewing files that changed from the base of the PR and between 2e39f2b and 909af78.

📒 Files selected for processing (2)
  • infra/origin/eu.ecency.com.conf
  • infra/origin/us.ecency.com.conf

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Both origin vhosts now apply frontend security and CORS headers on all responses. They also remove special HEAD handling that returned a fabricated 200 response.

Changes

Origin vhost behavior

Layer / File(s) Summary
Frontend response handling
infra/origin/eu.ecency.com.conf, infra/origin/us.ecency.com.conf
Security and CORS headers now use always. The special HEAD response branch was removed from both frontend locations.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 909af

This localized configuration change makes HEAD requests return the origin's real status and preserves security headers on error responses; no actionable merge-blocking risk remains after normal checks and review.

Poem

A rabbit checked each origin with care,
Headers now follow responses everywhere.
No false 200 for a HEAD request,
Real status codes now pass the test.
“Hop!” said the rabbit, “the routes are correct!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: removing fabricated 200 responses for HEAD requests at both origin vhosts.
Linked Issues check ✅ Passed The changes remove the HEAD short-circuit so the application returns the real status, and preserve required headers on error responses for issue #1575.
Out of Scope Changes check ✅ Passed All changes are limited to the two origin vhosts and directly support correct HEAD status handling and response-header behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/origin-head-and-header-always

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes direct-origin HEAD requests follow the real frontend proxy path rather than returning an unconditional 200, while preserving security headers across error statuses.

  • Removes the HEAD-specific short-circuit from both EU and US origin configurations.
  • Adds always to CORS, HSTS, and browser security headers.
  • Keeps both production origin configurations aligned.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

Both active origin configurations consistently remove the fabricated HEAD response and preserve the intended security headers when the upstream returns redirects or errors.

Important Files Changed

Filename Overview
infra/origin/eu.ecency.com.conf Correctly delegates HEAD requests to the EU frontend upstream and retains security headers on all response statuses.
infra/origin/us.ecency.com.conf Mirrors the EU origin fix, preserving equivalent HEAD and security-header behavior across origins.

Reviews (1): Last reviewed commit: "Stop fabricating 200 for every HEAD at t..." | Re-trigger Greptile

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Duplicate CORS header 🐞 Bug ≡ Correctness
Description
With add_header ... always, the origin will now append Access-Control-Allow-Origin: * to *all*
statuses even when the upstream already sets it (e.g., /api/oembed error responses), producing
duplicate Access-Control-Allow-Origin header fields. This creates ambiguous/non-compliant
responses and can break cross-origin consumers that expect a single ACAO value.
Code

infra/origin/eu.ecency.com.conf[R153-154]

+        add_header Access-Control-Allow-Origin * always;
+        add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains; preload' always;
Evidence
The PR changes nginx to always add ACAO for every response status. The app already sets ACAO for
/api/oembed (including error responses), so after this change those error responses will carry
multiple Access-Control-Allow-Origin headers (one from upstream, one from nginx).

infra/origin/eu.ecency.com.conf[153-159]
infra/origin/us.ecency.com.conf[148-154]
apps/web/src/app/api/oembed/route.ts[12-25]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`add_header Access-Control-Allow-Origin * always;` now forces ACAO onto 4xx/5xx too. For endpoints that already emit ACAO from the app (notably `/api/oembed`), responses will contain duplicate `Access-Control-Allow-Origin` header fields.

### Issue Context
The app route explicitly sets `Access-Control-Allow-Origin: *` on both success and error responses; after this PR, nginx also adds the same header on error statuses, causing duplicates.

### Fix Focus Areas
- infra/origin/eu.ecency.com.conf[153-161]
- infra/origin/us.ecency.com.conf[148-156]

### Suggested fix
In `location /` for both origin vhosts, hide upstream copies of headers that nginx sets globally (at least `Access-Control-Allow-Origin`, and optionally other globally-set security headers if the app ever emits them), then keep the `add_header ... always` lines.

Example:
```nginx
proxy_hide_header Access-Control-Allow-Origin;
# (optional) proxy_hide_header X-Content-Type-Options;
# (optional) proxy_hide_header X-Frame-Options;

add_header Access-Control-Allow-Origin * always;
...
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This is a security-relevant behavior change in two origin Nginx configurations, affecting HEAD status propagation and error-response headers across both origins; it warrants a complete single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread infra/origin/eu.ecency.com.conf
@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Informational

1. HEAD converted to GET 🐞 Bug ≡ Correctness
Description
With proxy_cache enabled in location /, nginx’s default proxy_cache_convert_head on converts
client HEAD requests into upstream GET requests, so the app may still not receive a true HEAD. This
can increase SSR work for HEAD probes and can diverge if the upstream treats HEAD differently from
GET.
Code

infra/origin/eu.ecency.com.conf[L161-164]

-	if ($request_method = HEAD) {
-	add_header Cache-Control no-store;
-	return 200;
-        }
Relevance

● Weak

PR explicitly intends true upstream HEAD behavior; matching proposals to remove the fabricated HEAD
branch were rejected.

PR-#1574

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR removes the HEAD early-return, so HEAD requests now flow through the cached location /.
Nginx defaults to converting HEAD to GET when proxy caching is enabled unless
proxy_cache_convert_head is turned off; the configs shown enable proxy_cache and do not override
that default.

infra/origin/eu.ecency.com.conf[115-168]
infra/origin/us.ecency.com.conf[115-163]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`location /` enables `proxy_cache`, and nginx defaults `proxy_cache_convert_head on`, which converts HEAD to GET for caching. After removing the HEAD short-circuit, HEAD now reaches the upstream, but may do so as GET, changing method semantics and potentially increasing upstream work.

### Issue Context
- `proxy_cache` is enabled in `location /`, and the cache key does not include `$request_method`.
- Nginx documents that when `proxy_cache_convert_head` is disabled, the cache key should include `$request_method`.

### Fix Focus Areas
- infra/origin/eu.ecency.com.conf[133-167]
- infra/origin/us.ecency.com.conf[133-162]

### Suggested change
In both origin vhosts’ `location /` blocks:
1) Add `proxy_cache_convert_head off;`
2) Update `proxy_cache_key` to include `$request_method` (e.g., `proxy_cache_key "$request_method$request_uri$html_limited_bot";`).

This preserves the upstream HEAD method and prevents HEAD and GET from sharing cache entries.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. HEAD now rate-limited 🐞 Bug ☼ Reliability
Description
Removing the HEAD short-circuit makes HEAD requests subject to limit_req and limit_conn in
location /, so HEAD probes can now return 429 instead of reliably reaching the app. This can break
monitoring/health checks that use HEAD and can make HEAD-based diagnostics unstable under load.
Code

infra/origin/us.ecency.com.conf[L156-159]

-	if ($request_method = HEAD) {
-	add_header Cache-Control no-store;
-	return 200;
-        }
Relevance

● Weak

PR explicitly accepts normal location rate limiting for HEAD; matching proposals to remove the
fabricated HEAD branch were rejected.

PR-#1574

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The PR deletes the HEAD short-circuit block. The same location / contains rate limiting and
connection limiting directives that now apply to HEAD as well, which can cause 429s for HEAD
requests under the configured limits.

infra/origin/eu.ecency.com.conf[115-146]
infra/origin/us.ecency.com.conf[115-146]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
After deleting the HEAD early-return, HEAD requests traverse the same `location /` as normal traffic, which includes multiple `limit_req` directives and `limit_conn`. HEAD requests are therefore throttled and may return 429 during traffic bursts, which can break HEAD-based monitoring.

### Issue Context
- `limit_req` and `limit_conn` apply to all request methods in the location.
- Previously, HEAD bypassed these limits due to the early return.

### Fix Focus Areas
- infra/origin/eu.ecency.com.conf[115-167]
- infra/origin/us.ecency.com.conf[115-162]

### Suggested change options
Pick one:
1) Add a dedicated lightweight health endpoint (e.g., `location = /healthz`) with no caching and no rate/conn limits, and have monitoring use that instead of `HEAD /`.
2) If you must keep `HEAD /` for monitoring, implement an explicit exemption for HEAD from the throttles (e.g., using a `map` at `http{}` scope to set a variable for HEAD and conditionalize `limit_req`/`limit_conn` via separate locations or an include that splits HEAD handling cleanly).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 82 rules
✅ Skills: 6 invoked
  add-feature
  add-query
  add-sdk-mutation
  add-test
  code-review
  debug
✅ Web pages:
  +2 more
Review mode: ⚖️ Balanced: This is a behavioral origin/proxy configuration change affecting HEAD status handling and security headers on error responses, so it warrants a careful single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@feruzm

feruzm commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

On the second finding, HEAD converted to GET: correct, and intended.

proxy_cache_convert_head defaults to on, so nginx turns a client HEAD into an upstream GET and shares the cache entry with the equivalent GET. That is the behaviour we want here, and the alternative is worse: adding $request_method to proxy_cache_key would split the entries and double the SSR renders for exactly the traffic this PR makes reachable. Deliberately not done.

It does mean a HEAD on an uncached path now costs a render where it previously cost nothing, because it previously returned a fabricated 200 without reaching the app. That is the price of the answer being true, and it lands on link checkers and uptime probes rather than on readers. Worth watching, not worth trading correctness for.

Verified after the change, direct to each origin:

EU origin      GET=307  HEAD=307  HEAD-HSTS=1
US origin      GET=307  HEAD=307  HEAD-HSTS=1
eu.ecency.com  HEAD 404 -> 404, security headers present
us.ecency.com  HEAD 404 -> 404, security headers present

and real pages still answer 200 on both methods on both boxes.

@feruzm
feruzm merged commit e2dd010 into develop Aug 20, 2026
9 checks passed
@feruzm
feruzm deleted the fix/origin-head-and-header-always branch August 20, 2026 16:55
feruzm added a commit that referenced this pull request Aug 20, 2026
#1575 is the issue describing the fabricated 200; #1578 is the PR that
removed it. The line read as though the issue did the work, which sends
anyone following it to a report rather than a diff. Cites both now, since
each answers a different question: the PR carries the change and the
before/after measurements, the issue carries the reproduction.

The main README already said #1578, so the two agree again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Origin vhosts answer every HEAD request with a fabricated 200

1 participant