Your Next.js BFF Stopped XSS, But What About CSRF?
The Cost of httpOnly Cookies — Seven Experts Debate
"Every security decision is a trade-off. When you close one door, you need to know which window just opened." — Jim Manico, OWASP Board of Directors
「每個安全決策都是一種取捨。當你關上一扇門,你得知道哪扇窗戶剛被打開了。」—— Jim Manico,OWASP 董事會
The Setup
In the previous articles, we established that the BFF (Backend-for-Frontend) pattern with httpOnly cookies is the correct approach for a separated Next.js 16 architecture. JWT tokens live in httpOnly cookies — JavaScript can't touch them, XSS can't steal them. The BFF extracts the cookie and forwards it as Authorization: Bearer <token> to the backend API.
Victory, right? Not so fast.
When tokens lived in localStorage, the developer explicitly added Authorization: Bearer <token> to each request. Cross-origin requests from evil.com couldn't access localStorage, so they couldn't attach the token. CSRF was impossible because the browser didn't automatically send credentials.
But httpOnly cookies? The browser attaches them to every request to your domain — including requests triggered by a malicious page:
<!-- On evil.com -->
<form action="https://your-bff.com/api/proxy/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
<input type="submit" value="Click for free stuff!" />
</form>The browser sends the httpOnly cookie. The BFF sees a valid JWT. The backend processes the request. The user never intended to make that transfer.
You solved XSS. You created CSRF.
How do you fix it? We assembled seven experts to fight about it.
場景設定
在前幾篇文章中,我們確認了 BFF(Backend-for-Frontend)模式搭配 httpOnly cookies 是前後端分離 Next.js 16 架構的正確做法。JWT token 存在 httpOnly cookie 中——JavaScript 碰不到、XSS 偷不走。BFF 取出 cookie,以 Authorization: Bearer <token> 轉發給後端 API。
勝利了,對吧?沒那麼快。
當 token 存在 localStorage 時,開發者明確地對每個請求加上 Authorization: Bearer <token>。來自 evil.com 的跨來源請求無法存取 localStorage,所以無法附加 token。CSRF 不可能發生,因為瀏覽器不會自動發送憑證。
但 httpOnly cookies?瀏覽器會把它附加在每一個對你網域的請求上——包括由惡意頁面觸發的請求:
<!-- 在 evil.com 上 -->
<form action="https://your-bff.com/api/proxy/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="10000" />
<input type="submit" value="點擊獲取免費好康!" />
</form>瀏覽器發送 httpOnly cookie。BFF 看到有效的 JWT。後端處理請求。用戶從未打算進行那筆轉帳。
你解決了 XSS,你製造了 CSRF。
怎麼修?我們集結了七位專家來吵這件事。
Roundtable Participants:
Security:
- Marcus Webb — Application Security Lead, 15 years in penetration testing, has exploited CSRF in production apps across 3 fortune-500 companies
- Elena Kowalski — Cloud Security Architect at a major bank, OWASP contributor, specializes in cookie security and CDN hardening
Frontend Architecture:
- Raj Patel — Staff Frontend Engineer, maintains an internal BFF framework used by 8 product teams, migrated 300+ API endpoints from localStorage-based auth to httpOnly cookies
- Lina Torres — Senior Frontend Engineer, led the CSRF remediation effort after a real incident at a fintech startup
Infrastructure & Runtime:
- David Park — Staff SRE at a fintech unicorn, deployed Next.js BFF on AWS ECS with 12 services across 4 subdomains
- Sarah Chen — Principal Engineer, former Next.js core contributor, knows the Server Actions CSRF implementation at the source code level
Moderator:
- Ana Reyes — Engineering Director, moderating
圓桌會議參與者:
安全:
- Marcus Webb — Application Security Lead,15 年滲透測試經驗,曾在 3 家財富 500 大企業的正式環境中利用過 CSRF
- Elena Kowalski — 大型銀行的 Cloud Security Architect,OWASP 貢獻者,專精 cookie 安全和 CDN 強化
前端架構:
- Raj Patel — Staff Frontend Engineer,維護一個供 8 個產品團隊使用的內部 BFF 框架,將 300 多個 API 端點從 localStorage 認證遷移到 httpOnly cookies
- Lina Torres — Senior Frontend Engineer,在一家金融科技新創公司的真實 CSRF 事件後主導了修復工作
基礎設施 & 運行時:
- David Park — 金融科技獨角獸的 Staff SRE,在 AWS ECS 上部署 Next.js BFF,跨 4 個子網域有 12 個服務
- Sarah Chen — Principal Engineer,前 Next.js 核心貢獻者,了解 Server Actions CSRF 實作的原始碼層級
主持人:
- Ana Reyes — Engineering Director,主持會議
Round 1: The Security Trade-off — Did We Just Trade One Vulnerability for Another?
Ana: Let's establish the problem. We moved tokens from localStorage to httpOnly cookies. Did we just shift the attack vector from XSS to CSRF?
Marcus: Yes. Unequivocally yes. And I want the room to understand why this is still the right trade-off before we argue about how to fix the new problem.
With localStorage, an XSS attacker gets your JWT and can use it from anywhere — their own machine, a script in another country, for as long as the token is valid. They can exfiltrate the token silently. You have no way to know it happened.
With httpOnly cookies, an XSS attacker cannot steal the token. They can make requests from the victim's browser while the victim is on the page, but they can't extract the token and use it elsewhere. The attack surface is bounded — it only works while the victim has the attacker's script running.
Lina: I experienced this first-hand. At my previous company, we had an XSS vulnerability in a user comment field. With localStorage-based auth, the attacker exfiltrated 2,000+ JWT tokens in 48 hours before we noticed. They had persistent access to every account. When we moved to httpOnly cookies, a similar XSS vulnerability the following year could only make requests during the victim's active session — and our monitoring caught the anomalous request patterns within 2 hours.
Elena: So the trade-off is: XSS becomes bounded, CSRF becomes the new concern. That's a net improvement, because CSRF is a solvable problem with well-understood defenses. XSS token theft with persistent access is much harder to contain.
Raj: I agree with the trade-off. But I want to point out something: in the BFF pattern, the CSRF risk is specifically about the BFF proxy endpoints — the Route Handlers at /api/proxy/*. These are the endpoints where the BFF reads the httpOnly cookie, extracts the JWT, and forwards it to the backend. If a cross-site request reaches these endpoints with the cookie attached, the BFF will proxy the request as if it were legitimate.
Sarah: And here's a crucial distinction most people miss: Server Actions have built-in CSRF protection. Route Handlers do not. Server Actions validate the Origin header against the Host header. If they don't match, the request is rejected. Route Handlers have zero such protection out of the box.
David: So the question isn't "is CSRF a problem?" — it clearly is. The question is: how many layers of defense do we need?
Ana: Perfect framing. Let's get into the specific defenses.
Vote: "Is the localStorage → httpOnly cookie trade-off net positive despite introducing CSRF risk?"
| Expert | Vote |
|---|---|
| Marcus Webb | Yes — bounded XSS is strictly better than unbounded token theft |
| Elena Kowalski | Yes — CSRF is solvable; persistent token theft is not |
| Raj Patel | Yes — with proper CSRF defenses in place |
| Lina Torres | Yes — I've lived through both; httpOnly is better |
| David Park | Yes |
| Sarah Chen | Yes |
Result: 6-0 — The trade-off is correct. Now let's fix the CSRF problem it creates.
第一回合:安全取捨——我們只是把一個漏洞換成了另一個嗎?
Ana: 讓我們先確立問題。我們把 token 從 localStorage 搬到了 httpOnly cookies。我們是不是只是把攻擊向量從 XSS 換成了 CSRF?
Marcus: 是的。毫無疑問是的。但我要讓在座的人先理解為什麼這仍然是正確的取捨,然後再來爭論怎麼修復新問題。
用 localStorage 時,XSS 攻擊者拿到你的 JWT 後可以從任何地方使用——他們自己的機器、另一個國家的腳本,只要 token 有效就行。他們可以悄悄地把 token 外洩。你根本不知道發生了什麼。
用 httpOnly cookies 時,XSS 攻擊者偷不走 token。他們只能在受害者停留在頁面時從受害者的瀏覽器發出請求,但無法提取 token 拿到別處使用。攻擊面是有限的——只在受害者的瀏覽器上跑著攻擊者的腳本時才有效。
Lina: 我親身經歷過。在我之前的公司,我們在用戶留言欄位有一個 XSS 漏洞。用 localStorage 認證時,攻擊者在 48 小時內外洩了 2,000 多個 JWT token,我們才注意到。他們對每個帳號都有持久的存取權。搬到 httpOnly cookies 後,隔年一個類似的 XSS 漏洞只能在受害者的活躍 session 期間發出請求——而且我們的監控在 2 小時內就捕捉到了異常的請求模式。
Elena: 所以取捨是:XSS 變得有限,CSRF 成為新的關注點。 這是淨改善,因為 CSRF 是一個有成熟防禦方案的可解問題。XSS token 竊取帶來的持久存取權要難控制得多。
Raj: 我同意這個取捨。但我要指出一點:在 BFF 模式中,CSRF 風險具體是針對 BFF 代理端點——/api/proxy/* 的 Route Handlers。這些端點是 BFF 讀取 httpOnly cookie、提取 JWT、轉發給後端的地方。如果跨站請求帶著 cookie 到達這些端點,BFF 就會像合法請求一樣代理它。
Sarah: 這裡有一個大多數人忽略的關鍵區別:Server Actions 有內建的 CSRF 保護。Route Handlers 沒有。 Server Actions 會驗證 Origin header 是否與 Host header 匹配。不匹配就拒絕。Route Handlers 開箱即用時零保護。
David: 所以問題不是「CSRF 是不是問題?」——顯然是。問題是:我們需要多少層防禦?
Ana: 完美的框架。讓我們進入具體的防禦方案。
投票:「從 localStorage 轉到 httpOnly cookie 的取捨,儘管引入了 CSRF 風險,整體是正面的嗎?」
| 專家 | 投票 |
|---|---|
| Marcus Webb | 是——有限的 XSS 嚴格優於無限的 token 竊取 |
| Elena Kowalski | 是——CSRF 可解;持久的 token 竊取不可解 |
| Raj Patel | 是——前提是有適當的 CSRF 防禦 |
| Lina Torres | 是——我經歷過兩者;httpOnly 更好 |
| David Park | 是 |
| Sarah Chen | 是 |
結果:6-0——取捨是正確的。現在讓我們修復它製造的 CSRF 問題。
Round 2: SameSite=Lax — Is It Enough on Its Own?
Ana: The most commonly cited defense is SameSite=Lax on the session cookie. Let's debate whether it's sufficient as a sole defense.
Elena: Let me explain exactly what SameSite=Lax does and doesn't do:
- Blocks: Cross-site POST, cross-site subresource requests (images, iframes), cross-site AJAX/fetch
- Allows: Top-level GET navigation from cross-site (clicking a link)
- Since Chrome 80 (February 2020):
Laxis the default for cookies without an explicitSameSiteattribute
For the classic CSRF attack — a form POST from evil.com to your-bff.com — SameSite=Lax blocks it completely. The browser won't send the cookie.
Raj: And if you explicitly set SameSite=Lax (not relying on Chrome's default), you avoid the 2-minute Lax+POST window. Chrome has a quirk: when it defaults a cookie to Lax because no attribute was set, it allows cross-site POST for the first 120 seconds. But if you explicitly set SameSite=Lax, this window does NOT apply.
Marcus: Standing up. I'm going to argue against relying solely on SameSite=Lax. And I have four concrete bypass scenarios.
Bypass 1: Subdomain attacks. SameSite operates at the "site" level, not the "origin" level. The "site" is the registrable domain — example.com. If you have app.example.com as your BFF and blog.example.com as your blog, they are the same site. An XSS on the blog can trigger POST requests to the BFF, and the browser will attach cookies. SameSite provides zero protection.
David: Raising hand. This is us. We have 12 services across 4 subdomains on the same domain. A vulnerability on any of those 4 subdomains can CSRF attack our BFF.
Marcus: Bypass 2: Client-side redirect gadgets. If your app has any open redirect — even a JavaScript-based one like window.location = userInput — an attacker can chain it. They navigate the user to your open redirect, which then redirects to the target endpoint. The final request originates from your domain, so it's same-site. Cookies attached.
Bypass 3: Method override. Some server frameworks support _method query parameters. A GET request to /api/transfer?_method=POST might be treated as POST by the server. Lax allows cross-site GET on top-level navigation. Next.js doesn't do this natively, but custom server logic could.
Bypass 4: GET endpoints that mutate state. If any GET endpoint performs a state-changing operation, SameSite=Lax provides no protection because Lax allows cross-site GET navigation.
Sarah: Marcus is right about every single one of those. But I want to give context: bypasses 3 and 4 are developer mistakes, not SameSite failures. Don't allow method override. Don't mutate state on GET. Bypass 2 requires an existing open redirect vulnerability.
Marcus: Bypass 1 is not a developer mistake. It's an architectural reality. If you operate multiple subdomains, SameSite alone is insufficient.
Elena: There's one more issue: non-browser clients. SameSite is enforced by the browser. Automated tools, scripts, and API clients can send any cookies they want. If an attacker obtains the session cookie through means other than a browser (network interception, for example), SameSite provides no protection.
Lina: OWASP is explicit about this. Let me quote: "SameSite Cookie Attribute should not replace having a CSRF Token. This cookie attribute should co-exist with that token in order to protect the user in a more robust way."
Ana: So what's the verdict on SameSite alone?
Vote: "Is SameSite=Lax sufficient as the SOLE CSRF defense for a Next.js BFF?"
| Expert | Vote |
|---|---|
| Marcus Webb | No — subdomain bypass is a real threat |
| Elena Kowalski | No — defense in depth required per OWASP |
| Raj Patel | No — necessary but not sufficient |
| Lina Torres | No — I've seen the subdomain attack in practice |
| David Park | No — we have 4 subdomains, SameSite alone is inadequate |
| Sarah Chen | No — it's a strong baseline but Route Handlers need more |
Result: 6-0 — SameSite=Lax is the foundation, not the complete solution.
第二回合:SameSite=Lax——它單獨夠用嗎?
Ana: 最常被引用的防禦是在 session cookie 上設定 SameSite=Lax。讓我們辯論它作為唯一防禦是否足夠。
Elena: 讓我精確說明 SameSite=Lax 做了什麼和沒做什麼:
- 阻擋: 跨站 POST、跨站子資源請求(圖片、iframe)、跨站 AJAX/fetch
- 允許: 從跨站的頂層 GET 導航(點擊連結)
- 自 Chrome 80(2020 年 2 月)起: 沒有明確
SameSite屬性的 cookie 預設為Lax
對於經典 CSRF 攻擊——從 evil.com 發出表單 POST 到 your-bff.com——SameSite=Lax 完全阻擋。瀏覽器不會發送 cookie。
Raj: 而且如果你明確設定 SameSite=Lax(不依賴 Chrome 的預設值),你就避免了 2 分鐘的 Lax+POST 視窗。Chrome 有一個怪癖:當它因為沒有設定屬性而預設一個 cookie 為 Lax 時,它會在前 120 秒內允許跨站 POST。但如果你明確設定 SameSite=Lax,這個視窗不適用。
Marcus: 站起來。 我要反對單獨依賴 SameSite=Lax。我有四個具體的繞過場景。
繞過 1:子網域攻擊。 SameSite 在「站」的層級運作,不是「來源」的層級。「站」是可註冊的網域——example.com。如果你的 BFF 在 app.example.com,部落格在 blog.example.com,它們是同一個站。部落格上的 XSS 可以對 BFF 觸發 POST 請求,瀏覽器會附加 cookies。SameSite 提供零保護。
David: 舉手。 這就是我們。我們在同一個網域下有 12 個服務跨 4 個子網域。任何一個子網域上的漏洞都可以 CSRF 攻擊我們的 BFF。
Marcus: 繞過 2:客戶端重定向小工具。 如果你的應用有任何開放重定向——即使是基於 JavaScript 的 window.location = userInput——攻擊者可以串聯利用。他們導航用戶到你的開放重定向,然後重定向到目標端點。最終請求來自你的網域,所以是同站的。Cookies 附加。
繞過 3:方法覆寫。 有些伺服器框架支援 _method 查詢參數。對 /api/transfer?_method=POST 的 GET 請求可能被伺服器當成 POST 處理。Lax 允許跨站頂層 GET 導航。Next.js 原生不做這個,但自訂的伺服器邏輯可能會。
繞過 4:會改變狀態的 GET 端點。 如果任何 GET 端點執行狀態變更操作,SameSite=Lax 不提供保護,因為 Lax 允許跨站 GET 導航。
Sarah: Marcus 說的每一個都是對的。但我要給出上下文:繞過 3 和 4 是開發者的錯誤,不是 SameSite 的失敗。不要允許方法覆寫。不要在 GET 上改變狀態。繞過 2 需要一個已存在的開放重定向漏洞。
Marcus: 繞過 1 不是開發者的錯誤。它是架構現實。如果你運營多個子網域,單獨 SameSite 就是不夠的。
Elena: 還有一個問題:非瀏覽器客戶端。 SameSite 由瀏覽器強制執行。自動化工具、腳本和 API 客戶端可以隨意發送任何 cookies。如果攻擊者透過瀏覽器以外的方式取得 session cookie(例如網路攔截),SameSite 不提供保護。
Lina: OWASP 對此有明確說明。讓我引用:「SameSite Cookie Attribute 不應取代 CSRF Token。這個 cookie 屬性應與 token 共存,以更穩健的方式保護用戶。」
Ana: 那對單獨使用 SameSite 的結論是什麼?
投票:「SameSite=Lax 作為 Next.js BFF 的唯一 CSRF 防禦是否足夠?」
| 專家 | 投票 |
|---|---|
| Marcus Webb | 不夠——子網域繞過是真實威脅 |
| Elena Kowalski | 不夠——OWASP 要求縱深防禦 |
| Raj Patel | 不夠——必要但不充分 |
| Lina Torres | 不夠——我在實務中見過子網域攻擊 |
| David Park | 不夠——我們有 4 個子網域,單獨 SameSite 不夠 |
| Sarah Chen | 不夠——它是強大的基線,但 Route Handlers 需要更多 |
結果:6-0——SameSite=Lax 是基礎,不是完整方案。
Round 3: Custom Header — The Pragmatic Defense for Route Handlers
Ana: So we need something beyond SameSite. What's the most practical defense for BFF proxy Route Handlers?
Raj: Custom header requirement. It's simple, stateless, and devastatingly effective. Here's why:
HTML forms — the classic CSRF vector — cannot set custom headers. Only JavaScript can. And JavaScript from a different origin is subject to CORS. When JavaScript sends a request with a custom header, the browser automatically sends a CORS preflight (OPTIONS request) first. If the server doesn't respond with Access-Control-Allow-Headers including that custom header for the attacker's origin, the browser blocks the actual request. The actual request is never sent.
// Client-side: always include the custom header
export async function bffFetch(path: string, options: RequestInit = {}) {
const headers = new Headers(options.headers);
headers.set('X-Requested-With', 'NextBFF');
headers.set('Content-Type', 'application/json');
return fetch(`/api/proxy/${path}`, {
...options,
headers,
credentials: 'same-origin',
});
}// Server-side: validate the custom header
// app/api/proxy/[...path]/route.ts
export async function POST(request: Request) {
const customHeader = request.headers.get('X-Requested-With');
if (customHeader !== 'NextBFF') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// ... proceed with proxying
}Marcus: I'll confirm this from the attacker's perspective. To bypass custom header checking, I would need one of:
- XSS on the same origin (then I can set any header)
- A CORS misconfiguration that allows my origin with the custom header
- A server-side bug that processes requests without the header
Options 2 and 3 are developer mistakes. Option 1 means they already have a bigger problem.
Sarah: OWASP explicitly endorses this approach: "If this is the case for your system, you can simply verify the presence of this header." They note that all modern browsers designate requests with custom headers as "to be preflighted."
Lina: But there's a subtlety. The custom header defense works for JavaScript-initiated requests — which is everything in a modern SPA or BFF pattern. But it doesn't work for plain HTML form submissions.
Raj: In our BFF architecture, there are no plain HTML form submissions to proxy endpoints. All client-to-BFF communication is through fetch(). Server Actions handle form submissions, and they have their own Origin check. So this isn't a gap for us.
David: I want to add: requiring Content-Type: application/json provides a similar benefit. HTML forms can only send three Content-Types: application/x-www-form-urlencoded, multipart/form-data, and text/plain. Any other Content-Type triggers CORS preflight. So requiring JSON doubles up the protection.
Marcus: Cautiously. But the server MUST validate the Content-Type. There's a known bypass: an attacker sends Content-Type: text/plain with a JSON body. If the server doesn't check Content-Type and still parses it as JSON, the preflight doesn't fire. Apollo GraphQL documents this exact bypass.
Elena: So the implementation must validate both the custom header AND the Content-Type:
export async function POST(request: Request) {
// Check 1: Custom header
if (request.headers.get('X-Requested-With') !== 'NextBFF') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Check 2: Content-Type
const contentType = request.headers.get('Content-Type');
if (!contentType?.includes('application/json')) {
return NextResponse.json(
{ error: 'Content-Type must be application/json' },
{ status: 415 }
);
}
// ... proceed
}Vote: "Is custom header + Content-Type validation the right primary defense for BFF proxy Route Handlers?"
| Expert | Vote |
|---|---|
| Marcus Webb | Yes — simple, stateless, effective |
| Elena Kowalski | Yes — combined with Content-Type validation |
| Raj Patel | Yes — we've used this in production for 2 years |
| Lina Torres | Yes — minimal complexity, maximum impact |
| David Park | Yes |
| Sarah Chen | Yes — complements Server Actions' built-in Origin check |
Result: 6-0 — Custom header requirement is the primary defense for Route Handlers.
第三回合:Custom Header——Route Handlers 的務實防禦
Ana: 所以我們需要 SameSite 之外的東西。BFF 代理 Route Handlers 最務實的防禦是什麼?
Raj: Custom header(自訂 header)要求。簡單、無狀態、而且毀滅性地有效。原因是:
HTML 表單——經典的 CSRF 向量——無法設定自訂 headers。只有 JavaScript 可以。而來自不同來源的 JavaScript 受 CORS 約束。當 JavaScript 發送帶有自訂 header 的請求時,瀏覽器會自動先發送 CORS 預檢(OPTIONS 請求)。如果伺服器沒有為攻擊者的來源回應包含該自訂 header 的 Access-Control-Allow-Headers,瀏覽器就阻止實際請求。實際請求永遠不會被發送。
// 客戶端:永遠包含自訂 header
export async function bffFetch(path: string, options: RequestInit = {}) {
const headers = new Headers(options.headers);
headers.set('X-Requested-With', 'NextBFF');
headers.set('Content-Type', 'application/json');
return fetch(`/api/proxy/${path}`, {
...options,
headers,
credentials: 'same-origin',
});
}// 伺服器端:驗證自訂 header
// app/api/proxy/[...path]/route.ts
export async function POST(request: Request) {
const customHeader = request.headers.get('X-Requested-With');
if (customHeader !== 'NextBFF') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// ... 繼續代理
}Marcus: 我從攻擊者的角度確認。要繞過 custom header 檢查,我需要以下之一:
- 同源的 XSS(那我可以設定任何 header)
- CORS 錯誤配置允許我的來源使用自訂 header
- 伺服器端 bug 處理了沒有 header 的請求
選項 2 和 3 是開發者的錯誤。選項 1 意味著他們已經有更大的問題。
Sarah: OWASP 明確背書這個方法:「如果你的系統是這種情況,你可以簡單地驗證這個 header 的存在。」他們指出所有現代瀏覽器都把帶有自訂 headers 的請求指定為「需要預檢」。
Lina: 但有一個微妙之處。Custom header 防禦對 JavaScript 發起的請求有效——在現代 SPA 或 BFF 模式中這就是所有東西。但對純 HTML 表單提交無效。
Raj: 在我們的 BFF 架構中,沒有對代理端點的純 HTML 表單提交。所有客戶端到 BFF 的通訊都通過 fetch()。Server Actions 處理表單提交,它們有自己的 Origin 檢查。所以這對我們不是缺口。
David: 我要補充:要求 Content-Type: application/json 提供類似的好處。HTML 表單只能發送三種 Content-Type:application/x-www-form-urlencoded、multipart/form-data 和 text/plain。其他任何 Content-Type 都會觸發 CORS 預檢。所以要求 JSON 加倍了保護。
Marcus: 謹慎地。 但伺服器必須驗證 Content-Type。有一個已知的繞過:攻擊者發送 Content-Type: text/plain 帶 JSON body。如果伺服器不檢查 Content-Type 仍然將其解析為 JSON,預檢不會觸發。Apollo GraphQL 記錄了這個確切的繞過。
Elena: 所以實作必須同時驗證 custom header 和 Content-Type:
export async function POST(request: Request) {
// 檢查 1:Custom header
if (request.headers.get('X-Requested-With') !== 'NextBFF') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// 檢查 2:Content-Type
const contentType = request.headers.get('Content-Type');
if (!contentType?.includes('application/json')) {
return NextResponse.json(
{ error: 'Content-Type must be application/json' },
{ status: 415 }
);
}
// ... 繼續
}投票:「Custom header + Content-Type 驗證是否是 BFF 代理 Route Handlers 正確的主要防禦?」
| 專家 | 投票 |
|---|---|
| Marcus Webb | 是——簡單、無狀態、有效 |
| Elena Kowalski | 是——搭配 Content-Type 驗證 |
| Raj Patel | 是——我們在正式環境用了 2 年 |
| Lina Torres | 是——最小複雜度,最大影響 |
| David Park | 是 |
| Sarah Chen | 是——補充了 Server Actions 的內建 Origin 檢查 |
結果:6-0——Custom header 要求是 Route Handlers 的主要防禦。
Round 4: Server Actions' Built-in Protection — Can We Trust It?
Ana: Sarah, you mentioned Server Actions have built-in CSRF protection. Walk us through it, and then let's debate whether it's trustworthy.
Sarah: Server Actions are always HTTP POST. The built-in protection works like this:
- Next.js reads the
Originheader from the incoming request - It compares it against the
Hostheader (orX-Forwarded-Hostwhen behind a reverse proxy) - If they don't match, the request is rejected
The implementation is in packages/next/src/server/app-render/action-handler.ts. It uses an isCsrfOriginAllowed function that supports exact domain matching, single-level wildcards (*.example.com), and multi-level wildcards (**.example.com). Matching is case-insensitive per RFC 1035.
David: There's a critical ECS deployment detail. When you're behind AWS ALB, the Host header seen by Next.js might be the ALB's internal hostname, not your public domain. You need to configure allowedOrigins:
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
allowedOrigins: [
'app.example.com',
'staging.example.com',
],
},
},
};Without this, Server Actions behind ALB will reject legitimate requests because Origin (https://app.example.com) doesn't match Host (the internal ALB hostname).
Marcus: Now let me attack this. I have three concerns:
Concern 1: The protection ONLY covers Server Actions. If I find a way to trigger the same backend mutation through a Route Handler instead, the Origin check doesn't apply. In a BFF, the proxy Route Handler at /api/proxy/transfer has no such protection.
Sarah: Correct. That's why custom header validation on Route Handlers is essential.
Marcus: Concern 2: CVE-2025-29927. In March 2025, attackers discovered they could bypass Next.js middleware entirely by adding an x-middleware-subrequest header. If your CSRF protection ran in middleware (now proxy.ts), it was completely circumvented. The lesson: never rely solely on a single layer, especially one that runs at the network edge.
Lina: Sharply. This CVE is exactly why we implement CSRF checks in both proxy.ts AND inside each Route Handler. If proxy.ts is bypassed for any reason, the Route Handler is the last line of defense.
Marcus: Concern 3: Server Actions are discoverable public HTTP endpoints. Even though Next.js obfuscates the endpoint behind a Next-Action header, the action ID can be extracted from client-side code. An attacker who finds the action ID can invoke it directly via HTTP POST — and if Origin checking is somehow circumvented, there's nothing stopping them.
Sarah: The Origin check is server-side — you can't bypass it from the client. But I accept that if a future CVE in the Origin checking logic were found, Server Actions would be unprotected. That's why defense in depth matters.
Raj: There was also a documented case-sensitivity bug in the CSRF Origin comparison. One code path did case-sensitive matching while another correctly lowercased. This has been fixed, but it shows the implementation isn't immune to bugs.
Vote: "Can we rely on Server Actions' built-in CSRF protection without additional measures?"
| Expert | Vote |
|---|---|
| Marcus Webb | No — CVE-2025-29927 proved single-layer defense is fragile |
| Elena Kowalski | No — it only covers Server Actions, not Route Handlers |
| Raj Patel | Trust but verify — it's good for Server Actions but doesn't cover the BFF proxy |
| Lina Torres | No — always implement defense in depth |
| David Park | Trust for Server Actions specifically, but we need more for Route Handlers |
| Sarah Chen | Yes for Server Actions, but it's not a complete CSRF strategy |
Result: 6-0 — Server Actions' built-in protection is trustworthy for Server Actions, but it's not a complete CSRF solution for a BFF architecture. Route Handlers need independent protection.
第四回合:Server Actions 的內建保護——我們能信任它嗎?
Ana: Sarah,你提到 Server Actions 有內建的 CSRF 保護。帶我們了解一下,然後我們來辯論它是否值得信任。
Sarah: Server Actions 永遠是 HTTP POST。內建保護這樣運作:
- Next.js 讀取傳入請求的
Originheader - 與
Hostheader(或在反向代理後面時的X-Forwarded-Host)比較 - 不匹配就拒絕請求
實作在 packages/next/src/server/app-render/action-handler.ts。它使用 isCsrfOriginAllowed 函式,支援精確網域匹配、單層萬用字元(*.example.com)和多層萬用字元(**.example.com)。匹配根據 RFC 1035 不區分大小寫。
David: 有一個關鍵的 ECS 部署細節。當你在 AWS ALB 後面時,Next.js 看到的 Host header 可能是 ALB 的內部主機名,不是你的公開網域。你需要配置 allowedOrigins:
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
allowedOrigins: [
'app.example.com',
'staging.example.com',
],
},
},
};沒有這個,ALB 後面的 Server Actions 會拒絕合法請求,因為 Origin(https://app.example.com)與 Host(ALB 內部主機名)不匹配。
Marcus: 現在讓我來攻擊這個。我有三個擔憂:
擔憂 1: 保護只涵蓋 Server Actions。如果我找到一種方式通過 Route Handler 觸發同樣的後端 mutation,Origin 檢查不適用。在 BFF 中,/api/proxy/transfer 的代理 Route Handler 沒有這種保護。
Sarah: 正確。這就是為什麼 Route Handlers 上的 custom header 驗證是必要的。
Marcus: 擔憂 2: CVE-2025-29927。2025 年 3 月,攻擊者發現他們可以通過加一個 x-middleware-subrequest header 完全繞過 Next.js middleware。如果你的 CSRF 保護跑在 middleware(現在的 proxy.ts)中,它被完全規避了。教訓:永遠不要只依賴單一層,特別是在網路邊緣運行的那一層。
Lina: 語氣銳利。 這個 CVE 正是我們在 proxy.ts 和每個 Route Handler 內部都實作 CSRF 檢查的原因。如果 proxy.ts 因任何原因被繞過,Route Handler 是最後一道防線。
Marcus: 擔憂 3: Server Actions 是可被發現的公開 HTTP 端點。即使 Next.js 用 Next-Action header 混淆端點,action ID 可以從客戶端程式碼中提取。找到 action ID 的攻擊者可以直接通過 HTTP POST 調用它——如果 Origin 檢查以某種方式被規避,沒有什麼能阻止他們。
Sarah: Origin 檢查是伺服器端的——你不能從客戶端繞過它。但我接受如果未來在 Origin 檢查邏輯中發現 CVE,Server Actions 就會失去保護。這就是為什麼縱深防禦重要。
Raj: 還有一個記錄在案的 CSRF Origin 比較大小寫 bug。一個程式碼路徑做了區分大小寫的匹配,而另一個正確地轉為小寫。這已經修復,但它顯示實作並非不受 bug 影響。
投票:「我們能在不加額外措施的情況下依賴 Server Actions 的內建 CSRF 保護嗎?」
| 專家 | 投票 |
|---|---|
| Marcus Webb | 不能——CVE-2025-29927 證明單層防禦是脆弱的 |
| Elena Kowalski | 不能——它只涵蓋 Server Actions,不涵蓋 Route Handlers |
| Raj Patel | 信任但驗證——對 Server Actions 好,但不涵蓋 BFF 代理 |
| Lina Torres | 不能——永遠實作縱深防禦 |
| David Park | 對 Server Actions 本身信任,但 Route Handlers 需要更多 |
| Sarah Chen | 對 Server Actions 本身是的,但它不是完整的 CSRF 策略 |
結果:6-0——Server Actions 的內建保護對 Server Actions 本身值得信任,但它不是 BFF 架構的完整 CSRF 方案。Route Handlers 需要獨立的保護。
Round 5: The Subdomain Problem — Do We Need Double Submit Cookie?
Ana: Marcus raised subdomain attacks as the biggest gap in SameSite + custom header defense. David, you operate 4 subdomains. What's the real-world risk, and do we need the Double Submit Cookie pattern?
David: The risk is real. We have app.example.com (BFF), admin.example.com (admin panel), docs.example.com (documentation with user-generated content), and staging.example.com (staging). An XSS on any of these is an XSS on the same site. The attacker's JavaScript runs on docs.example.com, and because it's the same site as app.example.com, it can:
- Send POST requests to
app.example.com/api/proxy/*— cookies are attached (SameSite doesn't block same-site) - Set custom headers on
fetch()requests — because it's JavaScript from the same site, CORS doesn't block it
Marcus: Nodding. Both SameSite and custom header defenses are bypassed by same-site XSS. This is where the Signed Double Submit Cookie pattern adds value.
Elena: Let me explain the pattern:
- When the user authenticates, the BFF generates a CSRF token using HMAC with a server-side secret and the session ID
- This token is set as a non-httpOnly cookie (JavaScript can read it)
- The client reads the cookie and includes the token value as a request header
- The server validates: (a) the cookie value matches the header value, and (b) the HMAC is valid for the session
Why this works against subdomain attacks: A script on docs.example.com can send requests to app.example.com with cookies attached. But to include the CSRF token in the header, the script needs to read the csrf-token cookie. Due to the Same-Origin Policy, JavaScript on docs.example.com cannot read cookies belonging to app.example.com — even though the browser sends them in requests. Reading is origin-scoped, sending is site-scoped.
// lib/csrf.ts
import crypto from 'crypto';
const CSRF_SECRET = process.env.CSRF_SECRET!;
export function generateCsrfToken(sessionId: string): string {
const random = crypto.randomBytes(32).toString('hex');
const message = `${sessionId}!${random}`;
const hmac = crypto.createHmac('sha256', CSRF_SECRET).update(message).digest('hex');
return `${hmac}.${random}`;
}
export function validateCsrfToken(
sessionId: string,
cookieToken: string,
headerToken: string
): boolean {
if (!cookieToken || !headerToken || cookieToken !== headerToken) return false;
const [receivedHmac, random] = cookieToken.split('.');
if (!receivedHmac || !random) return false;
const expected = crypto.createHmac('sha256', CSRF_SECRET)
.update(`${sessionId}!${random}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(receivedHmac), Buffer.from(expected));
}Raj: Wait. I want to push back. The Double Submit Cookie adds real complexity:
- You need a
CSRF_SECRETshared across all ECS instances - You need to generate and set the cookie on login
- Every client request must read the cookie and set the header
- Every Route Handler must validate
- Token rotation, expiry, multi-tab handling
Is this complexity justified for most applications?
Lina: It depends. If you operate a single domain with no subdomains, SameSite + custom header is enough. But if you have multiple subdomains — and David clearly does — the Signed Double Submit Cookie is the correct answer.
Marcus: I want to add: if your session cookie uses the __Host- prefix, you get additional protection:
cookieStore.set('__Host-session', jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
});The __Host- prefix requires Secure, Path=/, and no Domain attribute. This means the cookie is bound to the exact hostname — it won't be sent to sibling subdomains. And a sibling subdomain cannot inject a cookie with the __Host- prefix for your hostname. This mitigates the naive Double Submit Cookie's weakness (cookie injection from subdomains).
Sarah: So the recommendation is:
- Single domain: SameSite=Lax + custom header +
__Host-prefix = sufficient - Multiple subdomains: Add Signed Double Submit Cookie on top
David: That's exactly what we do. Our CSRF_SECRET lives in AWS Secrets Manager, injected as an environment variable to all ECS tasks.
Vote: "When is the Signed Double Submit Cookie pattern necessary?"
| Expert | Vote |
|---|---|
| Marcus Webb | When you operate sibling subdomains — which is common in enterprise |
| Elena Kowalski | When you operate sibling subdomains OR require OWASP-strict compliance |
| Raj Patel | Only when sibling subdomains exist; overkill for single-domain apps |
| Lina Torres | When sibling subdomains exist |
| David Park | When sibling subdomains exist — which is our case |
| Sarah Chen | When sibling subdomains exist; __Host- prefix handles most subdomain cookie injection |
Result: 6-0 — Signed Double Submit Cookie is necessary when operating sibling subdomains. For single-domain apps, SameSite + custom header + __Host- prefix is sufficient.
第五回合:子網域問題——我們需要 Double Submit Cookie 嗎?
Ana: Marcus 把子網域攻擊列為 SameSite + custom header 防禦的最大缺口。David,你運營 4 個子網域。真實的風險是什麼,我們需要 Double Submit Cookie 模式嗎?
David: 風險是真實的。我們有 app.example.com(BFF)、admin.example.com(管理面板)、docs.example.com(帶有用戶生成內容的文件)和 staging.example.com(staging 環境)。任何一個上面的 XSS 就是同一個站的 XSS。攻擊者的 JavaScript 在 docs.example.com 上運行,因為它跟 app.example.com 是同一個站,它可以:
- 對
app.example.com/api/proxy/*發送 POST 請求——cookies 被附加(SameSite 不阻擋同站) - 在
fetch()請求上設定 custom headers——因為是同站的 JavaScript,CORS 不阻擋
Marcus: 點頭。 SameSite 和 custom header 防禦都被同站 XSS 繞過。這就是 Signed Double Submit Cookie 模式增加價值的地方。
Elena: 讓我解釋這個模式:
- 用戶認證時,BFF 使用 HMAC 搭配伺服器端 secret 和 session ID 生成 CSRF token
- 這個 token 被設定為非 httpOnly cookie(JavaScript 可以讀取)
- 客戶端讀取 cookie 並把 token 值包含在請求 header 中
- 伺服器驗證:(a) cookie 值與 header 值匹配,且 (b) HMAC 對此 session 有效
為什麼這對子網域攻擊有效: docs.example.com 上的腳本可以對 app.example.com 發送帶有 cookies 的請求。但要在 header 中包含 CSRF token,腳本需要讀取 csrf-token cookie。由於同源策略,docs.example.com 上的 JavaScript 無法讀取屬於 app.example.com 的 cookies——即使瀏覽器在請求中發送它們。讀取是 origin-scoped 的,發送是 site-scoped 的。
// lib/csrf.ts
import crypto from 'crypto';
const CSRF_SECRET = process.env.CSRF_SECRET!;
export function generateCsrfToken(sessionId: string): string {
const random = crypto.randomBytes(32).toString('hex');
const message = `${sessionId}!${random}`;
const hmac = crypto.createHmac('sha256', CSRF_SECRET).update(message).digest('hex');
return `${hmac}.${random}`;
}
export function validateCsrfToken(
sessionId: string,
cookieToken: string,
headerToken: string
): boolean {
if (!cookieToken || !headerToken || cookieToken !== headerToken) return false;
const [receivedHmac, random] = cookieToken.split('.');
if (!receivedHmac || !random) return false;
const expected = crypto.createHmac('sha256', CSRF_SECRET)
.update(`${sessionId}!${random}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(receivedHmac), Buffer.from(expected));
}Raj: 等等。我要反駁。Double Submit Cookie 增加了真實的複雜度:
- 你需要跨所有 ECS 實例共享的
CSRF_SECRET - 你需要在登入時生成並設定 cookie
- 每個客戶端請求必須讀取 cookie 並設定 header
- 每個 Route Handler 必須驗證
- Token 輪換、過期、多分頁處理
這個複雜度對大多數應用程式來說合理嗎?
Lina: 取決於情況。如果你運營單一網域沒有子網域,SameSite + custom header 就夠了。但如果你有多個子網域——David 顯然有——Signed Double Submit Cookie 是正確的答案。
Marcus: 我要補充:如果你的 session cookie 使用 __Host- 前綴,你會得到額外的保護:
cookieStore.set('__Host-session', jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
});__Host- 前綴要求 Secure、Path=/、且沒有 Domain 屬性。這意味著 cookie 綁定到精確的主機名——它不會被發送到兄弟子網域。而且兄弟子網域無法為你的主機名注入帶有 __Host- 前綴的 cookie。這緩解了樸素 Double Submit Cookie 的弱點(來自子網域的 cookie 注入)。
Sarah: 所以建議是:
- 單一網域: SameSite=Lax + custom header +
__Host-前綴 = 充分 - 多個子網域: 在上面再加 Signed Double Submit Cookie
David: 這正是我們做的。我們的 CSRF_SECRET 存在 AWS Secrets Manager 中,作為環境變數注入到所有 ECS tasks。
投票:「Signed Double Submit Cookie 模式何時是必要的?」
| 專家 | 投票 |
|---|---|
| Marcus Webb | 當你運營兄弟子網域時——這在企業中很常見 |
| Elena Kowalski | 當你運營兄弟子網域或需要 OWASP 嚴格合規時 |
| Raj Patel | 只在兄弟子網域存在時;對單一網域應用過度 |
| Lina Torres | 當兄弟子網域存在時 |
| David Park | 當兄弟子網域存在時——這是我們的情況 |
| Sarah Chen | 當兄弟子網域存在時;__Host- 前綴處理了大多數子網域 cookie 注入 |
結果:6-0——Signed Double Submit Cookie 在運營兄弟子網域時是必要的。對單一網域應用,SameSite + custom header + __Host- 前綴就足夠了。
Round 6: Where to Enforce — proxy.ts, Route Handlers, or Both?
Ana: We've agreed on the defenses. Now where do we put them? proxy.ts (the new middleware) runs before Route Handlers. Should we check there, in the Route Handler, or both?
Lina: Both. No debate. CVE-2025-29927 settled this.
Marcus: Let me describe the CVE for those who haven't heard. In March 2025, researchers discovered that Next.js middleware could be completely bypassed by adding an x-middleware-subrequest header to HTTP requests. Next.js used this internal header to prevent recursive middleware calls, but it was never stripped from incoming external requests. An attacker could set this header to skip middleware entirely.
CVSS 9.1. Critical severity. Affected Next.js 11.1.4 through 15.2.3.
If your entire CSRF defense lived in middleware, it was gone.
Sarah: The fix is straightforward but important:
Layer 1: proxy.ts — lightweight, fast, catches most attacks early:
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = new Set([process.env.NEXT_PUBLIC_APP_URL!]);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/api/') && !SAFE_METHODS.has(request.method)) {
// Origin check
const origin = request.headers.get('Origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Custom header check
if (!request.headers.get('X-Requested-With')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.next();
}
export const config = { matcher: '/api/:path*' };Layer 2: Route Handler — defense in depth, validates independently:
// lib/csrf-guard.ts
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = new Set([process.env.NEXT_PUBLIC_APP_URL!]);
type Handler = (req: NextRequest, ctx: any) => Promise<NextResponse>;
export function withCsrf(handler: Handler): Handler {
return async (request, context) => {
const origin = request.headers.get('Origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!request.headers.get('X-Requested-With')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!request.headers.get('Content-Type')?.includes('application/json')) {
return NextResponse.json({ error: 'Invalid Content-Type' }, { status: 415 });
}
return handler(request, context);
};
}// app/api/proxy/[...path]/route.ts
import { withCsrf } from '@/lib/csrf-guard';
export const POST = withCsrf(async (request, { params }) => {
// ... proxy logic
});
export const PUT = withCsrf(async (request, { params }) => {
// ... proxy logic
});
export const DELETE = withCsrf(async (request, { params }) => {
// ... proxy logic
});Raj: The pattern is clear. proxy.ts is the first line of defense — fast, catches obvious attacks. Route Handler validation is the last line — independent, survives proxy bypass.
David: And for health checks: ALB health checks don't send Origin or custom headers. Make sure your health check endpoint is a GET that's excluded from CSRF checks (which it naturally is, since we only check state-changing methods).
Vote: "Where should CSRF validation be enforced?"
| Expert | Vote |
|---|---|
| Marcus Webb | Both proxy.ts AND Route Handlers — CVE-2025-29927 proved this |
| Elena Kowalski | Both — defense in depth is non-negotiable |
| Raj Patel | Both — proxy for speed, Route Handler for reliability |
| Lina Torres | Both — I lost sleep over middleware bypass |
| David Park | Both |
| Sarah Chen | Both — proxy.ts can be bypassed; Route Handlers are the ultimate trust boundary |
Result: 6-0 — Always validate in both proxy.ts and Route Handlers. Never rely on a single enforcement point.
第六回合:在哪裡執行——proxy.ts、Route Handlers、還是兩者都要?
Ana: 我們已經同意了防禦方案。現在要把它們放在哪裡?proxy.ts(新的 middleware)在 Route Handlers 之前執行。我們應該在那裡檢查、在 Route Handler 中檢查、還是兩者都要?
Lina: 兩者都要。沒有辯論餘地。CVE-2025-29927 已經解決了這個問題。
Marcus: 讓我為沒聽說過的人描述這個 CVE。2025 年 3 月,研究人員發現通過在 HTTP 請求中加一個 x-middleware-subrequest header,可以完全繞過 Next.js middleware。Next.js 使用這個內部 header 來防止 middleware 遞迴呼叫,但它從未從外部傳入的請求中移除。攻擊者可以設定這個 header 來完全跳過 middleware。
CVSS 9.1。重大嚴重性。影響 Next.js 11.1.4 到 15.2.3。
如果你整個 CSRF 防禦都在 middleware 中,它就沒了。
Sarah: 修復方法直接但重要:
第 1 層:proxy.ts——輕量、快速、及早捕捉大多數攻擊:
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = new Set([process.env.NEXT_PUBLIC_APP_URL!]);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/api/') && !SAFE_METHODS.has(request.method)) {
// Origin 檢查
const origin = request.headers.get('Origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Custom header 檢查
if (!request.headers.get('X-Requested-With')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.next();
}
export const config = { matcher: '/api/:path*' };第 2 層:Route Handler——縱深防禦,獨立驗證:
// lib/csrf-guard.ts
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = new Set([process.env.NEXT_PUBLIC_APP_URL!]);
type Handler = (req: NextRequest, ctx: any) => Promise<NextResponse>;
export function withCsrf(handler: Handler): Handler {
return async (request, context) => {
const origin = request.headers.get('Origin');
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!request.headers.get('X-Requested-With')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
if (!request.headers.get('Content-Type')?.includes('application/json')) {
return NextResponse.json({ error: 'Invalid Content-Type' }, { status: 415 });
}
return handler(request, context);
};
}// app/api/proxy/[...path]/route.ts
import { withCsrf } from '@/lib/csrf-guard';
export const POST = withCsrf(async (request, { params }) => {
// ... 代理邏輯
});
export const PUT = withCsrf(async (request, { params }) => {
// ... 代理邏輯
});
export const DELETE = withCsrf(async (request, { params }) => {
// ... 代理邏輯
});Raj: 模式很清楚。proxy.ts 是第一道防線——快速,捕捉明顯的攻擊。Route Handler 驗證是最後一道——獨立,在代理被繞過時仍然存在。
David: 而且對於健康檢查:ALB 健康檢查不發送 Origin 或 custom headers。確保你的健康檢查端點是一個被排除在 CSRF 檢查之外的 GET(自然就是,因為我們只檢查狀態變更方法)。
投票:「CSRF 驗證應該在哪裡執行?」
| 專家 | 投票 |
|---|---|
| Marcus Webb | proxy.ts 和 Route Handlers 都要——CVE-2025-29927 證明了這一點 |
| Elena Kowalski | 兩者都要——縱深防禦不可商量 |
| Raj Patel | 兩者都要——proxy 求速度,Route Handler 求可靠性 |
| Lina Torres | 兩者都要——middleware 繞過讓我失眠過 |
| David Park | 兩者都要 |
| Sarah Chen | 兩者都要——proxy.ts 可以被繞過;Route Handlers 是最終的信任邊界 |
結果:6-0——永遠在 proxy.ts 和 Route Handlers 兩者中都驗證。永遠不要依賴單一執行點。
Final Verdict: The Complete CSRF Defense Stack
Defense Matrix
| Layer | Defense | Protects Against | Where | Required? |
|---|---|---|---|---|
| 1 | SameSite=Lax (explicit) + Secure + HttpOnly | Cross-site POST, basic CSRF | Cookie config | Always |
| 2 | __Host- cookie prefix | Subdomain cookie injection, insecure transport | Cookie config | Always |
| 3 | Custom header (X-Requested-With) | Cross-origin form POST, simple requests | proxy.ts + Route Handlers | Always |
| 4 | Content-Type: application/json enforcement | Form-based CSRF (triggers preflight) | Route Handlers | Always |
| 5 | Origin header validation | Cross-origin requests | proxy.ts + Route Handlers | Always |
| 6 | Server Actions' built-in Origin check | Cross-origin Server Action invocations | Automatic | Built-in |
| 7 | No state mutations on GET | SameSite=Lax GET-based attacks | Code discipline | Always |
| 8 | serverActions.allowedOrigins config | ALB/proxy Origin mismatch | next.config.ts | On ECS/ALB |
| 9 | Signed Double Submit Cookie | Subdomain XSS, same-site attacks | Route Handlers | If sibling subdomains |
| 10 | CORS strict configuration | Unauthorized cross-origin API access | proxy.ts | Always |
For Single-Domain Apps (layers 1-8, 10):
Session Cookie: __Host-session, httpOnly, Secure, SameSite=Lax
↓
proxy.ts: Origin check + X-Requested-With check
↓
Route Handler: Origin check + X-Requested-With + Content-Type: application/json
↓
Server Actions: Built-in Origin/Host comparison (automatic)For Multi-Subdomain Apps (all 10 layers):
Session Cookie: __Host-session, httpOnly, Secure, SameSite=Lax
CSRF Cookie: csrf-token, NOT httpOnly, Secure, SameSite=Lax
↓
proxy.ts: Origin check + X-Requested-With check
↓
Route Handler: Origin + X-Requested-With + Content-Type + CSRF token validation
↓
Server Actions: Built-in Origin/Host comparison (automatic)最終裁決:完整的 CSRF 防禦堆疊
防禦矩陣
| 層級 | 防禦 | 防護目標 | 位置 | 必需? |
|---|---|---|---|---|
| 1 | SameSite=Lax(明確)+ Secure + HttpOnly | 跨站 POST、基本 CSRF | Cookie 設定 | 永遠 |
| 2 | __Host- cookie 前綴 | 子網域 cookie 注入、不安全傳輸 | Cookie 設定 | 永遠 |
| 3 | Custom header(X-Requested-With) | 跨來源表單 POST、簡單請求 | proxy.ts + Route Handlers | 永遠 |
| 4 | Content-Type: application/json 強制 | 基於表單的 CSRF(觸發預檢) | Route Handlers | 永遠 |
| 5 | Origin header 驗證 | 跨來源請求 | proxy.ts + Route Handlers | 永遠 |
| 6 | Server Actions 內建 Origin 檢查 | 跨來源 Server Action 調用 | 自動 | 內建 |
| 7 | GET 不進行狀態變更 | SameSite=Lax 基於 GET 的攻擊 | 程式碼紀律 | 永遠 |
| 8 | serverActions.allowedOrigins 設定 | ALB/代理 Origin 不匹配 | next.config.ts | 在 ECS/ALB 上 |
| 9 | Signed Double Submit Cookie | 子網域 XSS、同站攻擊 | Route Handlers | 如有兄弟子網域 |
| 10 | CORS 嚴格設定 | 未授權的跨來源 API 存取 | proxy.ts | 永遠 |
單一網域應用(第 1-8、10 層):
Session Cookie: __Host-session, httpOnly, Secure, SameSite=Lax
↓
proxy.ts: Origin 檢查 + X-Requested-With 檢查
↓
Route Handler: Origin 檢查 + X-Requested-With + Content-Type: application/json
↓
Server Actions: 內建 Origin/Host 比較(自動)多子網域應用(全部 10 層):
Session Cookie: __Host-session, httpOnly, Secure, SameSite=Lax
CSRF Cookie: csrf-token, NOT httpOnly, Secure, SameSite=Lax
↓
proxy.ts: Origin 檢查 + X-Requested-With 檢查
↓
Route Handler: Origin + X-Requested-With + Content-Type + CSRF token 驗證
↓
Server Actions: 內建 Origin/Host 比較(自動)The 8 Golden Rules
- SameSite=Lax is the foundation, not the fortress. Always set it explicitly. Never rely on it alone.
- Use the
__Host-prefix on your session cookie. It prevents subdomain injection and enforces Secure + exact hostname binding. - Require a custom header (
X-Requested-With) on all state-changing BFF proxy requests. This blocks form-based CSRF and triggers CORS preflight for cross-origin JavaScript. - Enforce
Content-Type: application/jsonon all Route Handler POST/PUT/DELETE. Don't just parse — validate the header. - Validate Origin header in both proxy.ts AND Route Handlers. CVE-2025-29927 proved that proxy/middleware can be bypassed.
- Configure
serverActions.allowedOriginsinnext.config.tswhen deploying behind ALB. Without it, Server Actions reject legitimate requests. - Never mutate state on GET requests. SameSite=Lax allows cross-site GET, and no amount of CSRF defense fixes a GET that deletes data.
- Add Signed Double Submit Cookie when operating sibling subdomains. SameSite and custom headers both fail against same-site XSS. The Double Submit pattern's read-vs-send asymmetry is your last line of defense.
八條黃金準則
- SameSite=Lax 是基礎,不是堡壘。 永遠明確設定它。永遠不要單獨依賴它。
- 在 session cookie 上使用
__Host-前綴。 它防止子網域注入並強制 Secure + 精確主機名綁定。 - 在所有狀態變更的 BFF 代理請求上要求 custom header(
X-Requested-With)。 這阻擋基於表單的 CSRF 並為跨來源 JavaScript 觸發 CORS 預檢。 - 對所有 Route Handler 的 POST/PUT/DELETE 強制
Content-Type: application/json。 不要只是解析——驗證 header。 - 在 proxy.ts 和 Route Handlers 中都驗證 Origin header。 CVE-2025-29927 證明 proxy/middleware 可以被繞過。
- 在 ALB 後面部署時,在
next.config.ts中配置serverActions.allowedOrigins。 沒有它,Server Actions 會拒絕合法請求。 - 永遠不要在 GET 請求上變更狀態。 SameSite=Lax 允許跨站 GET,再多的 CSRF 防禦都修不了一個會刪除資料的 GET。
- 運營兄弟子網域時加上 Signed Double Submit Cookie。 SameSite 和 custom headers 都無法防禦同站 XSS。Double Submit 模式的讀取與發送不對稱是你最後的防線。