安全 · SECURITY
這一頁給會讀設定檔的人。
〈信任〉回答「這些承諾由誰執行、你憑什麼相信」。這一頁回答「它實際怎麼運作、攻擊面在哪、壞掉的時候會怎樣」。數值全部照實列,包括我們知道的弱點。
沒有第三方安全認證,沒有專業滲透測試,沒有漏洞獎金計畫。有的是可以逐行核對的設定,以及一份誠實的攻擊面清單。
01出生資料的完整路徑
請求內容 → 請求範圍的資料模型(request-scoped,不跨請求存活) → 行程鎖底下的同步計算 → 回應 + 計算卷宗 → 你的瀏覽器記憶體 → (只有你按下時)剪貼簿或下載檔案
這條路徑上沒有任何一步寫入磁碟。守衛在請求處理期間替換 builtins.open、io.open 與 os.open;只要出現一次帶寫入旗標的呼叫,測試就失敗。
唯一的資料庫是內建的地名目錄,以唯讀、不可變模式開啟。它不含帳號或出生資料,也不寫入你打過的搜尋字串。
這說的是沒有主動寫入,不是安全抹除。計算期間資料存在於記憶體,之後由執行環境回收。守衛涵蓋的是 Python 層的寫入——原生函式庫的行為不在內。我們不宣稱能從記憶體、swap 或當機傾印中抹除任何東西。
02五個信任邊界
威脅模型把系統切成五個邊界,每一個都假設對面是敵意的。這是內部文件的結構,不是為了這一頁編的。
要誠實說一件事:這份威脅模型成文於實際部署之前。邊界 E 已經有一部分變成現況,文件還沒完全追上。這是我們自己知道的落差,不是被指出來才承認的。
03伺服器日誌的完整欄位
每完成一個請求輸出一行事件。欄位組合必須完全等於下表,多一個少一個都拒絕輸出。
| 欄位 | 允許的值 |
|---|---|
| duration_bucket | 固定分組,不記精確耗時 |
| error_code | 空值,或:ambiguous_local_time_choice_required、bad_request、client_disconnected、compute_capacity_exhausted、conflicting_request_framing、duplicate_json_key、edge_rate_limited、empty_request_body、full_ephemeris_required、house_system_unavailable、internal_server_error、invalid_content_length、json_invalid、method_not_allowed、nonexistent_local_time、not_found、place_catalog_unavailable、place_search_timeout、request_body_not_utf8、request_body_read_timeout、request_body_too_large、request_capacity_exhausted、request_headers_too_large、request_rejected、request_validation_failed、server_error、swiss_ephemeris_error、unauthorized、unclassified、undeclared_host、unknown_api_path、unsupported_media_type、unsupported_method、upstream_unavailable |
| event | 固定字串 |
| event_schema_version | 固定字串 |
| failure_class | 空值,或:AssertionError、AttributeError、Error、IndexError、KeyError、OSError、OverflowError、RecursionError、RuntimeError、TypeError、UnicodeDecodeError、ValueError、ZeroDivisionError、unclassified |
| method | GET/POST/HEAD/OPTIONS/OTHER |
| outcome | success/rejected/error/unknown |
| request_id | 伺服器產生的32字元十六進位字串 |
| request_size_bucket | 固定區間,不記內容 |
| route | 固定分類,不是原始網址 |
| status_code | 0(沒有送出回應:用戶端在回應之前已中斷連線)或 100–599 |
不會出現的東西:請求或回應內容、例外文字、堆疊追蹤、區域變數、查詢字串、完整網址、HTTP 標頭、cookie、authorization、呼叫端提供的請求 ID、IP 位址、User-Agent、referrer、出生時間、時區、地點、卷宗、計算軌跡、匯出內容。
這不是靠事後過濾。事件由固定的建構函式產生,送出前再檢查一次欄位順序、版本、字彙與格式——把未清洗的資料送進輸出端會失敗關閉,該事件不會被輸出。外部送來的 request-id 標頭一律不採用。日誌輸出端本身故障時直接放棄該筆事件,不建立備援輸出端,也不影響你的回應。
04出口:結構上不可能,不只是政策
容器接在一個標記為 internal 的 Docker 網路上。internal: true 的意思是這個網路沒有對外路由——即使有程式想連出去,也沒有路可以走。
在那之上還有兩層:後端不含任何錯誤追蹤、APM、外部分析,也沒有對外的 HTTP 客戶端;相依守衛偵測到這類套件名稱就直接失敗。
前端不載入外部字型、CDN 腳本、第三方 JavaScript 或遠端圖片。地名搜尋走同源請求讀內建目錄,不使用瀏覽器定位、不使用 IP 定位、不呼叫任何線上服務。
相依守衛是針對已審查名單上的名稱所設的迴歸阻擋,不是完整的資訊流分析,不是遞移相依稽核,也不是執行期沙箱。名單外的新途徑仍需人工審查。真正擋住出口的是網路那一層,不是這份名單。
05送到你瀏覽器的標頭
應用程式對每個回應加上這一組;部署時由反向代理以相同的值統一送出,每個標頭只出現一次:
| Content-Security-Policy | default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; form-action 'self' |
| Permissions-Policy | camera=(), microphone=(), geolocation=() |
| Cross-Origin-Opener-Policy | same-origin |
| Cross-Origin-Embedder-Policy | require-corp |
| Cross-Origin-Resource-Policy | same-origin |
| Referrer-Policy | no-referrer |
| X-Content-Type-Options | nosniff |
| Cache-Control | API 與錯誤回應:no-store;頁面:no-cache(每次使用前向伺服器確認);網址帶內容版本的樣式、程式與圖示檔:public, max-age=31536000, immutable;網址帶其他查詢參數:no-store |
geolocation=() 是刻意的:連瀏覽器都被告知這個頁面不得使用定位,所以「我們不用定位」不只是承諾,是瀏覽器層級的封鎖。frame-ancestors 'none' 讓這個站不能被嵌進別人的 iframe。
no-store 讓 API 回應與錯誤回應不進任何快取——出生資料只出現在這些回應裡。這一條由反向代理自己守住底線:/api 底下的所有回應、所有錯誤回應(4xx/5xx)、代理自己產生的回應(例如限流的 429),以及沒有帶快取指示的回應,一律送 no-store,即使應用程式出錯要求快取也一樣。
頁面與樣式、程式檔可以被瀏覽器暫存。頁面每次使用前都會向伺服器確認是否變過;網址帶內容版本(?v=,由檔案內容的雜湊算出)的樣式、程式與圖示檔,內容一變網址就變,所以可以長期暫存;版本不符或網址帶其他參數時不快取。/.well-known/security.txt 由代理直接提供,同樣每次確認。代價要說清楚:共用同一台電腦的人,可以從瀏覽器快取看出這台電腦開過本站——瀏覽紀錄本來就看得出這件事;快取裡沒有出生資料,也沒有計算結果。
反向代理另外加上 Strict-Transport-Security: max-age=31536000。公開測試(Public Alpha)期間,回應不帶 X-Robots-Tag:本站開放搜尋引擎收錄;個別頁面是否收錄,由頁內 robots 標記與 robots.txt 決定,兩者由同一份頁面清單產生。
前端不使用 localStorage、sessionStorage、IndexedDB、Cache Storage 或 beacon。敏感欄位標 autocomplete="off"——那是給瀏覽器的請求,不是它必須遵守的保證。
06入口的數值上限
| 計算請求限流 | 30 r/min/IP,burst 10,nodelay → 429 |
| 地名查詢限流 | 與計算請求共用同一個 30 r/min/IP 額度,burst 5 → 429 |
| 靜態資源限流 | 600 r/min/IP |
| 同時連線數 | 另有 limit_conn 上限 → 503 |
| 請求主體上限 | 16 KB |
| 年、月、日、時、分、秒 | 各有明確值域,超出即 422 |
| 緯度/經度 | ±90 / ±180 |
| UTC 位移 | −14 ~ +14 |
| 計算並行度 | 行程鎖,等待有預算上限,逾時即拒絕 |
處理濫用期間,以上數值可能暫時調低(計算與地名查詢 6 r/min、靜態資源 300 r/min、同時連線 16),濫用結束後恢復。
限流用的是你的 IP,這件事值得說清楚。反向代理必須看得到你的位址,否則封包送不回去。它拿那個位址做限流計數,但不寫進日誌,也不轉送給應用程式——應用程式從頭到尾沒有機會知道你的 IP。
應用程式不會因等待佇列預算到期而主動取消已取得計算鎖、正在執行的同步星曆計算;worker、容器或主機故障仍可能中斷請求。
07容器與主機的實際數值
| read_only | true (根檔案系統唯讀) |
| tmpfs | /tmp rw,noexec,nosuid,nodev,size=64m (唯一可寫處,容器結束即消失) |
| cap_drop | ALL |
| security_opt | no-new-privileges:true |
| mem_limit / memswap_limit | 512m / 512m 兩者相等=容器 swap 停用 |
| pids_limit | 192 |
| cpus | 1.0 |
| ulimits.nofile | 65536 / 65536 |
| networks | armillary-internal (internal: true) |
| init | true |
反向代理:存取日誌關閉;不轉送原始 IP 與各種轉送標頭給應用程式;線上的 API 文件關閉。作業系統:系統日誌短保留上限;停用 swap 與當機傾印。主機供應商的自動備份與快照關閉,屬營運者設定、人工確認,主機本身讀不到、沒有自動檢查。
存取控制:公開測試階段不設存取憑證,任何人都可以直接使用;先前 Private Alpha 階段發給受邀者的代理層憑證已隨該階段撤除。沒有帳號資料庫、沒有自助註冊、沒有 email 名單、沒有忘記密碼資料庫。濫用防護靠上一節的限流與大小上限。
上列容器與代理設定可在版控的 compose 與 nginx 設定檔中逐行核對。公開版本的部署驗收會在主機上確認 HSTS、nosniff、no-referrer 存在、X-Robots-Tag 不存在,並逐類核對上述快取指示(頁面每次確認、帶版本的檔案長期暫存、API 與錯誤回應 no-store)。但主機組態可能在我們不知情的情況下改變——「已在主機驗證」只描述具名時間點,不自動代表現況。
08壞掉的時候會怎樣
未預期的例外一律回傳固定錯誤代碼與伺服器產生的請求 ID,不回顯例外原文。星曆函式庫的錯誤回傳固定代碼,不回顯原始錯誤文字——那可能含本機路徑。
比較難的是回應已經開始送出之後才失敗。那時狀態碼已經在線路上,不能安全改寫。做法是:保留實際送出的狀態碼、把事件記為錯誤,並阻止原始例外進入框架自己的錯誤日誌。串流與背景工作的失敗路徑都有真實伺服器的反證測試。
匯出時 renderer 或 Blob 建立失敗,匯出面板必須顯示使用者看得見的錯誤,不能只留 console 或讓按鈕看似沒反應。
09兩個做過的判斷
控制清單看不出判斷力。下面兩件事仍屬於目前網頁產品,而且都包含我們拒絕外部建議一部分的理由。
不可見字元:黑名單不會收斂,所以不用黑名單
地名欄位原本用列舉式 regex 擋不可見字元。實測發現 U+200B(零寬空格)、U+2028、U+2029、U+061C 全部漏過。
修補不是「再補幾個字元」——改以 Unicode general category 表達:Cc、Cf、Zl、Zp。這涵蓋上述全部,也涵蓋未來 Unicode 版本新增的格式字元。
但我們拒絕了建議的一部分。報告主張把 ZWNJ(U+200C)與 ZWJ(U+200D)一併列為危險。那兩個字元在波斯文、阿拉伯文與印度系文字中是正字法必需的,用來選擇連寫或不連寫字形——封鎖它們是拒絕合法地名,不是拒絕敵意輸入。而且它們無法像 bidi 控制碼那樣重排已渲染的標籤,支撐這條邊界的顯示欺騙論證不及於它們。兩者明確豁免。
另外,錯誤訊息回報 U+XXXX 而不回顯該字元本身——避免把一個不可見或會翻轉方向的字元,放進一個將被其他介面渲染的字串。
「每執行緒一次」與「每請求一次」在原始碼裡分不出來
星曆初始化的意圖是每執行緒一次,程式卻寫成每請求一次——兩者在原始碼裡無從分辨,下一個讀者無法判斷哪個才是意圖。改用 threading.local 表達。
被拒絕的替代方案:用「已初始化的 thread ident 集合」會讓測試比較好寫,但 thread ident 在執行緒結束後會被重用,新執行緒會被誤判為已初始化——那正是這個機制要防的安靜錯誤。保留正確的機制,改讓測試配合。
10威脅登錄與嚴重度
current威脅模型按browser、proxy、container、host/provider與export等trust boundaries記錄威脅、控制與重驗觸發條件。舉三個實際的:
| 主體 | 攻擊故事 | 目前控制 | 剩餘風險 |
|---|---|---|---|
| 應用程式日誌 | 開發者對request、model repr或標頭做debug log | 封閉式事件schema、固定錯誤與真實server canary | Python沒有資訊流型別系統;logging或middleware改變時須重驗 |
| 星曆全域狀態 | 並行請求汙染Swiss Ephemeris的行程全域狀態 | per-process鎖、模式與並行測試 | native call topology或worker model改變時須重驗 |
| 瀏覽器/主機/供應商 | swap、當機傾印、瀏覽器還原、備份或provider保存資料 | 應用程式不寫出生資料;主機不開 swap;供應商備份與快照關閉屬營運者設定、人工確認 | 不宣稱安全記憶體抹除;browser與provider retention仍是剩餘風險 |
嚴重度用影響 × 可利用性,不是只看 CVSS 字樣。分級裡有一類值得特別指出:
宣稱超過可證明的邊界——即使尚無 exploit——也必須在公開文案前修正。換句話說,在這個專案裡,「話講得太滿」本身就登記為一種安全缺陷,和可利用的漏洞放在同一張表上。
11我們最希望有人打破的地方
下面列出一組可直接拿來挑戰目前設計的代表問題。公開在這裡的理由很簡單:一份只列出自己擅長之處的安全頁沒有用。
- 編碼過的路徑、absolute-form URL、無效方法或標頭、chunked body,能否突破路由/方法/大小的縮減器?
- ExceptionGroup、串流回應、背景工作、回應序列化失敗是否會外洩?CancelledError、KeyboardInterrupt、SystemExit、GeneratorExit 是否仍正確向上傳播?
- Python logging 重設、handler 錯誤或重複 import,是否可能退回 root logger 並輸出未清洗的資料?
- 執行期寫檔守衛是否漏掉 Path.write_*、原生寫入、快取、tempfile 或 fork?
- 是否存在請求模型、全域變數、closure、快取或背景工作跨請求保留出生資料?
- 清除之後殘留的 detached DOM handler、Blob URL、遲到回應或 back-forward cache,是否仍能還原資料?
- 卷宗裡的證據引用,是否可能因測試改名或未重跑閘門而變成誤導?
current threat model與原始碼一起公開;它按實際trust boundaries維護,不以本頁手抄的編號或數量作為currentness依據。
12相依與供應鏈
生產相依以帶雜湊的鎖定檔固定,另有軟體物料清單(SBOM)列出實際組成:Gate D 由映像以 syft 產生,與 lock 比對,保存在發佈收據中,與鎖定檔不一致即失敗。第三方原始碼封存檔、雜湊、授權與上游位置另有清單。
相依套件、星曆檔與時區資料庫三者性質不同,各有自己的更新節奏與驗證步驟,不共用一套流程。星曆檔與恆星表的雜湊由測試直接驗證——有人替換了資料檔,測試會失敗。
這些是可查的程序與清單。它們不是供應鏈稽核,也不是對上游套件本身的安全保證。
13我們自己知道的攻擊面
這一節列的是真的可以拿來攻擊我們的東西,不是免責聲明。
- 沒有專業滲透測試。應用層有具名外部技術審查與多工具對抗測試,每一輪的範圍與結果逐次留存。技術審查與滲透測試不是同一種證據。主機層另有供應商依合約每年執行的入侵測試(見〈信任〉層五)。
- 沒有內容傳遞網路,也沒有應用防火牆。目前架構就是沒有這兩個元件。限流與連線上限在反向代理,僅此而已。
- 沒有任何單一相依守衛算是完整控制。相依安全由直接輸入、雜湊鎖定的解析、SBOM、安裝集合回讀與執行期出口限制共同承擔;其中任何一項單獨都不足以宣稱安全。
- 測試是設計期的迴歸證據,不在你每次計算時重跑。卷宗裡的證據欄位是指向測試的指標,不是「這一次執行過並通過」的紀錄。
- 線上跑的版本不必然等於公開程式庫的最新版。要確認你正在用哪一版,看卷宗裡的版本識別碼——不要從最新提交推定,我們自己也不這樣推定。
- 隱私文案的依據是外部技術審查與主機上的live privacy驗證。技術審查與主機實測是不同證據,只涵蓋各自驗過的那一層,不互相代替,也不自動延伸到未涵蓋的層。
不能從「某個元件不存在」推論出「因此安全」。沒有 CDN 表示沒有 CDN 的攻擊面,也表示沒有 CDN 的防護。
14通報安全問題
privacy@projectarmillary.com
安全通報與隱私請求共用同一個信箱。這不是把兩件事混為一談——是目前只有這一個地址真的收得到信。列出一個實際收不到信的專用別名會比沒有更糟,所以我們只寫真的那一個。
請描述重現步驟,不要附上任何人的真實出生資料——用合成的日期時間就足以說明問題。
我們目前沒有安全港政策,因此使用條款不承諾超出法律範圍的測試授權。這是現況陳述,不是拒絕——歡迎善意通報。我們也沒有漏洞獎金計畫。
這一頁的每一個數值
都在版控的設定檔裡。
承諾由誰執行、以及你憑什麼相信這些話,在〈信任〉。