💡 完整的可運作範例可在 GitHub 上取得:
load-untrusted-documents-safely-python

以前的做法很痛苦

您只寫了三行程式碼就能渲染上傳文件的縮圖。它們長這樣,而且看起來沒問題:

with signature.Signature(upload_path) as sign:
    save_page_preview(sign, thumbnail_path)

在 GroupDocs.Signature 26.9 之前,這幾行程式碼會抓取文件指向的每一個位址。Word 檔案可以保存它本身不包含的圖片——檔案只存一個 URL,任何開啟它的程式都會下載該 URL。這在桌面環境是個功能,但在接受上傳的伺服器上,意味著傳送檔案的人決定了您的基礎設施會向哪些位址發出請求。

此攻擊有名稱,稱為「伺服器端請求偽造」(SSRF),且有三種常見形態。內部位址在互聯網上不可達,但從您的伺服器卻可達,於是精心製作的文件可以讓您的服務去抓取 http://169.254.169.254/ 或本機的管理端點。UNC 路徑會促使 Windows 主機向外部驗證,將憑證交給攻擊者控制的伺服器。連結到永遠不回應的主機則會讓載入執行緒卡住直至逾時,這是一種以看似無害的文件耗盡工作執行緒池的廉價手段。

這裡並沒有文件庫的 bug。遵循連結是檔案格式的要求。令人不安的是,這種「順從」是預設行為,且在程式碼審查時不會被標記。

有更好的方法

安全的文件載入是 GroupDocs.Signature 在 Python 中的行為,它會拒絕發出這些請求。從 26.9 版開始,LoadOptions.skip_external_resources 預設為 True,因此相同的三行程式碼現在不會抓取任何內容,而是以佔位圖取代連結的圖片。

這是一個預設值的變更,而非新功能——屬性本身早已存在。26.9 版改變的是當程式碼未明確設定時它指向的行為,這正是大多數服務唯一會使用的設定。

新方法:三種載入模式

步驟 1 - 為所有不受信任的內容保留預設

完全不使用 LoadOptions:

with signature.Signature(source_path) as sign:
    return save_page_preview(sign, preview_path)

不會發出任何請求。預覽會比原本小,這個大小差異是最直觀的證明,顯示沒有請求離開機器。

步驟 2 - 為您實際擁有的主機加入白名單

許多文件會連結到合法的資源:公司 CDN、內部影像伺服器、範本庫。只允許這些,其他全部拒絕:

load_options = LoadOptions()
load_options.whitelisted_resources = [trusted_address]

with signature.Signature(source_path, load_options) as sign:
    return save_page_preview(sign, preview_path)

匹配規則需要特別注意。它是對資源位址執行不區分大小寫的子字串測試,這會讓短片段變得危險:github 會同樣匹配 github.attacker.example/payload.png。請使用 scheme、host 與 path——此範例將 raw.githubusercontent.com/groupdocs-signature/ 加入白名單。

步驟 3 - 故意允許所有內容

仍可使用 26.9 之前的行為:

load_options = LoadOptions()
load_options.skip_external_resources = False

適用於您自己的應用程式產生的文件。需要留意的是,已棄用的 load_external_resources 屬性極性相反,因此 skip_external_resources = False 等同於 load_external_resources = True。若直接把舊屬性的值複製過來,會在不顯示錯誤的情況下把安全姿態顛倒。

並排比較:變更前後

相同文件、相同程式路徑、三種載入政策。以下是樣本 Result/ 資料夾中提交的檔案大小,您可以自行驗證而非僅憑信任:

載入模式 預覽大小 外部請求
預設 (26.9 及之後) 16,435 bytes none
白名單主機 51,738 bytes one, to the allowed address
所有資源 (26.9 之前的預設) 51,738 bytes one per linked resource

差異中的 35,303 bytes 來自連結的圖片。直到看到這兩個數字並排,我才信任此設定,亦建議您如此:讀回屬性值能告訴您實際配置了什麼,而不是程式實際執行了什麼。

什麼算是外部資源?

範圍比大多數人預期的還要窄,這也是升級通常不會造成太大衝擊的原因。外部資源包括連結圖片(而非嵌入式圖片)、INCLUDEPICTURE 欄位、簡報與試算表中的連結圖片,以及 SVG 參考的影像與樣式表。嵌入式內容不受影響,因為它已經在檔案內部,渲染時不需要任何請求。

這個區別就是完整的安全邊界。文件只有在儲存位址而非位元組時才會讓您的伺服器發出請求,因此對任何語料庫而言,只要檢查有多少檔案是「連結」而非「嵌入」即可。若全部都是嵌入式,新的預設不會產生任何成本,您可以直接升級而無需進一步閱讀。

真實案例:上傳後簽署

預設變更的典型情境。外部文件抵達後,需要在其上加上簽章:

with signature.Signature(source_path) as sign:
    options = QrCodeSignOptions("Approved by GroupDocs.Signature")
    options.encode_type = QrCodeTypes.QR
    options.left = 400
    options.top = 50
    options.width = 120
    options.height = 120

    result = sign.sign(output_path, options)

載入、簽署或儲存過程中不會請求任何外部資源。簽署後的輸出仍保留原有連結,使用者日後在 Word 中開啟時,仍會在自己的機器上解析圖片。跳過外部資源是伺服器端的政策,而非對文件的編輯——正是這點讓它在代他人處理檔案時安全可靠。

升級時還會改變什麼?

對大多數服務而言,沒有可見變化,這點值得明確說明,因為若安全預設會在所有地方改變行為,升級審查將難以通過。簽署、驗證與搜尋功能皆未受影響。唯一例外是預覽畫面:以前會顯示連結圖片,現在會顯示佔位圖——這正是預期的行為。若您的主機在白名單內,請自行加入白名單;若不在,則保持阻擋。

另需特別說明的是 SVG。SVG 可以透過 URL 參考影像與樣式表,這些參考同樣屬於外部資源,且 SVG 是常見的上傳格式與 SSRF 向量。接受 SVG 大頭貼並在伺服器端渲染的服務,正是此變更所保護的典型系統。

一個 Python 細節:預覽如何寫入

PreviewOptions 需要兩個串流工廠而非路徑,普通的 Python 可呼叫函式即可:

def create_page_stream(page_data):
    return open(preview_path, "wb")

def release_page_stream(page_data, page_stream):
    page_stream.close()

preview_options = PreviewOptions(create_page_stream, release_page_stream)
preview_options.preview_format = PreviewOptions.PreviewFormats.PNG
sign.generate_preview(preview_options)

第一個函式為每頁建立串流,第二個則負責釋放。範例文件只有一頁,因此只會寫入一個檔案;若是多頁輸入,請在檔名中加入頁碼,否則每頁都會覆寫前一頁。

結論

預設已改為必須明確決定才會執行風險行為,而安全的行為則不需要任何設定。對不受信任的輸入保留預設,對您自己的主機採取嚴格白名單,並記住簽署本身根本不需要網路。

如果想要比檔案大小更強的檢查方式,可將測試文件指向您控制的主機,並在預覽執行時觀察該主機的存取日誌。檔案大小只能告訴您是否有位元組到達;存取日誌則告訴您是否真的發出了請求,兩者在關鍵情況下會有明顯差異——例如白名單主機恰好無法連線,僅從輸出看會與被阻擋的情形相同。

將範例對自己的文件執行一次,只需約一分鐘,即可透過三個檔案大小,精確了解您的服務在代表上傳者抓取了哪些資源。

其他資源