本章從一個具體問題開始:一個當天註冊的租戶建完產品就離開,我們想知道他卡在哪——結果發現唯一的線索是容器的
docker logs,而那是易失的。追查過程意外揭開一個更嚴重的問題:平台對「請求從哪裡來」的判定,在三個不同的地方都是錯的,而三處的後果完全不同。
docker logs 重建使用者旅程只成功一次,而且純屬運氣(容器剛好沒重建過)。已經流失的租戶追不回來。audit_logs,以 funnel. 前綴區分行為事件與文件 CRUD。既有稽核表已是 append-only、已有索引、已有隔離——新開一張表只會製造第二個真相來源。audit_logs 的 trigger 只放行「已過保留期」的 DELETE,UPDATE 永遠擋。清除工作寫錯或被誤呼叫時,未到期的紀錄在 DB 層就刪不掉。127.0.0.1(欄位失去意義)、per-IP 防爆破塌成單一全域桶(30 次失敗即可鎖死全平台)、簽署來源 IP 可由簽署人自行指定(證據反轉)。CF-Connecting-IP、取 X-Forwarded-For 第一段——在本平台的 L4 分流架構下都是錯的。一位租戶當天註冊、上傳標籤三次、建立產品、嘗試從產品產生毒理報告、查看方案價格,然後離開。我們想知道他卡在哪一步。
當時能查到的只有 docker logs——138 行,涵蓋兩天,容器一重建就消失。這次查得到純屬運氣:他當天早上操作,而容器剛好幾天沒重建過。同一批更早流失的租戶,已經無從查起。
追出來的原因值得記錄,因為它不是 bug:台灣化粧品標籤依法必列全成分,AI 每次都讀到了,但當時的系統提示詞明寫「全成分不擷取」。他上傳了三次標籤,系統三次讀到成分又丟掉,最後請他對著空白表格手動重打整張配方。他沒打,去看了價格,離開了。
這個結論只能事後從程式碼推出來,無法從資料推出來——因為資料沒有被留下。
修法不是新開一張分析表,而是沿用既有的 audit_logs。理由有三:
行為事件以 funnel. 前綴與既有的文件 CRUD 稽核區分,涵蓋註冊、登入、標籤解析、成分分析、報告預覽、報告產出、方案瀏覽、進入結帳,以及兩類最有價值的受阻事件:撞到付費牆、額度用盡。
撞牆的人是「想用但用不到」的人——這是最接近成交、卻在修補前完全不可見的一群。
一個容易寫錯的細節:既有的稽核寫入契約是「只 db.add(),由呼叫端的 commit 一併落地」——這對業務稽核是對的,稽核紀錄應與業務變更同生共死。
但遙測不能照做。若遙測在 POST 端點裡自行 commit,會把當下尚未完成的業務變更一併提交;若不 commit,GET 端點的事件則永遠不落地。解法是遙測開自己的 session,完全不碰呼叫端的交易,並整段包 try/except——記不到 log 不是業務錯誤。
行為紀錄含 email、IP 與完整行為軌跡,構成個人行為檔案,因此保留期為三年、且僅平台管理員可讀。
實作上的關鍵是邊界寫在哪。audit_logs 原本無條件擋 DELETE;改為只放行 created_at 已過保留期的 DELETE,UPDATE 則永遠擋(不可竄改與保留期是兩件事)。清除工作只是執行者——它寫錯、被誤呼叫、或參數算錯時,未到期的紀錄在資料庫層就刪不掉。
清除採分批(單次上限數千列):這張表在請求路徑上持續有寫入,一次刪光會長時間持有鎖。
建立足跡後第一次驗證,發現記下的 IP 全部是 127.0.0.1。追查根因時才發現,同一個錯誤在平台的三個地方各造成一種不同的傷害。
正式環境的請求鏈是:
Cloudflare → nginx:443(L4 stream,依 SNI 分流)→ 127.0.0.1:8443(nginx http)→ 應用容器
L4 層不改動 HTTP 標頭,但連到 8443 的來源就是本機,因此在 8443 這層 $remote_addr 恆為 127.0.0.1。而該層的設定為了防偽造,把 CF-Connecting-IP 覆寫成 $remote_addr——於是每個人都變成 127.0.0.1。
真實來源仍在 X-Forwarded-For 中。存取記錄可以直接對照:$remote_addr = 127.0.0.1,而 $http_x_forwarded_for 是真實的外部位址。
| 寫法 | 在本拓撲下的結果 |
|---|---|
優先讀 CF-Connecting-IP |
每個人都是 127.0.0.1(該標頭被刻意覆寫) |
取 X-Forwarded-For 第一段 |
取到客戶端自己塞入的偽造值 |
第二點的成因是:CDN 是把真實 IP 附加在 XFF 尾端,客戶端自帶的值留在前面。因此正解是取 XFF 中最後一個非私有位址——偽造值(在前)被跳過,自家 hop(私有位址)被跳過,剩下的就是 CDN 認定的來源。
這個取法在本拓撲下不可偽造,前提是 L4 層對平台網域只接受 CDN 來源(非 CDN 的外部連線導向黑洞)。繞過 CDN 直連來源會在連線層被重置,因此能到達應用層的請求必然經過 CDN。
| 使用處 | 錯誤後果 | 嚴重度 |
|---|---|---|
| 足跡遙測 | 每筆都記同一個值,IP 欄位失去意義 | 資料無用 |
| per-IP 登入節流 | 所有人共用一個計數桶 | 可用性:任何人累積上限次登入失敗,全平台使用者一起被鎖 |
| 簽署來源 IP | 簽署人可自行指定紀錄上的 IP | 證據性:欄位用途反轉 |
第二項最嚴重。per-IP 防爆破的上限刻意設得寬鬆(考量共用 NAT 與企業出口),但在所有人塌成同一個桶之後,那個寬鬆上限變成全平台共用的鎖——不需要任何權限,累積上限次失敗即可讓所有租戶登不進來。而症狀(沒有錯誤訊息、重試更糟)與一次真實的登入事故完全相同,極易誤判方向。
第三項的性質不同:簽署來源 IP 與內容雜湊、版本綁定、簽署人資格快照同屬構成不可否認性的欄位組。若簽署人能決定紀錄上寫哪個 IP,這個欄位的用途就被反轉了——它本來要證明「你在這裡簽的」,變成「你說你在哪裡簽的」。
修補時機值得記錄:當時尚未發生任何實際簽署,因此不會產生「同一欄位兩種語意」的既有紀錄混雜。證據性欄位的語意改動,越早越便宜。
三處原本各有一份實作、語意互不相同。修補後全平台只有一份 IP 解析,並以測試鎖住兩件事:任何模組不得再自行解析、以及行為驗證——直接送入含偽造前綴的 XFF,斷言取到的是 CDN 附加的真實來源。
行為驗證比字串比對可靠:即使日後有人改寫成別的寫法,只要行為錯了就會失敗。
本章的教訓可以壓縮成一句:沒有錯誤訊息的失效最貴。IP 全記成 127.0.0.1 不會拋例外、節流塌成全域桶不會拋例外、簽署 IP 被偽造也不會拋例外。它們只能靠對機制本身的懷疑、以及行為層的驗證找出來。
| 版本 | 日期 | 摘要 |
|---|---|---|
| v0.4 | 2026-08-24 | 首次撰寫。涵蓋易失證據問題、沿用 audit_logs 的足跡設計、遙測獨立交易、保留期邊界寫在 DB trigger、邊緣拓撲下 IP 解析的兩種常見錯誤寫法、同一根因造成的三種不同傷害(遙測/防爆破/簽署證據)、收斂為單一來源與行為驗證。 |
© 2026 Baiyuan Tech. Licensed under CC BY-NC 4.0.