這篇整理網站放在 CDN(內容遞送網路,content delivery network:代替源站接收使用者連線、在邊緣做快取與過濾的服務,例如 Cloudflare)後面時,源站主機的防火牆要限制什麼、限制放在哪裡:請求避開 CDN 直接連到源站的成因(源站位址從 DNS 歷史紀錄、憑證透明度紀錄與其他記錄外洩)、來源限制放在雲端防火牆、主機防火牆或 CDN 端驗證時各自擋得住什麼、只放行 CDN 位址的規則什麼時候套上去、管理通道與同一台主機上不經 CDN 的服務怎麼處理、Docker 發佈的連接埠為什麼不受 ufw 與 firewalld 的一般規則約束、Podman 與 rootless 模式的容器在這一點上的差異,以及不發佈、只發佈在本機位址、DOCKER-USER 鏈、firewalld 的 StrictForwardPorts、掛在位址轉換之前的 nftables 表、雲端防火牆與 Cloudflare Tunnel 這幾種做法、出站的 DNS 只准送往指定解析器擋得住與擋不住的部分,以及地理封鎖放在 CDN 邊緣的理由。

機制以 Linux 主機、Cloudflare 與以 root 執行的 Docker(預設的 iptables 後端;compose 發佈連接埠的方式與 docker run -p 相同)說明。其他 CDN 對應的是它公佈的、連回源站時使用的位址清單與它的回源驗證功能。Podman 與 rootless 模式的 Docker 轉送連接埠的方式不同,判斷的依據相同(容器執行環境有沒有在主機防火牆之前改寫封包的目的地),各自量到的結果見〈Docker 發佈的連接埠:封包路徑與限制的寫法〉裡 Docker 以外的容器執行方式那一段;Kubernetes 的 NodePort 沒有驗證。

指令與輸出逐步記在下列實測紀錄,下文寫「實測」的地方都出自它們:源站防火牆只放行 Cloudflare 位址的實測紀錄:firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS 在 Oracle Linux 10 的 firewalld 2.4.3 與 Docker 29.9.2 上,用一個位址落在 Cloudflare 範圍內、一個落在範圍外的兩個測試來源,量了每一種限制寫法擋不擋得住,以及規則在 firewalld 重新載入、dockerd 重啟與重開機之後還在不在;ufw 主機上 Docker 發佈連接埠的實測紀錄:ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑 在 Ubuntu 的 ufw 上量同一組問題;Podman 與 rootless Docker 發佈連接埠的實測紀錄:rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址 換成其他容器執行方式;Cloudflare 回源的實測紀錄:Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠 接上實際的 Cloudflare 帳號,讓 Cloudflare 回源到一台 Oracle Cloud 主機。管理員登入主機那條通道的設計另見 伺服器管理通道的設計:網路可達性、通道授權、主機登入驗證三層的做法與選擇,以及救援入口,請求從 DNS 到應用經過哪幾層見 流量入口層 — 請求怎麼從使用者到達應用,CDN 本身的分發責任見 5.9 邊緣分發與靜態資源(CDN / Origin Protection)(那一篇的 origin protection 指的是快取未命中時保護源站的負載,跟這篇的安全限制是兩件事)。

主機前面有 CDN 時連到源站的路徑:經由 CDN、直連公開 IP 與管理通道

源站(origin server)是實際跑網站程式的主機,CDN 收到使用者的請求之後,從自己的機房連回源站取回應,這個動作叫回源。網域的 DNS 記錄設成經由 CDN 代理時,使用者解析網域拿到的是 CDN 的位址,請求先到 CDN,CDN 再用它自己的位址連到源站。這時連得到源站的路徑有三條:

  • 經由 CDN 回源:使用者連 CDN,CDN 連源站的 80 或 443。源站看到的來源位址是 CDN 機房的位址,使用者真正的位址放在 CDN 加上的 HTTP 標頭裡(Cloudflare 是 CF-Connecting-IP)。
  • 直連源站的公開 IP:知道源站位址的人跳過 CDN,直接對源站的 80、443 或其他對外的連接埠送請求。CDN 上設定的 WAF(Web 應用程式防火牆:依規則檢查 HTTP 請求內容並攔下可疑請求的元件)規則、速率限制與地理封鎖都在 CDN 那一端執行,這條路徑上沒有任何一條會作用。
  • 管理通道:管理員登入主機的 SSH,或經由雲端業者的代理(Oracle Cloud 的 Bastion、GCP 的 IAP)。這條路徑跟網站流量無關,CDN 也不經手。

CDN 帶來的保護只對經由 CDN 回源那條路徑成立。一個漏洞公開之後,掃描程式會對網際網路上的主機送出探測請求;源站主機的防火牆對全網開放 80 與 443 時,這些請求可以走直連公開 IP 那條路徑送到源站,CDN 上為這個漏洞加的每一條規則都碰不到它們。直連那條路徑開著還有一個影響:直連的人可以自己在請求裡寫一個假的 CF-Connecting-IP,源站的存取記錄、速率限制與依國家的判斷照這個標頭做時都會被騙。反向代理的設定裡只信任來自 CDN 位址段的這個標頭(nginx 是 set_real_ip_from 加 real_ip_header),擋下的是不經過 CDN 的直連;從 CDN 位址送來的請求不一定是 CDN 替這個網域轉送的:實測用一個 Cloudflare Worker(任何帳號都能部署、從 Cloudflare 的位址對外連線的程式)連到只放行 Cloudflare 位址的源站,Worker 自己寫的 CF-Connecting-IP 原封不動送到了源站。這個標頭要能信任,源站還要能確認請求是 CDN 替自己的網域送來的,做法在〈限制放在哪一層:雲端防火牆、主機防火牆與 CDN 端的驗證〉。

讓請求避開 CDN 的成因:源站位址從哪裡外洩

直連源站要先知道源站的位址,而這個位址的來源比網域的 A 記錄多。Cloudflare 的〈Protect your origin server〉列出的外洩來源有:接上 CDN 之前的 DNS 歷史紀錄(DNS 歷史查詢服務保留了網域過去解析到的位址,接上 CDN 之後舊記錄仍查得到)、沒有經由 CDN 代理的記錄(mail、ftp 這類子網域的 A 記錄,以及寫著 IP 的 SPF、TXT 記錄;SPF 是寫在 TXT 記錄裡、列出允許替這個網域寄信的主機的設定),以及跟網站同一台主機收發郵件時,退信會帶出主機的位址。Cloudflare 那一頁沒有列、但同樣公開的另一個來源是憑證透明度紀錄(certificate transparency log):每一張公開簽發的 TLS 憑證都記在公開的日誌裡,源站曾經用自己的位址對外提供 HTTPS 時,掃描服務把憑證上的網域與當時回應的位址對起來。另外,網際網路上的掃描程式不必先知道網域:它們對整段 IP 位址逐一連 80 與 443,碰到回應的主機就送出探測請求,所以位址從未外洩的源站一樣會被掃到。

源站的位址因此要當成已經公開,防護要直接寫在源站能被連到的位置上:只有經由 CDN 的請求連得進來,直連公開 IP 那條路徑才會關上。Cloudflare 那一頁另外建議接上 CDN 之後更換源站的 IP,讓歷史紀錄裡的位址失效。

限制放在哪一層:雲端防火牆、主機防火牆與 CDN 端的驗證

「只讓 CDN 連進來」可以寫在三個位置,它們擋的範圍與失效的方式不同:

  • 雲端防火牆(Oracle Cloud 的 security list 與 NSG(network security group,套在網卡上的一組放行規則)、AWS 的 security group、GCP 的 VPC 防火牆規則,位置與合併方式見 網路:subnet、私有 IP、公開 IP 與防火牆規則 的〈防火牆規則的位置與合併方式〉):規則在業者的網路上執行,被擋的封包到不了主機,主機上跑什麼程式、程式怎麼改寫主機的防火牆都不影響它。要評估的是規則數:Cloudflare 在 2026-10-25 公佈的是 15 段 IPv4 與 7 段 IPv6,一條規則能放幾段來源、一組規則能放幾條,每一家業者的配額不同。Oracle Cloud 的 NSG 與 AWS 的 security group 一條規則只放一段來源,條數是「段數 × 要放行的連接埠數」:只放行 80 與 443 時 IPv4 是 30 條、加上 IPv6 是 44 條,實測 Oracle 的 NSG 上限是入站加出站合計 120 條,AWS 的 security group 文件寫的預設上限是入站 60 條、IPv4 與 IPv6 分開算,兩家都放得下;GCP 的 VPC 防火牆規則一條可以放 5000 段來源,IPv4 與 IPv6 各一條就放下了(GCP 與 AWS 依文件,沒有實測)。清單變動時也要有人改規則:一條規則只放一段時,每次更新是逐條增刪;AWS 可以改用自建的 prefix list,更新的是 list 的項目,security group 的規則不動,而 prefix list 被引用時以它設定的最大項數計入規則數(依文件,沒有實測)。
  • 主機防火牆(firewalld、ufw、nftables):在主機的核心裡執行,可以用 ipset(核心裡的一個位址集合,一條規則比對整個集合)一次放進整份清單,也能寫出只對某個連接埠生效的規則。它管不到的是在它之前就改寫封包目的地的程式,Docker 是最常見的一個,機制見〈Docker 發佈的連接埠:封包路徑與限制的寫法〉。
  • CDN 端的驗證:源站確認請求是 CDN 替這個網域送來的,而不只是來自 CDN 的位址。Cloudflare 的做法有兩種:Authenticated Origin Pulls 讓 Cloudflare 回源時出示用戶端憑證,源站的 TLS 設定只接受帶這張憑證的連線;Cloudflare Tunnel 由源站上的 cloudflared 主動往外連到 Cloudflare,請求經由這條往外建好的連線送進來(outbound tunnel 的一種)。Authenticated Origin Pulls 用 Cloudflare 預設的共用憑證(主控台的 Global 開關)時,〈Protect your origin server〉寫明它只保證請求來自 Cloudflare 的網路;要分辨請求是否由自己的網域送來,要改用自己上傳的用戶端憑證(設在 Cloudflare 的 zone 層級,也就是一個網域的設定單位,或每個主機名稱各一張)。實測打開 Global 之後,Cloudflare 回源時出示憑證、nginx 驗證成功,nginx 設成必須有憑證時,沒有憑證的直連被回 400(nginx 的設定與輸出在 Cloudflare 回源的實測紀錄:Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠 的〈SSL/TLS 模式「完整」與 Authenticated Origin Pulls〉);自己上傳憑證的那兩種沒有實跑。

雲端防火牆與主機防火牆比對的是來源位址,而 CDN 的位址由所有客戶共用。別人也可以用自己的 Cloudflare 帳號,把一個網域指向這台源站的位址,或部署一個 Worker 直接對源站送請求;這些請求同樣從 Cloudflare 的位址送來,通過只放行 CDN 位址的規則,而源站擁有者在自己帳號上設的 WAF 規則管不到別人帳號送來的請求。Worker 這一條實測過,用的是自己帳號的 Worker,連一個不屬於任何 Cloudflare 網域的主機名稱:Worker 出站的位址落在 Cloudflare 公布的清單裡,穿過了只放行 Cloudflare 位址的 Oracle Cloud NSG,它在請求裡寫的 CF-Connecting-IP 也原樣送到源站。下面兩條是推論,都沒有用第二個帳號實測:Worker 這條路徑不經過帳號裡任何網域的設定,所以換成別人的帳號結果相同;別人把自己帳號裡的網域指向這台源站時,回源請求同樣從共用的 Cloudflare 位址送來,所以同樣通過只放行位址的規則。把這一類稱為經由別的 CDN 帳號送來的請求:只放行位址的規則擋不住它;共用憑證的 Authenticated Origin Pulls 擋不住別人帳號裡同樣打開 Global 的網域(依文件),Worker 對 https 源站的請求會不會帶這張共用憑證沒有測;擋住整類要用自己上傳憑證的 Authenticated Origin Pulls 或 Tunnel。只放行位址的規則擋下的是不經過 CDN 的直連,漏洞公開後對整段位址的掃描就屬於這一類。自己上傳憑證的 Authenticated Origin Pulls 以外,還有幾種讓源站分辨請求屬於自己網域的做法,這次都沒有實跑:在自己的 zone 用 Cloudflare 的規則替回源請求加一個只有自己知道的標頭、源站驗證它;或用 Cloudflare Access 的 service token。三個位置可以疊加,例如雲端防火牆只放行 CDN 位址到 443,源站的 TLS 設定再驗證自己上傳的用戶端憑證,主機防火牆則管住雲端防火牆碰不到的出站流量與主機上的其他連接埠。用戶端憑證只在 TLS 上驗證,80 上的 http 請求不帶憑證:80 也放行時,依 CF-Connecting-IP 做判斷的設定只能放在驗證憑證的那一個 server 區塊,80 只做轉址到 https、不採信這個標頭;實測的 Worker 偽造標頭走的正是 80。

只放行 CDN 位址的套用時機、範圍與清單的維護

只放行 CDN 位址的規則一套上去,不經過 CDN 的連線全部連不進來。規則的效果與失效的方式,依它在什麼時候套上、套在哪些連接埠而不同:

  • 時機:網域的 DNS 記錄已經經由 CDN 代理,舊記錄的快取也過期了。還沒接上 CDN、或記錄設成只做 DNS 解析(Cloudflare 的 DNS only)時,使用者直接連源站,規則一套上,所有訪客都連不進網站:規則寫在雲端防火牆或丟棄封包的規則上時,瀏覽器等到連線逾時;寫在 firewalld 的 zone 上時,firewalld 以 ICMP 回拒,瀏覽器立即顯示連線失敗(兩種結果的分辨見 Connection Refused vs. Timeout(連線被拒與逾時))。網站回應的標頭帶著 CDN 的標記(Cloudflare 會加 cf-ray),只代表管理員自己這一次的請求經過了 CDN;其他訪客的解析器可能還快取著舊的 A 記錄(指向源站),換成 Cloudflare 的 nameserver 時,舊的 NS 記錄也有快取(2026-10-25 查 .com 上層伺服器回的 NS 記錄,TTL 是 172800 秒,也就是兩天;dig +noall +authority <網域> @a.gtld-servers.net 查得到自己網域的值)。所以套規則的時間點在舊記錄的快取全部過期之後:只改了 A 記錄時,等舊 A 記錄的 TTL;換了 nameserver、而舊的 DNS 業者還保留著這個網域的資料時,快取著舊 NS 的解析器在那份快取到期前一刻向舊業者查 A,又會拿到一份新的舊 A 記錄,所以要等舊 NS 的 TTL 加上舊 A 的 TTL;舊 NS 的 TTL 取上層伺服器回的值與舊業者自己回的值之中較大的那一個(解析器常採用後者)。舊業者已經刪掉這個網域時,等兩者之中較長的那一個就夠(依 DNS 快取的機制推論,沒有實測)。在那之前套上,快取著舊記錄的訪客連不進網站。
  • 接上 CDN 之前就能做的限制:管理通道改走白名單或代理、資料庫與內部服務的容器不發佈或只發佈在本機位址、關掉 80 與 443 以外對外開著的連接埠、出站 DNS 的限制,這幾項不依賴 DNS 記錄的狀態,可以先做。只放行 CDN 位址到 80 與 443 這一項,要等上一項的時機。
  • 範圍:管理通道不在這條規則裡。規則只套在網站的 80 與 443 上,SSH 走 伺服器管理通道的設計 裡的那一條(來源 IP 白名單、Bastion、IAP 或 VPN),兩者分開設定。把整台主機的入站都改成只放行 CDN 位址時,管理員自己的 SSH 會一起被擋在外面;Cloudflare 的 DNS 代理轉送的是網站的 HTTP 與 HTTPS,SSH 要經由 Cloudflare 時另外架 Tunnel。
  • 範圍:同一台主機上還有不經 CDN 的服務。例如網站經由 Cloudflare,而同一台主機的 443 也直接對外提供一個 API 子網域。防火牆只看位址與連接埠,分不出同一個連接埠上的兩個網域(那要看 TLS 的 SNI 或 HTTP 的 Host),所以對 443 套「只放行 CDN」,API 就斷線;不套,網站的直連路徑就開著,而 API 子網域的 A 記錄本身就公開了源站的位址。可行的分法有:API 也改經由 CDN 代理;API 改用另一個公開 IP 或另一個連接埠,各自套各自的規則;或在反向代理依網域分流,網站的網域只接受來自 CDN 位址的請求(這一種仍留著經由別的 CDN 帳號送來的請求);或網站的網域用自己上傳的每個主機名稱一張的用戶端憑證,只在網站的 server 區塊驗證,API 的區塊不驗證。這幾種做法沒有實跑。

規則寫在主機本身程式收連線的路徑上(firewalld 的 zone、ufw 的入站規則)時,寫成「放行 CDN 位址到 80 與 443」就夠:zone 的預設動作已經回拒未列出的連線(firewalld 的 public zone 是回拒,ufw 預設拒絕入站是丟棄),放行清單之外的來源自然被擋下。這個前提要先查:主機的預設 zone 不一定是 public(firewall-cmd --get-default-zone),有些發行版的預設 zone 本身就放行一段高連接埠,firewall-cmd --list-all 列得出它的 target 與開著的連接埠。zone 的預設回拒只作用在主機本身收連線的那條路徑上。Docker 發佈的連接埠走的是另一條路徑:封包的目的地先被改寫成容器,再由主機轉送過去,這條轉送路徑上沒有對應的預設拒絕,那裡的規則要寫成「來源不在 CDN 清單裡就丟棄」,寫法見〈Docker 發佈的連接埠:封包路徑與限制的寫法〉的 DOCKER-USER 與 nftables 表。

CDN 經由 IPv6 回源的條件,是管理員在 CDN 的 DNS 設定裡替源站填了 IPv6 位址(Cloudflare 的 AAAA 紀錄、內容是源站的 IPv6)。訪客查得到的 AAAA 不代表這件事:實測只填了源站 IPv4 的 Proxied 紀錄,網域照樣解析出 Cloudflare 的 IPv6 位址,而回源全部經由 IPv4;填了源站 IPv6 時 Cloudflare 會不會改用 IPv6 回源沒有測(依 Cloudflare 的 DNS 文件推論會)。沒有替源站填 IPv6 時,主機的 IPv6 只會收到直連的請求,主機防火牆照預設回拒就好。填了的話,漏寫 IPv6 的後果依規則的寫法而不同:

  • 放行式(只放行清單,其餘交給預設回拒)只寫了 IPv4 的清單:CDN 經由 IPv6 回源的連線被預設回拒擋下,網站對 IPv6 回源失效。
  • 丟棄式(來源不在清單裡就丟棄)的比對只寫了 IPv4 位址(nftables 的 ip saddr、iptables):IPv6 的連線不受這條規則限制,經由 IPv6 直連源站的路徑照舊開著。

兩種寫法都要另寫一份 IPv6 清單(Cloudflare 的 ips-v6)。Docker 發佈的連接埠的 IPv6 那一份走的路徑跟 IPv4 那一份不同,寫在〈Docker 發佈的連接埠:封包路徑與限制的寫法〉。

CDN 的位址清單會變動。Cloudflare 的〈Cloudflare IP addresses〉寫明清單不常變,新的位址段會先加進公佈的清單再投入使用,沒有寫會另外通知。規則裡的清單沒有跟著更新時,Cloudflare 從新位址段回源的請求會被擋下,所以清單要定期從 https://www.cloudflare.com/ips-v4 與 ips-v6(或 Cloudflare 的 API)取回、跟規則比對。規則用位址集合(firewalld 的 ipset、nftables 的 set)寫時,更新只換集合的內容,規則本身不動,排程一支取回清單、換掉集合內容的程式就能維護;這個做法沒有實跑。DOCKER-USER 的自建鏈、ufw 的 after.rules 與 ufw 的 IPv6 入站放行都是一段一條的規則,更新時整段重新產生(after.rules 的產生與對帳見 ufw 主機上 Docker 發佈連接埠的實測紀錄:ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑 的〈讓規則開機時載入:寫進 /etc/ufw/after.rules〉)。不論哪一種載體,Cloudflare 這兩份清單的最後一行沒有換行字元,逐行讀取的迴圈會漏掉最後一段而不報錯,所以寫進去的條數要跟清單的段數對帳。

Docker 發佈的連接埠:封包路徑與限制的寫法

容器用 docker run -p 8081:80 發佈連接埠時,Docker 在 iptables 的 nat 表寫一條目的位址轉換規則(DNAT,destination NAT:把封包的目的地改寫成另一個位址與連接埠),在封包剛進主機的 prerouting 階段,就把目的地從「主機的 8081」改成「容器的 80」。改寫之後,核心判定這個封包要轉送給另一個網路介面(容器網路在主機上的 bridge 介面:預設網路是 docker0,compose 建的自訂網路是 br- 開頭的介面,容器接在它後面),它走的是轉送(forward)路徑,不經過管主機本身入站連線的 input 路徑。

主機防火牆的一般規則寫在 input 路徑上,所以對發佈的連接埠沒有作用,ufw status 與 firewall-cmd --list-all 也都列不出這些連接埠(要用 docker ps 或 ss -ltnp 查):

  • ufw:Docker 的〈Packet filtering and firewalls〉寫明,送往發佈連接埠的流量在經過 ufw 的規則之前就被轉走,ufw 的設定對它等於不存在。網路上常見的解法是在 Docker 的設定裡關掉它的 iptables 管理("iptables": false),同一頁寫明這樣做多半會讓容器的網路失效,發佈的連接埠也會變成區網內連得到。
  • firewalld:firewalld 把網路介面分到 zone(一組介面共用一套入站規則,例如 public),zone 與 zone 之間轉送的流量由 policy 管(一條 policy 指定從哪些 zone 轉送到哪些 zone、帶一個優先序與預設動作)。Docker 啟動時建一個 target 為 ACCEPT 的 docker zone,把它的 bridge 介面放進去,再建一條 docker-forwarding policy,放行任何 zone 轉送到 docker zone 的流量。實測在 public zone 只開了 ssh 與 dhcpv6-client、沒有任何一條規則提到 8081 的主機上,位址落在 Cloudflare 範圍外的測試來源照樣拿到容器的回應。firewalld 的轉送鏈開頭還有一條 ct status dnat accept:在預設的 StrictForwardPorts=no 下,所有經過目的位址轉換的連線在任何 policy 之前就被接受,所以自己加一條「public 轉送到 docker 只放行 CDN 位址」的 policy 也擋不住,實測加上之後範圍外的來源仍然連得到。

經過位址轉換的那一份,Docker 保留封包原本的來源位址,容器裡的程式看到的是真正的連線來源(實測容器的存取記錄裡,兩個經由 IPv4 連的測試來源位址都原樣出現),所以來源限制在技術上做得到。

-p 8080:80 同時發佈在 IPv4 與 IPv6,而兩份走的路徑不同。容器所在的網路沒有開 IPv6(Docker 預設的 bridge 網路就是),而 Docker 的 userland-proxy 是預設的開著時,IPv6 那一份由 docker-proxy 這個主機上的程式(Userland Proxy 的一種)接聽、再自己連到容器的 IPv4 位址,連線走 input 路徑:實測在 ufw 與 firewalld 上,沒有放行規則時經由 IPv6 連發佈的連接埠都被擋下,加了 IPv6 的放行規則之後才連得到,跟 IPv4 那一份正好相反,所以 IPv6 這一份照放行式寫在主機防火牆的入站規則上。代價是來源位址:docker-proxy 自己連進容器,實測容器記到的 IPv6 那一筆來源是預設網路的閘道 172.17.0.1(compose 建的自訂網路閘道是別的位址,docker network inspect <網路名> 的 Gateway 欄查得到),容器裡的反向代理依來源位址做的判斷(只信任 CDN 位址段送來的 CF-Connecting-IP、存取紀錄)對經由 IPv6 回源的請求都不成立。Docker 的 userland-proxy 設成 false 時,實測在 ufw 主機上 IPv6 那一份根本沒有發佈:docker ps 只列 0.0.0.0 的那一份、ip6tables 沒有轉換規則,經由 IPv6 連都被拒。網路建立時帶 --ipv6、容器有 IPv6 位址時,依 Docker〈Port publishing〉的說明,IPv6 那一份改由 ip6tables 的位址轉換送進容器,走轉送路徑,規則要寫在 ip6tables 的 DOCKER-USER 或 nftables 的 ip6 saddr;這一種沒有實跑。

限制發佈連接埠的做法,從「讓連接埠不對外」到「在主機之外擋」依序是:

  • 不發佈:同一個 compose 專案或同一個 Docker 網路裡的容器,以服務名稱互相連線,不需要發佈連接埠。資料庫、快取這類只給其他容器用的服務不寫 ports,主機外面就連不到它們;主機上的工具要連時,才發佈在本機位址。

  • 只發佈在本機位址(-p 127.0.0.1:8082:80):外部來源連不到這個連接埠,實測兩個外部來源都被拒、主機本機連得到。這個結果有版本條件:Docker 的〈Port publishing〉寫明 28.0.0 以前,跟主機在同一個 L2 網段的其他主機連得到發佈在 127.0.0.1 的連接埠(實測用的是 29.9.2,docker version 查得到版本),共用網段的 VPS 或區網裡要先確認版本。對外改由反向代理(nginx、Caddy)收 80 與 443 再轉給容器;反向代理以主機上的程式或 host network 模式的容器執行時,它收連線走 input 路徑,主機防火牆的放行式規則對它有效,這是用 ufw、又沒有雲端防火牆可設的主機上規則最少的組合。反向代理自己也用 -p 80:80 -p 443:443 發佈時,它跟其他發佈的連接埠一樣走轉送路徑,要照下面幾種寫法限制。預設的發佈位址可以在 Docker 的設定裡改:實測 daemon 設定的 ip 設成 127.0.0.1 之後,預設 bridge 網路上的容器發佈在 127.0.0.1,而接在自訂網路上的容器照樣發佈在 0.0.0.0 與 [::],外部來源連得到;自訂網路建立時帶網路選項 com.docker.network.bridge.host_binding_ipv4=127.0.0.1,那個網路上的容器才發佈在 127.0.0.1。compose 的自訂網路在 driver_opts 寫同一個選項,或用 daemon 設定的 default-network-opts 套到之後新建的每一個 bridge 網路(已經建好的網路不受影響),這兩種沒有實跑。

  • DOCKER-USER 鏈:Docker 在 iptables 的 FORWARD 鏈最前面跳到這條鏈,裡面的規則由使用者自己寫,Docker 啟動與重啟時不清掉它(實測只重啟 dockerd 之後規則還在、照樣擋得住)。封包到這裡時目的地已經改寫成容器的位址與連接埠,所以要用連線追蹤(conntrack:核心記錄每一條連線的狀態與它原本的位址、連接埠的表)記下的原始目的連接埠比對(-m conntrack --ctstate DNAT --ctorigdstport 443 --ctdir ORIGINAL)。--ctstate DNAT 限定只比對經過位址轉換的連線:容器自己往外連 443、容器之間連 80 的封包也經過這條鏈、原始目的連接埠一樣是 443 或 80,實測少了這個條件時兩者都被丟掉,加上之後恢復,而範圍外的來源仍被擋(實測時發佈的連接埠是 8081,正式設定換成 80 與 443 時才會撞到這兩種流量)。--ctdir ORIGINAL 讓規則只比對發起方向的封包;容器回覆的封包也帶著同一個原始目的連接埠,少了這個條件時這些回覆封包會被拿去比對來源而丟掉(依連線追蹤的機制推論,沒有拿掉這個條件實測)。實測範圍外的來源被擋下(連線逾時),範圍內的照常。它的弱點在規則的存續:防火牆重新載入、dockerd 重啟與重開機之後規則還在不在。用 iptables 指令加的規則只存在執行中的核心裡,各種主機上的結果如下:

    • 任何主機重開機:規則不在(ufw 主機上實測)。
    • ufw 主機,after.rules 沒有宣告這條鏈:ufw reload 與關閉再開啟只重建 ufw 自己的鏈,規則還在(實測)。
    • ufw 主機,規則寫進 /etc/ufw/after.rules:這是 ufw 主機上讓規則留過重開機的做法(整份 Cloudflare 清單的寫法在 ufw 主機上 Docker 發佈連接埠的實測紀錄:ufw 規則的效果、DOCKER-USER 規則的存續與 after.rules、IPv6 的 docker-proxy、compose 內建 DNS 的出站路徑 的〈讓規則開機時載入:寫進 /etc/ufw/after.rules〉)。實測 reload、Docker 重啟與重開機之後都還在:開機時 ufw 先於 Docker 啟動,先建出這條鏈,Docker 沿用它。after.rules 宣告這條鏈時,每次 ufw reload 先清空它再寫入檔案裡的規則,實測另外用 iptables -I 加的規則在 reload 之後不在,所以同一台主機上的 DOCKER-USER 規則要全部寫在 after.rules 裡。
    • firewalld 主機,nftables 後端:firewall-cmd --reload 在有 direct 設定時清掉規則與自建的鏈,沒有時規則還在(FlushAllOnReload 設成 yes 或 no 都一樣);direct 設定有 direct rule 與 passthrough 兩種,實測各加一條都會觸發,查的時候 firewall-cmd --direct --get-all-rules 與 --get-all-passthroughs 兩個都要跑,前一個列不出 passthrough。實測的 Oracle Cloud 主機(2026.10.16 的 Oracle Linux 10.2 平台映像,nftables 後端)自帶一組保護執行個體中繼資料服務的 passthrough,reload 清掉了規則,那一組的內容記在 源站防火牆只放行 Cloudflare 位址的實測紀錄:firewalld、Docker 發佈的連接埠、DOCKER-USER、nftables 表與出站 DNS 的〈DOCKER-USER 鏈的限制效果與規則的存續〉。direct 設定也會在之後被別的軟體加進來(例如 fail2ban 的 firewalld 動作,依它的設定推論),所以這是主機當下的狀態,不是固定的屬性。
    • firewalld 主機,iptables 後端(/etc/firewalld/firewalld.conf 的 FirewallBackend):沒有任何 direct 設定,reload 也清掉了規則,因為那時 iptables 的表由 firewalld 管、reload 整張重建(實測)。
    • 用組態管理工具定期 reload 的主機上,規則會被清掉的那幾種情形,每一次 reload 之後直連就重新開著,沒有任何告警。systemctl restart firewalld,以及 iptables-services 或 netfilter-persistent 的開機載入,都沒有實跑。

    另外,Docker 改用實驗性的 nftables 後端時,〈firewall-nftables〉寫明沒有 DOCKER-USER 這條鏈,自訂規則要寫在自己的 nftables 表裡,也就是下面「自己的 nftables 表」那一種。

  • firewalld 的 StrictForwardPorts=yes:設定檔 /etc/firewalld/firewalld.conf 的這個選項(這次的 firewalld 2.4.3 有;它是 2024 年 11 月 firewalld 官方部落格〈Strict Forward Ports〉介紹的新功能,較舊的 firewalld 沒有)把轉送鏈開頭的 ct status dnat accept 換成只接受 firewalld 明確放行的連接埠。〈firewalld.conf〉與那篇部落格寫明,設成 yes 之後發佈的連接埠要用 firewalld 的 forward-port(firewalld 自己做的目的位址轉換規則)明確放行。實測它管得到「哪些發佈的連接埠可以從外面連」:設成 yes 之後兩個測試來源都被回拒,加了 forward-port 之後又都連得到;它管不到「誰可以連」:即使 forward-port 寫在限定來源的 rich rule(firewalld 可以同時寫來源、連接埠與動作的規則)裡,firewalld 在轉送鏈產生的放行規則也只比對原始目的連接埠(連線追蹤記下的、位址轉換之前的連接埠),沒有帶上來源條件,範圍外的來源也連得到。forward-port 改寫在 policy 上時,轉送鏈裡沒有產生任何放行,範圍內的來源也連不到。規則存在 firewalld 的永久設定裡,reload 與 dockerd 重啟之後都在。

  • 自己的 nftables 表,掛在目的位址轉換之前:在 nftables 建一張自己的表,鏈接在 prerouting 這個 hook(核心處理封包的流程上,讓規則掛上去執行的固定位置;prerouting 是封包剛進主機、還沒決定路由的那一個)上、優先序排在目的位址轉換之前(priority dstnat - 10),封包還沒被改寫時就用原本的目的連接埠比對來源,來源不在 CDN 清單裡就丟棄。規則要限定目的地是主機自己的位址(fib daddr type local):容器往外連 443 與容器之間連 80 的封包也從 docker0 進 prerouting,實測少了這個條件時兩者都被丟掉。實測範圍外的來源被擋下,firewall-cmd --reload、dockerd 重啟之後規則都還在;把表寫進 nftables.service 開機時載入的設定檔(RHEL 系是 /etc/sysconfig/nftables.conf,其他發行版看 systemctl cat nftables 的 ExecStart)並啟用這個服務之後,重開機也還在。代價有兩個。一是規則不在 firewalld 的管理介面裡,firewall-cmd --list-all 看不到它,接手的人要知道還有這一張表。二是 nftables.service 會清掉整個 nftables 規則集:RHEL 系的 unit 在停止、重新啟動與重新載入時執行 nft flush ruleset,而 Debian 系的預設設定檔第一行就是 flush ruleset(這一點沒有核對各版本),被清掉的包括 firewalld 的表,以及 Docker 經由 iptables-nft 寫的位址轉換與轉送規則;之後自建的表又被載回來,主機只剩這一張表,其他連接埠失去預設回拒、發佈的連接埠與容器的出站都斷線,要依序重新啟動 firewalld 與 dockerd 才會重建(依 unit 檔的內容推論,沒有實跑)。避開這個風險的做法是不經過 nftables.service,改用一個開機時只執行 nft -f 載入這一張表的 systemd unit。

  • 雲端防火牆:規則在主機之外執行,Docker 改寫的是主機自己的防火牆,碰不到它。只放行 CDN 位址到 80 與 443、其他連接埠一律不開時,主機上誤發佈的任何連接埠都連不進來。

  • Cloudflare Tunnel:源站不需要任何入站放行。這個效果要管理員自己做到:移除雲端防火牆與主機防火牆上 80、443 的入站放行,網站容器不發佈連接埠(或只發佈在本機位址),讓 cloudflared 經由同一個 Docker 網路或本機位址連到網站;網站容器還用 -p 80:80 發佈時,直連路徑照樣開著。代價是 cloudflared 要常駐、主機要能出站連到 Cloudflare,而且網站的入口綁在 Cloudflare 一家。實測用不需要帳號的 quick tunnel:後端只聽 127.0.0.1、沒有任何入站放行也連得到;quick tunnel 的 cloudflared 用 QUIC 連 Cloudflare 的 UDP 7844,出站擋掉 UDP 7844、TCP 7844 開著時,它一直重試 QUIC、沒有自動改用 http2,網站回 Cloudflare 的 530;指定 --protocol http2 之後改用 TCP 7844 連上。取得 quick tunnel 網址的那一步另外連出站的 443。所以出站只放行一份清單的主機上,quick tunnel 要的是 UDP 與 TCP 的 7844(各給 QUIC 與 http2)與取網址用的 443;清單只有 TCP 7844 而協定留在預設時,quick tunnel 停在重試 QUIC、網站回 530,協定設成 http2 時才改走 TCP 7844。每一種出站限制的結果記在 Cloudflare 回源的實測紀錄:Oracle Cloud NSG 只放行 Cloudflare 位址與規則數上限、回源的來源位址、Authenticated Origin Pulls、Worker 偽造 CF-Connecting-IP 與 Tunnel 的出站連接埠 的〈Cloudflare Tunnel:源站不開入站,以及出站要放行的連接埠〉。具名的 tunnel(綁在帳號上的那種)預設的協定選擇沒有測。沒有出站路徑的主機(例如只有 service gateway 的私有 subnet 主機)用不了。

Docker 以外的容器執行方式,發佈連接埠的路徑分成兩類:在主機防火牆之前改寫封包目的地的,連線走轉送路徑,結果取決於轉送路徑上每一張表的預設動作;由主機上一個轉送程式聽著那個連接埠、收下連線再轉進容器的,連線走 input 路徑,主機防火牆的入站規則管得到。分辨是哪一類,先查有沒有規則把那個連接埠的目的地改寫掉,再看誰在聽:改寫規則可能寫在經由 iptables-nft 管的表,也可能寫在原生 nftables 的表(實測 Podman 5.8.2 的 netavark 寫在 inet netavark,iptables -t nat 看不到),所以要用 nft list ruleset 查。查到改寫規則時就是前一類,ss -ltnp 裡聽著那個連接埠的程式(Podman 的 conmon、docker-proxy 的 IPv4 那一份)只是佔住連接埠;查不到時,聽著的轉送程式才是接收者。查表是預測,最終以範圍外的來源實際連一次為準。

以 root 執行的 Podman(用 netavark 寫規則)屬於前一類,跟 Docker 一樣用位址轉換。實測在 firewalld 上它跟 Docker 一樣繞過 zone,在 ufw 上卻被 ufw 預設的 deny (routed) 擋下,連 Cloudflare 位址都連不到,要用 ufw 的 route 規則放行到容器的位址與連接埠;ufw 那一台的差別在 netavark 的轉送鏈只放行回程,沒有 Docker 那樣接受新入站連線的 DOCKER-FORWARD。兩台的 Podman 版本也不同(ufw 那一台是 4.9.3、firewalld 那一台是 5.8.2),防火牆與版本的影響沒有分開。主機上同時裝了 Docker 時,Podman 的轉送封包也經過 DOCKER-USER:實測在 ufw 那一台,一條比對原始目的連接埠 8089、沒有帶 --ctstate DNAT 的規則,對 Podman 發佈的 8089 一樣擋得住範圍外的來源(換成 80、443 時照上面 DOCKER-USER 那一項加 --ctstate DNAT,這個組合沒有在 Podman 上實跑)。

rootless 模式的 Docker 與 Podman 屬於後一類:它們以一般使用者執行,不能改主機的防火牆,發佈的連接埠由主機上的轉送程式(rootlesskit、rootlessport)接聽,ufw 的入站規則實測管得到。代價是在預設設定下(RootlessKit 的預設轉送方式、Podman 4 的 slirp4netns),容器看到的來源是轉送程式那一端的位址(172.17.0.1、10.0.2.100),存取紀錄與依來源位址的判斷拿不到真正的來源;Podman 5 預設的 pasta 依它的發行說明保留來源位址,沒有實跑。另外,--network host 的容器沒有位址轉換也沒有轉送程式,聽著連接埠的是程式本身、走 input 路徑;macvlan、ipvlan 網路的容器有自己的位址,封包不經過主機的轉送與 input 路徑,主機防火牆的規則都碰不到它(依網路驅動的機制推論,沒有實跑)。Docker 文件列的保留來源位址做法這次沒有實跑,列在 Podman 與 rootless Docker 發佈連接埠的實測紀錄:rootful Podman 在 ufw 與 firewalld 上的轉送路徑、rootless Docker 與 rootless Podman 由主機程式接收的連接埠與容器看到的來源位址 的 rootless Docker 那一節。

這些寫法可以疊加。業者的防火牆放得下整份 CDN 清單時,雲端防火牆加上不發佈或只發佈在本機位址的組合,不靠主機上任何一條會被重新載入或重開機清掉的規則:雲端防火牆擋住直連,不發佈與本機位址讓「某個容器不小心發佈在 0.0.0.0」這種錯誤不會變成對外開放。網站跑在主機上的程式(不是容器)時,雲端防火牆加上 firewalld zone 或 ufw 的放行式規則就夠,兩層各自擋一次直連。同一組規則要套在多台主機上時,雲端防火牆的規則可以套在一組網卡上共用(Oracle Cloud 的 NSG、AWS 的 security group),主機防火牆的寫法則要在每一台各自部署與維護。單台主機、網站流量、主機連得到外面時,Cloudflare Tunnel 可以取代入站的 CDN 位址清單:移除 80 與 443 的入站放行,而網站容器不發佈或只發佈在本機位址時,清單的維護與 Docker 繞過主機防火牆這兩件事都不必處理。

出站的 DNS 只准送往指定的解析器:擋得到與擋不到的部分

主機加固清單常見的一條是限制 DNS 只用 53,它有入站與出站兩種讀法。入站的讀法是主機有跑 DNS 伺服器時才開 53,沒有跑就不開;網站主機多半沒有跑 DNS 伺服器,所以入站的做法就是不把 53 放進放行清單。出站的讀法是主機與容器送出的 DNS 查詢只准送往指定的解析器(雲端業者的解析器,例如 Oracle Cloud 虛擬網路 VCN 裡的 169.254.169.254),送往其他位址的 53 一律拒絕。後一種的目的,是讓被植入的程式不能用自己指定的 DNS 伺服器查詢,查詢全部經過一個留有紀錄的解析器。它擋不住把資料編進查詢的網域名稱送出去:允許的解析器會遞迴解析任何公開的名稱,<編碼後的資料>.<攻擊者的網域> 照樣被它送到攻擊者的權威伺服器,這一種要靠解析器端的查詢紀錄或 DNS 防火牆偵測。「指定的解析器」指的是主機實際往外送查詢的上游:Ubuntu 預設的 systemd-resolved 讓 /etc/resolv.conf 寫 127.0.0.53,實際的上游要用 resolvectl status 查,規則照 /etc/resolv.conf 寫會擋掉 systemd-resolved 自己的查詢;容器拿到的伺服器用 docker exec <容器> cat /etc/resolv.conf 查。

出站限制在 firewalld 上寫成一條從 HOST(主機本身送出的流量)到 ANY(任何 zone)的 policy,裡面兩條 rich rule:放行送往指定解析器的 dns 服務那一條優先序較小、先比對,拒絕其餘 dns 的那一條排在它之後。實測它的效果與漏洞:

  • 主機本身送出的查詢:送往指定解析器的照常解析,送往其他位址的 53 被拒絕(dig 回 connection refused),對方收不到任何封包。
  • 容器送出的查詢:實測的容器在預設的 bridge 網路上,它的 DNS 設定是主機 /etc/resolv.conf 的副本,查詢直接從容器送出、走轉送路徑,不經過 HOST,所以這條 policy 對它無效,容器照樣把查詢送到外部的 53。這些查詢要在 DOCKER-USER 另寫一條(從容器網路的 bridge 介面進來、目的連接埠 53、目的地不是指定解析器的拒絕;預設網路的介面是 docker0,compose 的自訂網路是 br- 開頭的介面,iptables 可以寫成 -i br-+),而它有 DOCKER-USER 規則存續的問題(用 iptables 指令加的規則,在用 iptables 後端、或用 nftables 後端而有 direct 設定的 firewalld 主機上 reload 就消失、在 ufw 主機上被 after.rules 宣告的鏈 reload 時清空、重開機之後都不在;寫進 after.rules 的那一份重開機之後還在),要一起處理。compose 的自訂網路上,容器先問 Docker 內建的解析器(127.0.0.11),內建解析器再往外查,而那一筆往外查的封包由主機送出:實測連線追蹤記到的原始來源是主機的 IP,不是容器的位址;主機的出站規則擋掉 53(ufw 主機上的 OUTPUT、firewalld 的 HOST policy)時,compose 容器經由 127.0.0.11 的查詢失敗,DOCKER-USER 的規則對它沒有作用。compose 容器自己指定別的 DNS 伺服器時,查詢又從容器的介面轉送出去,DOCKER-USER 擋得到。所以容器的 DNS 要兩個位置都限制:轉送路徑上的規則(DOCKER-USER 或自建 nftables 表的 forward 鏈,實測自建表在 firewall-cmd --reload 之後還在)擋容器直接送出的,主機的出站規則擋內建解析器替它送出的。
  • 不走 53 的 DNS:DNS over TLS 用專用的 853,再加一條拒絕出站 853 的規則就擋得下(沒有實跑)。DNS over HTTPS 把查詢包在送往 443 的 HTTPS 裡,連接埠的限制看不到它。實測時主機對外部 443 送出的一般 TCP 連線不受這條 policy 影響;沒有實際送 DNS over HTTPS 的查詢,它走同一個連接埠,結果依此推論。要擋這一種,出站就要改成只放行一份允許的目的地清單(套件庫、業者的 API),這在 443 上會連帶影響每一個需要連外的功能,成本遠高於限制 53。

所以出站限制 53 擋下的是寫死了 DNS 伺服器位址、走 53 的程式,它讓主機與容器的名稱查詢集中到一個留有查詢紀錄的解析器;有意繞過的程式改走 443 就繞得過去。

地理封鎖:依國家比對來源的位置與 GeoIP 清單的成本

依國家封鎖連線,要先知道連線來自哪個國家,而源站在 CDN 後面時,源站看到的來源位址全是 CDN 機房的位址:主機防火牆照來源位址查國家,查到的是 CDN 機房所在的國家,不是使用者的。所以地理封鎖寫在 CDN 那一端(Cloudflare 的 WAF custom rule 依 ip.src.country 比對),那裡看得到使用者真正的位址;源站只需要擋掉不經過 CDN 的連線,只放行 CDN 位址的規則已經做到。源站要自己做時,由反向代理依 CF-Connecting-IP 查國家,前提是這個標頭可信。只放行 CDN 位址不足以讓它可信(任何帳號的 Worker 都從 CDN 的位址送出、標頭可以自己寫,見〈主機前面有 CDN 時連到源站的路徑:經由 CDN、直連公開 IP 與管理通道〉),可信要靠自己上傳憑證的 Authenticated Origin Pulls 或 Tunnel;防火牆這一層做不到,因為它看不到 HTTP 標頭。

主機防火牆做地理封鎖的機制跟只放行 CDN 位址相同(把一個國家的位址段放進 ipset),適用的情形是主機直接面對使用者、前面沒有 CDN,或同一台主機上不經 CDN 的服務。它的成本在清單:國家的位址段清單要從 GeoIP 資料庫(把 IP 位址段對應到國家的資料庫)取得並定期更新,資料庫對 VPN、行動網路與雲端業者的位址判斷常常不準,被誤擋的使用者只看到連線失敗(逾時或立即被拒,看規則是丟棄還是回拒),沒有任何頁面告訴他原因。CDN 端的封鎖可以回一個說明頁或改成驗證挑戰,誤判時使用者看得到發生了什麼。

防火牆限制與修補的關係

只放行 CDN 位址、在 CDN 上加 WAF 規則,縮小的是能連到有漏洞程式的來源範圍,漏洞本身還在。源站已經修補時,直連的掃描請求送到也攻不破;源站還沒修補時,經由 CDN 送來的同一種請求只要沒有被 WAF 規則認出來,照樣會送到程式。防火牆限制與修補各自關掉一條路徑:限制擋住不經過 CDN 的請求,修補處理經過 CDN 也會送到的那些。漏洞公開到修補完成之間的暫時緩解(WAF 規則、關閉功能)見 7.3 入口治理與伺服器防護 的〈修補窗口期內的暫時緩解〉。單台主機上的修補依軟體的安裝管道分開處理:發行版與第三方套件庫的套件、容器映像檔、應用程式自己的更新、自己建置的程式與從上游下載的二進位檔,各自由不同的機制得知有修補、能不能交給排程也不同;裝上去之後,已經在執行的程序要重新啟動、核心要重開機,修補才生效。各條管道的做法見 長期上線主機的維護項目:會累積的資料、定期讀的 log、對外入口的盤點、安全更新、會到期的設定與檢查間隔的推算〈會老化的軟體:各條更新管道與更新後的重開機〉。

延伸閱讀:實測紀錄、入口層、管理通道、CDN 的分發與兩個網路之間的連線