本文同步發表於個人部落格:醫療 app 的安全思維
火線超人除了幫病人查資料,還多做一件事:用 LLM 幫病人解讀自己的健康資料。 LLM 就是大型語言模型,ChatGPT 那一類。檢驗值是什麼意思、這個數字算不算正常,這些問題交給模型回答。
跑這個模型的機器在醫院裡,是醫院提供的服務。
醫院對這件事有一條資安要求:內網只准對外連線,不開任何入口讓外面連進來。
所以問題變成這樣。我的後端跑在雲端,租的是 DigitalOcean 的機器。那要怎麼叫得動院內那台模型?
外面的機器要連進來,防火牆上得有個對應的洞,那個洞就是 port。開了洞就會有人來敲門,醫院那條規矩等於說一個洞都不開。
我用的是 Kafka。這是一套訊息佇列,你不直接打對方的服務,而是把訊息丟進一個叫 topic 的收件匣。丟的那一端叫 producer,拿的那一端叫 consumer。中間放訊息的那台伺服器叫 broker。
火線超人就是這樣跑的。雲端的後端是 producer,把要解讀的資料丟進 broker 的 topic。院內有一台機器整天掛著一支程式,我叫它 AI worker。這支程式就是 consumer。
重點在 consumer 怎麼拿訊息。consumer 不是等人來派工,是自己連出去找 broker。找到之後說我要訂 topic 這個收件匣,這個動作叫 subscribe。訂完就一直問有沒有新的,有就拉回來跑。
從頭到尾都是院內往外連。broker 從來不需要回頭連院內,院內也不用生一個網址出來給人連。
這在術語上叫解耦合,白話講就是兩邊根本不認識。producer 不知道 consumer 是誰,也不知道它在哪。兩邊只認 topic 那個名字。雲端手上沒有院內任何一台機器的位址,想連也連不了。牆上那個洞從頭到尾不用開。
當初會這樣設計,理由很單純。在我想得到的做法裡,這是最安全的一種資料交換方式。
每一種設計我都拿同一個問題去問:雲端那台主機哪天被入侵了,會怎麼樣?
最直覺的替代方案是 VPN。VPN 就是把兩個網路接成一個。接通之後,雲端跟院內像在同一個內網裡。問題就在這裡。雲端那台如果被入侵成功,攻擊者順著這條通道就進了醫院內網。
另一種是反向通道。院內主動連出去一條線,接通之後就掛著不關,這叫持續連線。線一旦接上,資料就能兩頭跑。雲端可以沿著它把請求送進院內。這確實不用開 port,但雲端被入侵的話,攻擊者能發什麼請求一樣沒有限制。
Kafka 這條路不一樣,因為這條通道只能做一件事。雲端能做的只有把一則請求丟進 topic,沒有別的動作可以發。要不要處理、什麼時候處理,決定權在院內。就算雲端那台被入侵,攻擊者也只是多了一個丟訊息的管道,連不進醫院。
這裡也有一條當時列為待改善的。那台 broker 的對外通道沒有加密,也沒有認證授權。稽核建議補上 TLS 與 SASL。攻擊者進不了醫院內網這件事不受影響。但「只能丟訊息」要真的成立,broker 還得擋住誰能丟、能丟去哪。

這個架構省掉的麻煩很具體。
如果反過來,讓雲端主動連進院內查,那就得在院內防火牆開一個入口。開了入口就多出四類要維護的規則:
每一條都要有人顧,每一條都可能被改壞。院內主動出去的話,這四條整批消失。院內只要能連得出去就好,而「允許對外連線」這條規則本來就存在。
這就是「安全是架構屬性,不是事後修補」最具體的樣子。 所謂攻擊面,就是外面那些人碰得到的地方,開一個 port 就多一塊。院內只出不進,那個洞從頭到尾沒有被開出來,也就沒得防。
從架構回到 app。第一條也是最基本的一條。
第三幕 day10 和 day11 講過 scope 怎麼組。這裡要補的是:scope 不該是一個全域常數。
火線超人的做法是每一台伺服器自己帶一組 scope。要送哪一組,照三層順序決定。呼叫時明確傳入的優先,其次是那台伺服器的設定,都沒有才用全域預設。
火線超人那邊的邏輯大致長這樣:
const SERVERS = {
hospitalA: { scope: 'system/Appointment.read system/Location.read' },
hospitalB: { scope: 'system/Appointment.read' },
}
const DEFAULT_SCOPE = 'system/Appointment.read'
function resolveScope(serverKey, explicitScope) {
// 三層順序:明確傳入 > 那台伺服器的設定 > 全域預設
return explicitScope
?? SERVERS[serverKey]?.scope
?? DEFAULT_SCOPE
}
為什麼要這樣?因為每家醫院開放的資源不一樣。你用同一組 scope 去要所有醫院,結果就是對某些醫院要太多。輕則授權伺服器直接拒絕,重則在對方的稽核紀錄上留下一筆。
實際的數字。我在醫院端那條系統憑證只申請了五個唯讀 scope,涵蓋這五種資源:
Appointment
Location
Schedule
Practitioner
Organization
這剛好是看診進度功能需要的全部,一個都沒多要。
沒試過 403 的最小權限,不算驗證過。

這兩組回應是我在真的醫院端打的。我們用的那台公開 sandbox 在資源端不檢查 scope。day10 與 day21 都講過。
day14 講過 token 存哪裡的取捨。這裡補三個實作上的細節。
快取的存活時間要比 expires_in 短。 火線超人短 60 秒。理由是時鐘會有誤差,網路也會有延遲。你算的到期時間跟伺服器算的不會完全一樣,留 60 秒的餘裕比較保險。在請求送到一半才發現過期,那是最難處理的狀況。
收到 401 要先失效快取再重試,而且只重試一次。 重試無上限就是在幫對方做壓力測試。
這兩條寫成程式碼就是這樣:
// 存 token:到期時間往前算 60 秒
function cacheToken(res) {
return {
accessToken: res.access_token,
expiresAt: Date.now() + (res.expires_in - 60) * 1000,
}
}
// 收到 401:先讓快取失效,重新換一次 token,只重試這一次
async function callWithRetry(sendRequest) {
let res = await sendRequest(getCachedToken())
if (res.status === 401) {
invalidateCache()
res = await sendRequest(await fetchNewToken())
}
return res
}
client secret 只能存在加密的資料庫欄位裡。 這條有一個可驗收的方式:拿那個 secret 的值 git grep 一次,必須零命中。醫院給的憑證資料夾要寫進 .gitignore,git status 不能列出它。
明文憑證進版控這件事,一旦發生就沒有辦法乾淨地收回。歷史紀錄裡永遠有那一筆。
day18 教過怎麼寫回 FHIR。那也就代表你的 app 有寫入的能力。
火線超人有一支對醫院端的探測工具。這支工具定期連上醫院的 FHIR 端點,確認資料來源還活著、格式沒有變。
這種工具很容易寫著寫著就多出寫入能力。所以在動手寫之前,我先在規格文件裡列死它能發哪些請求。這就是我自己給這支程式訂的規則,寫完的程式碼不准超出它。
這件事的重點是:唯讀不能只靠 scope。
scope 是對方給你的限制,這條規則是你給自己的限制。兩層都有,才不會因為某天對方多給了寫入權限,你的工具就默默開始寫東西。

day23 講過匿名化鐵則要寫在技術規格之前。這裡把邊界講清楚。
界線不在「有沒有名字」,在這筆資料能不能指回一個人。
叫號進度看起來完全沒有個資,就是一個號碼加一個狀態。但那筆 Appointment 一旦帶了 Patient 參照,它就變成可以指回一個人。所以規格把 Patient 參照列成禁止欄位,跟必填、選填並列。
另一條是資料要分開。day23 講過那條要求,叫號的 identifier.system 要跟臨床的分開,各用自己的命名空間。兩邊的識別碼對不起來,讀叫號的程式在技術上就碰不到病歷。
還有一件當時列為待改善的。2025 年 12 月那次資安稽核建議,送往外部分析服務之前要先移除直接識別資訊。直接識別資訊就是姓名、病歷號、身分證字號那一類,看一眼就知道是誰的欄位。這一項我也還沒做。
稽核軌跡就是一份紀錄,誰在什麼時候讀了哪一筆病人資料,一行一行留著。這一條要照實寫。
火線超人沒有實作稽核軌跡。
專案的套件清單裡沒有任何做資料異動紀錄的套件。程式碼裡也沒有存取日誌的模型或服務。同一次資安稽核把「增加健康資料存取的詳細日誌」列為建議事項。
到今天還是建議事項。
為什麼要在一篇講安全的文章裡承認這個?因為不承認的話,這張清單就變成一份行銷文案。而且我猜你的 app 大概也沒有這一條,這樣至少我們是誠實的同一國。
該做的話要做什麼?至少三件:
第三件最容易被忽略。稽核軌跡如果能被改,它就不是稽核軌跡。
前面五條講的是狀態,這一節講的是時間。
開發流程從寫程式碼一路走到上線,越靠左邊越早。安全最貴的做法,是等上線之後被人通報才處理。把檢查往左邊挪,能在自己機器上跑的就不要等到上線,這件事叫資安左移。
火線超人是 Rails 專案,部署前會跑兩種掃描。
Brakeman 掃你自己寫的程式碼。 Brakeman 讀原始碼找常見的漏洞寫法。例如 SQL 拼字串、沒有跳脫的輸出、參數直接灌進 model。不用把 app 跑起來,也不用測試資料。
bundler-audit 掃你裝的套件。 這支工具拿 Gemfile.lock 鎖定的版本去比對公開的漏洞資料庫。你鎖的版本落在某一則公告的影響範圍裡,它就列出來。
兩種掃的地方不一樣,一個看你寫的,一個看你裝的。兩個都排在部署之前,任一個沒過就停在那裡,版本送不上線。
那你這個 JS app 呢?沒有 Rails,也沒有那兩支工具,但「你裝的」那一半照樣適用。
day04 我們用 curl 抓了一份 fhir-client.pure.min.js 進 vendor/,版本是 2.6.3。那份檔案躺在那裡不會變。哪天 fhirclient 發了一則安全公告,你的檔案不會知道,你也不會知道。
這是 vendor 進版控換來的。好處是誰 clone 下來跑的都是同一份程式碼。壞處是沒有 package.json,也就沒有 bundler-audit 那種工具幫你盯。
所以要自己補兩件事。
記下你 vendor 的是什麼。 在 vendor/ 旁邊放一個 README,寫下網址、版本號、下載日期。沒有這三行,哪天想查更新你連要去哪裡看都不知道。
定期去看一次。 打開那個專案的 releases 頁面與安全公告,找有沒有修掉漏洞的版本。三個月看一次也行,重點是有排。
重點在「定期」。左移管得到的是上線前那一刻,已知的公告先擋掉。上線之後才出現的公告,只能靠排程重查。安全公告不會挑你正好在改那個檔案的那天發表。
還有一條原則值得帶走。火線超人有一份「已知未修」的漏洞清單,掃到清單上的公告不擋。那個檔案如果不見了或是空的,判定不是全部放行,是全部擋下。預設值出錯的時候要往嚴的那邊倒。
排程先記到行事曆上。下面回到前面那五條,拿它們檢查你自己的 app。
打開你第三幕做出來的那個專案,逐列回答。除了最後一列,每一列在前三幕都找得到對應的篇章。
| 檢查項 | 怎麼查 |
|---|---|
| scope 有沒有多要 | 看 servers.js 裡每台的 scope 字串,逐個問「這個資源我畫面上用得到嗎」 |
| 授權有沒有 PKCE | 看授權網址有沒有 code_challenge |
| 有沒有防跨站請求偽造 | 看授權網址有沒有 state,導回時有沒有驗它 |
| token 存在哪裡 | 找存 token 的那一行,是 sessionStorage 還是 localStorage |
| token 過期怎麼辦 | 找收到 401 的處理,有沒有無上限重試 |
| 錯誤訊息會不會外洩 | 看丟到畫面上的錯誤有沒有帶到 token 或完整網址 |
| 寫入的範圍 | 找所有 POST 與 PUT,確認每一個都是使用者主動觸發的 |
| 每台伺服器的憑證有沒有分開 | 看 client id 是不是每台一組,有沒有共用 |
| 有沒有稽核軌跡 | 找記錄「誰在什麼時候讀了什麼」的地方 |

最後一列指不到任何一篇。這是刻意的。
只照前三幕做的話,最後一列一定打叉,因為前三幕從來沒有教過稽核軌跡。我的也沒有。
這就是這一節真正要你發現的東西。一份看起來完整的清單,走到最後一列會發現有一格填不了。安全清單的價值不在全部打勾,在於讓你知道哪一格還是空的。
順便看一下 day18 那顆寫回按鈕。你的 app 現在有寫入能力,而它拿到的 scope 是 patient/*.rs,那個 s 是搜尋不是寫入。所以第七列你可能會發現一件事:你申請的權限跟你實際做的事,其實對不太起來。
安全清單有五條:
第五條我自己還沒做,寫出來是因為清單少一條比清單造假好。
清單之外還有時間這一項。這五條今天做完都對,vendor/ 那份 JS 卻會慢慢變舊。能往前挪的檢查就往前挪,挪不動的就排時間重查。
架構那一段的決策當時沒有寫成文件。判準倒是很清楚:假設雲端那台被入侵,這個設計還剩多少防線。最好的防護是讓攻擊面不存在,不是在攻擊面前面加一道牆。
第四幕到這裡結束。明天進第五幕,把這 27 天做出來的東西真的送上線。