Skip to content

Preserve the original host on forwarded Server Action requests - #96552

Draft
EnricoOrt wants to merge 2 commits into
vercel:canaryfrom
EnricoOrt:fix/forwarded-action-original-host
Draft

Preserve the original host on forwarded Server Action requests#96552
EnricoOrt wants to merge 2 commits into
vercel:canaryfrom
EnricoOrt:fix/forwarded-action-original-host

Conversation

@EnricoOrt

@EnricoOrt EnricoOrt commented Aug 3, 2026

Copy link
Copy Markdown

What

Restores the original Host header on a Server Action request that Next.js forwarded to another worker, so headers().get('host') inside the action reports the host the user requested instead of the server's internal origin.

Fixes #96344

Why

When an action POST lands on a route whose page doesn't bundle that action, handleAction forwards it to a worker that does (selectWorkerForForwardingcreateForwardedActionResponse). The forward is an HTTP self-fetch to the server's own origin, normally http://localhost:PORT.

host is a forbidden fetch header, so it can't be carried onto the subrequest; fetch derives Host from the URL it is given. The action ends up seeing:

POST /a  (page bundles the action)   host: "repro.example"    x-forwarded-host: "repro.example"
POST /b  (page does NOT bundle it)   host: "localhost:4111"   x-forwarded-host: "repro.example"

Reading the tenant from headers().get('host') is a common self-hosting pattern, and it breaks only for the subset of actions that happen to get forwarded, which depends on the build's static import graph versus where the action is actually invoked. Shared client components and intercepted or parallel routes routinely land a POST on a route that doesn't bundle the action. The reporter measures about 1,000 failed actions a day across 9 domains, all surfacing as "project not found: localhost:3000".

x-forwarded-host does survive the forward and still carries the original host, so the information is recoverable.

How

restoreForwardedActionHost rewrites host from x-forwarded-host. It runs in base-server.ts immediately after attachRequestMeta, and the placement is load-bearing in both directions. It has to be after attachRequestMeta, because the origin it compares against can come from the initURL request meta, and that call is what sets it. It has to be before i18nProvider.detectDomainLocale, a few lines further down, which is the first thing in the request lifecycle to read host. Doing the rewrite inside handleAction would leave the locale lookup reading the internal origin.

The marker header is not authenticated, so it is treated as a hint rather than proof. The rewrite requires all four of:

  1. x-action-forwarded is exactly 1, the only value createForwardedActionResponse ever sends. A repeated header arrives comma-joined and fails this.
  2. The request is a fetch action. handleAction only forwards a POST carrying an action id, so no other request shape can have come through the forwarding path.
  3. x-forwarded-host is present.
  4. host matches the host of the origin this server forwards to, port included.

The send and receive sides have to agree on that origin, so both now go through getActionForwardingOrigin(req): __NEXT_PRIVATE_ORIGIN when it is set, otherwise the origin of initURL. That also replaces two identical copies of the resolution block, in createForwardedActionResponse and createRedirectRenderResult.

The initURL fallback needed a correction from the first version of this PR, which claimed initURL already carries the right host. It only does under experimental.trustHostHeader. When the server is started with a hostname and port, which is the self-hosted case in the issue, attachRequestMeta builds initURL as ${protocol}://${fetchHostname}:${port}${req.url} and the bug is still live, so that path now gets the rewrite too. The trustHostHeader case is deliberately excluded: there initURL comes from the request's own host header, which would make guard 4 vacuously true. base-server passes hasConfiguredOrigin: Boolean(this.fetchHostname && this.port), mirroring the condition in attachRequestMeta, and the helper bails when there is neither a private origin nor a configured one.

On trust. parseHostHeader already prefers x-forwarded-host over host for the action CSRF origin check, so the value itself isn't newly trusted. What is new is that it becomes visible as headers().get('host'), and that is the value host-based tenancy reads. So, plainly: the marker is a hint, not authentication. In the normal topology the forwarding origin is loopback and unreachable from outside, so guard 4 can't be satisfied over the network. A proxy that presents an internal Host to Next.js while also passing through a client-controlled x-forwarded-host would satisfy all four guards. Authenticating the marker with a per-server secret would close that. I left it out because it's a larger change than the issue calls for, and I'd rather have a maintainer opinion on it first.

The obvious alternative doesn't work: x-action-forwarded can't just be added to INTERNAL_HEADERS. filterInternalHeaders runs on every ingress in router-server.ts, including the genuine loopback request, so listing it there would strip the real marker.

ACTION_FORWARDED_HEADER, ACTION_FORWARDED_VALUE, getForwardedHostValue, getActionForwardingOrigin and restoreForwardedActionHost now live in app-render/action-forwarding.ts, shared between base-server.ts and action-handler.ts. getForwardedHostValue is the existing x-forwarded-host list parsing lifted out of parseHostHeader rather than duplicated.

Tests

Unit, action-forwarding.test.ts, 25 cases. Both list forms of x-forwarded-host, both origin sources, and the rejection paths: a forged marker arriving at a host that isn't the internal one, marker values "", "true", "0", "1, 1" and "yes", non fetch-action requests, missing x-forwarded-host, a malformed private origin, and the trustHostHeader shape where initURL is derived from the request.

e2e, test/e2e/app-dir/action-forward-host. /with-action bundles the action and /without-action doesn't, so a POST to the latter goes through the forwarding path. Both arms are asserted, so the test pins the invariant rather than only the symptom, and it checks x-action-forwarded to prove forwarding actually ran. A third case sends a forged marker together with a forged x-forwarded-host and asserts host comes through unchanged.

It uses raw node:http instead of next.fetch, because undici refuses to set host. Same forbidden-header rule that causes the bug.

skipDeployment: true. The test needs a local port to connect to, has to send a Host different from the one it connected to, and reads the action's output from the running server's stdout. A deploy instance has a remote URL, no local app port, and build logs rather than live server output. The behaviour under test is a loopback self-fetch to the server's own origin, which is inherently self-hosted.

Before and after:

before   host=localhost:54034   actionForwarded="1"   ✗ Expected "example.test"
after    host=example.test      actionForwarded="1"   ✓

Green after rebasing onto current canary: the new suite in dev and start for both webpack and Turbopack, action-forward-loop, actions-allowed-origins including "should error if x-forwarded-host does not match the origin", the new unit suite, and tsc.

Out of scope

createRedirectRenderResult does a second self-fetch for app-relative redirect streaming and loses Host the same way. It shares the origin resolver now, but not the restoration: it sets x-action-forwarded only when the original action was itself forwarded, so it needs its own marker and the same provenance decision as above. Happy to follow up separately.

For an Edge action target, runEdgeFunction builds the request URL from initURL, so request.url still shows the internal origin even though headers().get('host') is restored.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Failing test suites

Commit: f363259 | About building and testing Next.js

pnpm test-dev test/e2e/app-dir/back-before-hydration/back-before-hydration.test.ts (job)

  • back navigation before hydration after reload > no Suspense boundary above the page > reconciles the URL with the rendered content once hydration completes (DD)
  • back navigation before hydration after reload > no Suspense boundary above the page > reconciles when the traversed entry differs only in search params (DD)
  • back navigation before hydration after reload > no Suspense boundary above the page > other history changes before hydration > leaves the traversal unhandled when a third-party write lands before the replay (DD)
Expand output

● back navigation before hydration after reload › no Suspense boundary above the page › reconciles the URL with the rendered content once hydration completes

expect(received).toBe(expected) // Object.is equality

Expected: "Home"
Received: "Post"

  127 |       await retry(async () => {
  128 |         expect(new URL(await browser.url()).pathname).toBe(homePath)
> 129 |         expect(await browser.elementByCss('h1').text()).toBe('Home')
      |                                                         ^
  130 |       })
  131 |
  132 |       // History traversal must still work after recovery.

  at toBe (e2e/app-dir/back-before-hydration/back-before-hydration.test.ts:129:57)
  at retry (lib/next-test-utils.ts:868:14)
  at Object.<anonymous> (e2e/app-dir/back-before-hydration/back-before-hydration.test.ts:127:7)

● back navigation before hydration after reload › no Suspense boundary above the page › reconciles when the traversed entry differs only in search params

expect(received).toBe(expected) // Object.is equality

Expected: "Page 1"
Received: "Page 2"

  158 |       await retry(async () => {
  159 |         expect(new URL(await browser.url()).search).toBe('?page=1')
> 160 |         expect(await browser.elementByCss('h1').text()).toBe('Page 1')
      |                                                         ^
  161 |       })
  162 |
  163 |       await browser.forward()

  at toBe (e2e/app-dir/back-before-hydration/back-before-hydration.test.ts:160:57)
  at retry (lib/next-test-utils.ts:868:14)
  at Object.<anonymous> (e2e/app-dir/back-before-hydration/back-before-hydration.test.ts:158:7)

● back navigation before hydration after reload › no Suspense boundary above the page › other history changes before hydration › leaves the traversal unhandled when a third-party write lands before the replay

expect(received).toBe(expected) // Object.is equality

Expected: "?tp=1"
Received: ""

  298 |           // particular the router must not reload a page that just loaded.
  299 |           expect(await browser.eval('window.__stayed')).toBe(true)
> 300 |           expect(new URL(await browser.url()).search).toBe('?tp=1')
      |                                                       ^
  301 |           expect(await browser.elementByCss('h1').text()).toBe('Post')
  302 |         })
  303 |

  at toBe (e2e/app-dir/back-before-hydration/back-before-hydration.test.ts:300:55)
  at retry (lib/next-test-utils.ts:868:14)
  at Object.<anonymous> (e2e/app-dir/back-before-hydration/back-before-hydration.test.ts:295:9)

pnpm test-dev-turbo test/development/acceptance-app/app-hmr-changes.test.ts (turbopack) (job)

  • Error overlay - RSC build errors > Skipped in webpack > should handle successive HMR changes with errors correctly (DD)
Expand output

● Error overlay - RSC build errors › Skipped in webpack › should handle successive HMR changes with errors correctly

Application is in inconsistent state: timeout.

  114 |             }
  115 |             if (status !== 'pending') {
> 116 |               throw new Error(
      |                     ^
  117 |                 `Application is in inconsistent state: ${status}.`
  118 |               )
  119 |             }

  at Object.patch (lib/development-sandbox.ts:116:21)
  at Object.<anonymous> (development/acceptance-app/app-hmr-changes.test.ts:58:11)

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🔴 2 regressions

Metric Canary PR Change
node_modules Size 553 MB 553 MB 🔴 +117 kB (+0%)
Webpack Build Time (cached) 24.672s 25.308s 🔴 +636ms (+3%)
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change
Cold (Listen) 813ms 813ms
Cold (Ready in log) 796ms 787ms
Cold (First Request) 1.330s 1.313s
Warm (Listen) 814ms 813ms
Warm (Ready in log) 794ms 791ms
Warm (First Request) 1.320s 1.308s
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change
Cold (Listen) 812ms 813ms
Cold (Ready in log) 787ms 784ms
Cold (First Request) 3.393s 3.421s
Warm (Listen) 813ms 811ms
Warm (Ready in log) 784ms 778ms
Warm (First Request) 3.389s 3.419s

⚡ Production Builds

Metric Canary PR Change
Fresh Build 6.070s 6.281s
Cached Build 3.029s 2.978s
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change
Fresh Build 24.499s 24.371s
Cached Build 24.672s 25.308s 🔴 +636ms (+3%)
node_modules Size 553 MB 553 MB 🔴 +117 kB (+0%)
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
0_z1ozsra2_86.js gzip 157 B N/A -
00r3fx3eeevxf.js gzip 158 B N/A -
050icza-xjz0i.js gzip 5.73 kB N/A -
07jdby0ue616s.js gzip 450 B N/A -
0anzicvq0iwy6.js gzip 46.7 kB N/A -
0bjdc8muo74n5.js gzip 8.71 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0rci1f3or1a19.js gzip 13.3 kB N/A -
1_2x714--ii1i.js gzip 8.76 kB N/A -
1-3y752pkth5-.js gzip 10 kB N/A -
1-pbgw6jtibj4.js gzip 157 B N/A -
111w718tsk0_p.js gzip 71.5 kB N/A -
140m6hb0qpu8v.js gzip 157 B N/A -
160n3skrv1yk5.js gzip 157 B N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1fsqx5xpdxd-c.js gzip 153 B N/A -
1hy0e5cihni_x.js gzip 7.54 kB N/A -
1tf1phijqlx9j.js gzip 220 B 220 B
1uq5fmtkqhu5v.js gzip 158 B N/A -
1uzabyd1120a1.js gzip 8.71 kB N/A -
2-ufv8lc-g7gg.js gzip 10.6 kB N/A -
21kmjy_10x14f.js gzip 8.81 kB N/A -
22alyybqz5_09.js gzip 3.57 kB N/A -
28dhc6t85q1_p.js gzip 8.78 kB N/A -
2ahs26ct36f9f.js gzip 65.6 kB N/A -
2btssrfgthink.js gzip 158 B N/A -
2f-ilvczue-tp.js gzip 9.46 kB N/A -
2f1u17u5c8iny.js gzip 8.79 kB N/A -
2ikltg_8iegxw.js gzip 10.3 kB N/A -
2vxi673cz1-t4.js gzip 8.79 kB N/A -
38-q43pzktqhs.js gzip 1.46 kB N/A -
3a-pgojsoq5_w.js gzip 158 B N/A -
3cruv3lb9957o.js gzip 169 B N/A -
3mrwxzed0ylgv.js gzip 13.1 kB N/A -
3nbojhxiy1qv_.js gzip 13.7 kB N/A -
3t5_y-uw--87g.js gzip 159 B N/A -
3wbfmg_ucf8de.js gzip 162 B N/A -
3xbq084zz-vz8.js gzip 163 B N/A -
41u5s3oe2-erp.js gzip 2.29 kB N/A -
445s_9hf8o7ao.js gzip 8.76 kB N/A -
turbopack-1l..-5yv.js gzip 3.73 kB 3.73 kB
0-6bcj16ji2wf.js gzip N/A 10.6 kB -
07ryk0jced-sc.js gzip N/A 8.78 kB -
0j64ls9v56hnd.js gzip N/A 3.56 kB -
0jwho9fkrb_t3.js gzip N/A 2.29 kB -
0lzqiphzyraxb.js gzip N/A 155 B -
0qma3l_t-wns4.js gzip N/A 168 B -
0roh390ijzxa5.js gzip N/A 8.75 kB -
0s7ldmg9glcmx.js gzip N/A 46.7 kB -
0vex9w55ursqj.js gzip N/A 8.79 kB -
0ynj-axdw_was.js gzip N/A 155 B -
12uzup57ecfw9.js gzip N/A 160 B -
17oe55cu76cd7.js gzip N/A 450 B -
1822r4f_m9r_g.js gzip N/A 154 B -
1drww5xikb-c-.js gzip N/A 9.46 kB -
1g60xde_dv17t.js gzip N/A 8.79 kB -
1srvuraudh358.js gzip N/A 71.5 kB -
1uzv47btzam64.js gzip N/A 5.73 kB -
1vy7n7wxv_rh5.js gzip N/A 8.81 kB -
2-0i7pl900-ou.js gzip N/A 8.71 kB -
2-kcbngm7ik7y.js gzip N/A 8.75 kB -
252pm4-yjo-g8.js gzip N/A 157 B -
28w0eio9rc6cp.js gzip N/A 156 B -
29p5-xa4jmdhg.js gzip N/A 13.7 kB -
2lpk5_hknut8q.js gzip N/A 13.1 kB -
2ugc69z0t0ypz.js gzip N/A 1.46 kB -
32pajiszar8p9.js gzip N/A 155 B -
3301-f5avh3en.js gzip N/A 157 B -
345geq-0tw2nd.js gzip N/A 156 B -
36a4lu088knjc.js gzip N/A 151 B -
36dn8i-_3dnq5.js gzip N/A 10 kB -
3dtb8zg2hpy5x.js gzip N/A 156 B -
3fljpmwcjxqhx.js gzip N/A 10.3 kB -
3gz44skqdlsrb.js gzip N/A 8.71 kB -
3m29pi-s05gfl.js gzip N/A 65.6 kB -
3oslhuq9luvp2.js gzip N/A 7.55 kB -
3s04zj-jqprop.js gzip N/A 155 B -
3y9tus7kb5su0.js gzip N/A 13.3 kB -
Total 401 kB 401 kB ✅ -17 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 1.05 kB 1.06 kB
Total 1.05 kB 1.06 kB ⚠️ +2 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 881 B 876 B
Total 881 B 876 B ✅ -5 B
Build Cache
Canary PR Change
00000001.sst gzip 13.7 MB 13.2 MB 🟢 535 kB (-4%)
00000002.sst gzip 15.9 MB 13.8 MB 🟢 2.12 MB (-13%)
00000003.sst gzip 12.5 MB 14.5 MB 🔴 +2 MB (+16%)
00000004.sst gzip 13 MB 13.7 MB 🔴 +704 kB (+5%)
00000005.sst gzip 2.81 MB 10.2 MB 🔴 +7.42 MB (+264%)
00000006.sst gzip 10.2 MB 2.81 MB 🟢 7.4 MB (-72%)
00000007.sst gzip 59 B 59 B
00000008.meta gzip 89 B 89 B
00000009.meta gzip 298 kB 298 kB
00000010.meta gzip 298 kB 298 kB
00000011.meta gzip 298 kB 298 kB
00000012.sst gzip 51.4 kB 51.7 kB
00000013.sst gzip 1.87 MB 1.84 MB 🟢 24.4 kB (-1%)
00000014.sst gzip 59 B 59 B
00000015.meta gzip 116 B 116 B
00000016.meta gzip 325 kB 325 kB
00000017.meta gzip 405 kB 404 kB
00000018.sst gzip 52.1 kB 52.4 kB
00000019.sst gzip 1.4 MB 1.4 MB 🟢 3.96 kB (0%)
00000020.sst gzip 59 B 59 B
00000021.meta gzip 116 B 116 B
00000022.meta gzip 325 kB 325 kB
00000023.meta gzip 371 kB 371 kB
00000024.sst gzip 52.2 kB 52.4 kB
00000025.sst gzip 1.4 MB 1.39 MB 🟢 4.22 kB (0%)
00000026.sst gzip 59 B 59 B
00000027.meta gzip 116 B 116 B
00000028.meta gzip 325 kB 325 kB
00000029.meta gzip 371 kB 371 kB
00000030.sst gzip 51.5 kB 51.8 kB
00000031.sst gzip 1.4 MB 1.39 MB 🟢 4 kB (0%)
00000032.sst gzip 59 B 59 B
00000033.meta gzip 116 B 116 B
00000034.meta gzip 325 kB 325 kB
00000035.meta gzip 371 kB 371 kB
00000036.sst gzip 52.2 kB 52.3 kB
00000037.sst gzip 1.4 MB 1.39 MB 🟢 4.32 kB (0%)
00000038.sst gzip 59 B 59 B
00000039.meta gzip 116 B 116 B
00000040.meta gzip 325 kB 325 kB
00000041.meta gzip 371 kB 371 kB
CURRENT gzip 24 B 24 B
LOG gzip 670 B 675 B
Total 80.2 MB 80.3 MB ⚠️ +23.3 kB

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 65.7 kB N/A -
4191.HASH.js gzip 169 B N/A -
7920-HASH.js gzip 4.67 kB N/A -
9784-HASH.js gzip 5.63 kB N/A -
b1ad9f4c-HASH.js gzip 63.2 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 253 B 253 B
main-HASH.js gzip 40.1 kB 40.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
3577.HASH.js gzip N/A 168 B -
578-HASH.js gzip N/A 66.4 kB -
8590-HASH.js gzip N/A 5.61 kB -
9750-HASH.js gzip N/A 4.68 kB -
a8984546-HASH.js gzip N/A 63.2 kB -
Total 241 kB 242 kB ⚠️ +654 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B
_error-HASH.js gzip 181 B 182 B
css-HASH.js gzip 334 B 331 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 255 B 253 B
head-HASH.js gzip 349 B 351 B
hooks-HASH.js gzip 382 B 384 B
image-HASH.js gzip 581 B 582 B
index-HASH.js gzip 260 B 259 B
link-HASH.js gzip 2.48 kB 2.48 kB
routerDirect..HASH.js gzip 317 B 318 B
script-HASH.js gzip 384 B 386 B
withRouter-HASH.js gzip 316 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.95 kB 7.96 kB ⚠️ +4 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 129 kB 128 kB
page.js gzip 294 kB 295 kB
Total 423 kB 423 kB ⚠️ +557 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 618 B 616 B
middleware-r..fest.js gzip 156 B 156 B
middleware.js gzip 45.7 kB 45.3 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 47.3 kB 46.9 kB ✅ -358 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 717 B 718 B
Total 717 B 718 B ⚠️ +1 B
Build Cache
Canary PR Change
0.pack gzip 4.8 MB 4.79 MB
index.pack gzip 122 kB 122 kB
index.pack.old gzip 122 kB 123 kB 🔴 +1.27 kB (+1%)
Total 5.04 MB 5.04 MB ✅ -2.17 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 375 kB 375 kB
app-page-exp..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 374 kB 374 kB
app-page-tur..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 371 kB 371 kB
app-page-tur..prod.js gzip 205 kB 205 kB
app-page.run...dev.js gzip 371 kB 371 kB
app-page.run..prod.js gzip 205 kB 205 kB
app-route-ex...dev.js gzip 83.3 kB 83.3 kB
app-route-ex..prod.js gzip 56.4 kB 56.4 kB
app-route-tu...dev.js gzip 83.3 kB 83.3 kB
app-route-tu..prod.js gzip 56.4 kB 56.4 kB
app-route-tu...dev.js gzip 82.9 kB 82.9 kB
app-route-tu..prod.js gzip 56.2 kB 56.2 kB
app-route.ru...dev.js gzip 82.8 kB 82.8 kB
app-route.ru..prod.js gzip 56.2 kB 56.2 kB
dev-validati...dev.js gzip 133 kB 133 kB
dev-validati...dev.js gzip 133 kB 133 kB
dev-validati...dev.js gzip 131 kB 131 kB
dev-validati...dev.js gzip 131 kB 131 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 46 kB 46 kB
pages-api-tu..prod.js gzip 34.3 kB 34.3 kB
pages-api.ru...dev.js gzip 46 kB 46 kB
pages-api.ru..prod.js gzip 34.3 kB 34.3 kB
pages-turbo....dev.js gzip 54.8 kB 54.8 kB
pages-turbo...prod.js gzip 39.9 kB 39.9 kB
pages.runtim...dev.js gzip 54.8 kB 54.8 kB
pages.runtim..prod.js gzip 39.9 kB 39.9 kB
server.runti..prod.js gzip 67 kB 67.3 kB
use-cache-pr...dev.js gzip 72.3 kB 72.3 kB
use-cache-pr...dev.js gzip 72.4 kB 72.4 kB
use-cache-pr...dev.js gzip 70.6 kB 70.6 kB
use-cache-pr...dev.js gzip 70.6 kB 70.6 kB
Total 4.1 MB 4.1 MB ⚠️ +278 B
📝 Changed Files (9 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • server.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js

Diff too large to display

app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js

Diff too large to display

app-page-tur..time.prod.js

Diff too large to display

app-page.runtime.dev.js

Diff too large to display

app-page.runtime.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/f3632590331defe4d9654eb8f92688c76bc56450/next

Commit: f363259

@DavidIlie

Copy link
Copy Markdown
Contributor

good start

@EnricoOrt
EnricoOrt marked this pull request as draft August 7, 2026 20:08
When an action POST lands on a route that does not bundle the action, it is
forwarded to a worker that does, via an internal self-fetch to
`__NEXT_PRIVATE_ORIGIN`. `host` is a forbidden `fetch` header, so it cannot be
carried onto the subrequest: the action saw `headers().get('host')` as
`localhost:PORT` instead of the host the user requested. That breaks
host-based multi-tenancy for exactly those actions that happen to get
forwarded, which depends on the build's static import graph rather than
anything visible in the application.

Restore `host` from `x-forwarded-host`, which does survive the forward,
guarded on the request being marked `x-action-forwarded` and having arrived at
the internal origin. `x-forwarded-host` is already preferred over `host` by
`parseHostHeader` for the CSRF origin check, so this aligns userland
`headers()` with the value the framework already trusts.

Fixes vercel#96344
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.

Forwarded Server Actions expose the internal origin (localhost:PORT) via headers() — breaks host-based multi-tenancy when self-hosting

2 participants