工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    登入有時候好、有時候壞:CDN 把 Set-Cookie 吃掉了

    Authors
    • avatar
      Name
      Alex Yu

    prod 上開始有人登入失敗,網址停在 ?error=invalid_request

    回報陸續進來,但不是每個人都遇得到,同一個人重整幾次又能登入。我自己去試,也中過一次。

    「部分使用者 + 間歇性」這個組合,幾乎都是快取在作怪。

    而這次的快取設定,是我自己寫的。

    這個站的請求路徑是這樣:

    使用者 → CDN → Next.js(middleware 先跑,再進 route handler)

    CDN 會把回應存一份在自己那邊,下一個人來要同一個東西就直接給他,不用麻煩後面的伺服器。手上有就直接給,這叫 cache hit;沒有才回頭去問後面那台,那次就是 cache miss。

    middleware 則是 Next.js 在請求進到頁面或 API 之前會先跑的一段程式。

    登入本身走 OAuth 2.0 + PKCE,token 生命週期交給 BFF(Backend for Frontend,這裡就是 Next.js 的 API routes)管。

    PKCE 的流程簡化講是這樣:網站在導去授權端之前,先自己產生一組隨機值(code verifier)跟一個 state,存進瀏覽器 cookie;使用者授權完導回來時,callback 要從 cookie 把這兩個值讀回來驗證,才能換到 token。

    所以 /api/auth/login 這個 endpoint 的回應裡,會帶三個 Set-Cookie:code verifier、state、還有導回網址。

    # 正常的回應長這樣(cookie 名稱以 <> 代稱)
    HTTP/1.1 307 Temporary Redirect
    Set-Cookie: <code verifier>
    Set-Cookie: <state>
    Set-Cookie: <導回網址>
    Location: https://<授權端>/authorize?...

    這三個 cookie 少任何一個,callback 就湊不齊要驗證的值。

    根因:一個 matcher 把 auth endpoint 也丟給 CDN

    問題出在 middleware。

    為了讓內容頁能被 CDN 快取、壓低 TTFB(第一個位元組回來的時間),middleware 對回應統一蓋了 publicCache-Control。而它的 matcher 是這樣寫的:

    // middleware.ts
    export const config = {
      matcher: '/:path*', // ← 「所有」路徑,包含 /api/auth/login
    }
     
    export function middleware(req: NextRequest) {
      const res = NextResponse.next()
      res.headers.set('Cache-Control', 'public, s-maxage=300') // ← 連 auth route 都被蓋上 public
      return res
    }

    /:path* 把每一條路徑都納進來,/api/auth/login 自然也在內。CDN 看到 public,就把這個 endpoint 當成可快取的東西。

    CDN 在 cache hit 時會把 Set-Cookie strip 掉。 這是合理的,因為一份被多人共用的快取回應,不該夾帶屬於某個人的 cookie。

    # 命中快取之後,CDN 回給下一個人的
    HTTP/1.1 307 Temporary Redirect
    Location: https://<授權端>/authorize?...
    # ← 三個 Set-Cookie 一個都不剩

    於是第二個之後打到同一個 CDN 節點的使用者,拿到的是被快取、已經沒有 Set-Cookie 的那份回應。三個 PKCE cookie 一個都沒種進去,callback 時 code verifier 跟 state 全缺,登入直接回 invalid_request

    打到還沒存過東西的節點就沒事,打到存過的就中獎。「有時候好有時候壞」就是這麼來的。

    把快取控制權整包交給 CDN、關掉框架自己的多層快取,這件事我在刷了 CDN 還是舊資料:Next.js 的快取沒有清除按鈕寫過,到現在還是覺得是對的。但那套設定有一個例外要先排除掉:會發 Set-Cookie 的路徑。

    坑:只在 handler 設 no-store,蓋不掉

    第一直覺是去 route handler 裡把它改掉。既然這條不能快取,那就在 /api/auth/login 的回應補一個 no-store:

    // app/api/auth/login/route.ts
    export async function GET() {
      const res = NextResponse.redirect(authorizeUrl)
      res.headers.set('Cache-Control', 'private, no-store') // ← 想 override middleware
      return res
    }

    本以為這樣就結了。但實測沒用,auth route 竟然還是被快取。

    原因是 middleware 跟 route handler 兩層的 response header 會合併,而 middleware 先跑、已經把 public 蓋上去了;handler 在後面補的這份,並沒有把前面那份整個換掉。

    # 兩層合併後,實際送出去的
    Cache-Control: public, s-maxage=300
    # ← handler 設的 private, no-store 沒出現在這裡

    修法:從 matcher 把 /api/* 整段排除

    真正的修法是改 matcher,讓 middleware 根本不處理 /api/*

    // middleware.ts
    export const config = {
      // (?:/|$) 是為了連 /api 本身也排除掉,只寫 api/ 會漏掉
      matcher: ['/((?!api(?:/|$)).*)'],
    }

    快取策略因此變成兩邊各管各的:

    // 內容頁(middleware):給 CDN 快取
    'Cache-Control': 'public, s-maxage=300'
     
    // auth route(handler 自己設,middleware 不再碰這條)
    'Cache-Control': 'private, no-cache, no-store, must-revalidate'

    改完要驗一次,不要只看程式碼。同一條 auth route 連打兩次,看第二次還有沒有 Set-Cookie

    # 連打兩次,兩次都要有 Set-Cookie 才算過
    for i in 1 2; do
      curl -sI 'https://<你的網域>/api/auth/login' | grep -iE 'set-cookie|cache-control'
      echo '---'
    done

    auth endpoint 從此不進 CDN,Set-Cookie 每次都完整種到使用者瀏覽器,登入恢復正常。

    regex 本身不用記,記那個判斷就好:擋快取要擋在 middleware 的 matcher,那是請求進來的第一關。等它跑完再去 handler 設 header,前面那份已經蓋上去了。

    附帶:同一條登入流程上的 open redirect

    修這個 cache bug 時,順手也把另一個 auth 的洞補了,剛好都跟「不要信任不可信的輸入」有關。

    登入完要導回使用者原本的頁面,舊的做法是讀 x-forwarded-host 來組這個導回網址:

    // ❌ 信任 x-forwarded-host 組 URL
    const base = `https://${req.headers.get('x-forwarded-host')}`
    const redirectUrl = `${base}${backUrl}`

    x-forwarded-host 是 client 可以偽造的 header。攻擊者塞一個自己的網域進來,就能讓登入流程把人導去釣魚站,甚至把 token 帶過去。

    修法是不再信任任何 forwarded header,URL 一律用自己設定的 appUrl 組;導回目標再過一層白名單驗證,不合法就退回首頁:

    // ✅ 只信任自己的 appUrl,導回目標還要過白名單
    const base = config.appUrl
    const redirectUrl = sanitizeRedirectUrl(backUrl, {
      allowedOrigins: [config.appUrl, ...allowedRedirectOrigins],
    }) // ← 不在白名單就回首頁,不照使用者給的網址跳

    登出的 callback 也一樣:state 對不上就導回首頁,使用者指定的網址一律不照跳,免得登出流程被當成開放導轉的跳板。

    修完之後才想到的事

    還好這次踩到的是登入。登入壞掉會有人立刻回報,invalid_request 也夠具體,一路查回 middleware 沒花太久。

    真正讓我不太舒服的是另一件事:那個 matcher 排除的是 /api/*,它認得的只有一段路徑前綴。至於「這個回應是不是只屬於某一個人」,它根本沒在看。哪天有人在 /api 以外的地方寫一條會發 Set-Cookie 的 route,middleware 一樣會蓋上 public,一樣沒有東西會攔它。

    我目前沒有自動的辦法擋這個,只能靠 code review 的時候有人想起來。前傳那篇的結論是快取要能手動救。這篇再補一個:快取的範圍請用列舉的,一條一條寫出哪些路徑可以快取。

    延伸閱讀

    參考資料