在您自己的應用程式裡預覽一份不受信任的 PDF,是一項關於「執行」的決定,而真正要緊的不是檢視器的外觀框架,而是這個窗格自己拒絕去做哪些事。別把檔案寫到磁碟。別讓它的連結呼叫外部殼層。別給它的附件一條路徑。一份惡意文件造成的傷害,大多不是來自引擎漏洞,而是來自檢視器拿攻擊者提供的輸入去做那些再普通不過的事:打開一個指向 UNC 共用、會外洩 NTLM 憑證的 file:// 連結;在暫存目錄裡留下一份預備副本;把內嵌酬載複製到某個檔名字串指定的任何地方。PDFium Component 是為 Delphi、C++Builder 與 Lazarus 提供的原始碼版 PDF 檢視器,它把相關的開關放在您搆得到的地方:一個在載入時就掐死指令碼的旗標、幾個您可以否決的連結點擊事件、走您自己程式碼的附件存取,以及您讀得到的權限位元。下面的順序,跟著一份文件從落地的那一刻,走到使用者在裡面點下某樣東西的那一刻
預覽窗格的威脅模型
請對「安全預覽」到底買到了什麼誠實以對。不管您做什麼,呈現器都會剖析不受信任的位元組,而引擎自身的強化就是您腳下的地板。地板之上的一切都是應用程式政策:指令碼是否初始化、連結點擊會做什麼、內嵌檔案能不能碰到磁碟、剪貼簿與印表機是門還是牆。有一樣東西請早早劃掉,就是引擎的 FPDF_SetSandBoxPolicy 開關。引擎的限制大多是編譯進去的,這個開關實務上改變不了什麼,而把您的隔離故事寄託在它身上,只會製造一種「已經做了什麼」的錯覺。當輸入真的懷有敵意時,例如一個公開的上傳入口,唯一真實的隔離,是在另一個低權限行程中呈現,再把點陣圖送給 UI。行程內的旗標是政策,不是圍堵
有兩個面向特別容易被忘記,正因為從來沒有任何點擊碰到它們。第一個是暫存檔。如果您的流水線在預覽前把收進來的文件預備到磁碟上,那些預備副本會活得比工作階段更久,除非有東西可驗證地把它們刪掉,而一份「從暫存目錄還救得回來」的檔案,已經悄悄擊敗了窗格本身執行的每一項控制。請改用 TPdfStreamAdapter 從記憶體載入,讓那些惡意位元組從來沒有屬於自己的路徑。第二個是剪貼簿。一個允許選取並複製的預覽,早就把文件匯出去了,一次一個畫面,而任何連結攔截都抓不到那件事
在載入時掐死 JavaScript,而不是在 UI 上
PDFium Component 中的文件 JavaScript,只會與表單填寫環境一起初始化。因此,以 FormFill := False 載入,是從根部停用指令碼,而不是壓抑它的症狀:
procedure TPreviewPane.LoadUntrusted(const FilePath: string);
begin
Pdf.FileName := FilePath;
Pdf.FormFill := False; // 沒有表單環境,因此也沒有 JavaScript 引擎
Pdf.Active := True;
FPermissions := Pdf.Permissions; // 原始旗標字組;所有位元皆設 = 不受限
end;
這筆取捨是真實的,該寫進您的規格。停用表單填寫之後,正當的 AcroForm 互動與驗證指令碼也一起沒了;欄位會以上次儲存的外觀呈現,但編輯不了。對預覽窗格而言,這通常是對的選擇,因為預覽的意思是看,不是填。但如果同一個視窗兼作受信任內部文件的表單填寫介面,答案是兩條載入路徑,中間夾一個明確的信任判定,而不是一條路徑配一組折衷設定——那組設定對惡意情境太鬆、對受信任情境又太緊。那道切分中屬於表單填寫的那一側有它自己的陷阱,涵蓋於表單欄位導覽與外觀重建一文
連結:預設處理常式會呼叫外部殼層
放著不管,連結點擊會直接交給作業系統。檢視器預設的 LinkOptions 包含 loAutoOpenURI,那正是那樁蓄勢待發的 file:// 對 UNC 共用外洩。有兩個事件構成咽喉點:頁面文字中偵測到 URL 時的 OnWebLinkClick,以及帶著 URI 或啟動動作之連結註記的 OnAnnotationLinkClick。在做任何判斷之前,先在兩者中無條件把 Handled := True 設好,然後只重新放行政策允許的部分。作為第二層防護,面對惡意輸入時請從 LinkOptions 中拿掉 loAutoOpenURI,並確認預設關閉的 loAutoLaunch 絕不會經由複製來的設定檔悄悄溜回來:
procedure TPreviewPane.PdfViewWebLinkClick(Sender: TObject;
const Url: WString; var Handled: Boolean);
begin
Handled := True; // 絕不讓它落回預設的殼層行為
if (AnsiStartsText('https://', Url) or AnsiStartsText('http://', Url))
and HostIsAllowed(Url) then
OpenInBrowser(Url)
else
FAudit.LogBlockedLink(FDocumentId, Url);
end;
有兩個細節決定這道防護是否真的站得住。第一,協定檢查必須是在任何剖析之前、針對原始字串做的前置詞檢查,因為 file://、UNC 路徑與各種冷門協定,恰恰就是那些會弄掛天真 URL 剖析器、或從過度熱心正規化的剖析器手中溜走的值。第二,每一次封鎖都要連同文件身分一起記錄下來。零星幾條被封鎖的 file:// 連結是背景雜訊;短時間內橫跨許多收進來的文件出現一整批,就是一起資安事件,而您的資安團隊寧可從您這裡聽到,也不想從別的地方聽到
附件:副檔名政策,與那個不是您挑的檔名
AttachmentCount 搭配 AttachmentName[] 屬性,會在任何東西碰到磁碟之前,先告訴您這個容器裝了什麼。這裡有兩項各自獨立的控制,而只有其中一項是顯而易見的。顯而易見的那項是型別政策:一份允許匯出的副檔名允許清單。細微的那項是:附件名稱就是攻擊者可控的資料,沒有例外。一個像 ..\..\Startup\update.exe 這樣的內嵌名稱,會把一次隨手儲存變成路徑穿越,把一個可執行檔丟進 Windows 會在登入時執行的資料夾。元件透過 Attachment[] 把酬載以位元組交給您,並讓您的程式碼挑選路徑,所以請用一個消毒過的基本檔名組出那條路徑,絕不要用原始的內嵌字串:
procedure TPreviewPane.ExportAttachment(Index: Integer; const TargetDir: string);
var
RawName, SafeName, Ext: string;
Data: TBytes;
begin
RawName := string(Pdf.AttachmentName[Index]);
SafeName := ExtractFileName(RawName); // 剝掉任何路徑成分
Ext := LowerCase(ExtractFileExt(SafeName));
if not FAllowedExt.Contains(Ext) then // 允許清單,不是封鎖清單
raise EPreviewPolicy.CreateFmt('Attachment type %s blocked by policy', [Ext]);
Data := Pdf.Attachment[Index]; // 以原始位元組取得內嵌酬載
TFile.WriteAllBytes(
IncludeTrailingPathDelimiter(TargetDir) + SafeName, Data);
end;
請採允許清單這個方向。一份列出「危險」副檔名的封鎖清單,會在有人把您沒聽過的副檔名武器化的那一天輸掉這場賽跑;而一份只放行 .pdf、.png 與 .csv 的允許清單,是故障封閉的
加密權限實際上承諾了什麼
ISO 32000-1 的標準安全處理器編碼了列印、內容複製與修改的權限旗標,而文件開啟後,Permissions 與 UserPermissions 屬性會把它們以原始位元遮罩呈現出來。ISO 32000-1 表 22 定義了那些位元,而未加密的檔案會回報每個位元都已設定。請讀取它們,並在您的命令層尊重它們,但要清楚它們是什麼。對於一份以擁有者密碼加密、使用者密碼為空的文件,內容在開啟時就完全解密了,那些旗標是對相容檢視器的請求,不是強制執行機制。這件事有兩個後果,而且它們拉往相反的方向。絕不要把權限旗標當成使用者收到之文件的安全屬性呈現給他們,因為那不是。與此同時,即使一般複製(位元 5)被拒絕,也請尊重無障礙擷取位元(位元 10);螢幕閱讀器的存取權在權限模型中是刻意被單獨劃出來的,因為「複製關掉了」就把它一起剝掉,只會弄壞輔助科技,卻換不到任何安全性
請在命令層級落實被拒絕的動作,而不是靠隱藏工具列按鈕。Ctrl+C、右鍵選單與拖曳選取全都繞得過工具列;而複製命令內部的一次權限檢查,什麼都繞不過
對於確實需要使用者密碼的文件,請在 Active := True 之前指派 Password,並把那個值當成它本來的祕密看待:每個工作階段從您的憑證存放區取用它、別讓它進記錄檔與當機報告,也絕不要把它與文件存在一起。一個「為了方便」而快取密碼的預覽窗格,已經悄悄變成一個密碼資料庫,卻沒有密碼資料庫該有的任何保護
列印值得有它自己的決定,而不是沿用複製規則最後落在哪裡。實體列印本依定義就無法稽核,然而完全封鎖列印,往往會把使用者推向螢幕擷圖,而那在每個面向上都更糟。常見的折衷是允許列印,但在每一頁蓋上使用者身分與時間戳記,並在列印命令內部落實。只要對它抱持正確的期待就好:浮水印是嚇阻與歸屬,不是預防
收件階段本來就該告訴您的事
當檔案帶著一份已經備好的檔案卷宗現身時,預覽窗格能做出更好的決定:加不加密、有沒有 JavaScript、附件清點、表單型別。那趟檢查該放在檢視器的上游,而打造 PDF 收件審閱工作臺一文中的模式,產出的正是預覽政策想消費的那些旗標。收件階段標記為高風險的檔案,會自動走強化路徑;例行文件則保有它們的便利。請把這兩個階段綁在同一個共用的政策物件上,而不是兩個設定畫面——不管您第一次寫得多小心,到第二個版本它們就會漂開
行程內與行程外的界線落在哪裡,取決於誰寄檔案給您。對一般的商務收件而言,寄文件來的人是已知的,只是粗心,那麼關掉指令碼、攔下連結的行程內預覽,是一條站得住腳的門檻。對匿名的公開上傳而言則不是,而且再怎麼設定行程內旗標也變不成;請把那些在另一個低權限工作行程中呈現,只把點陣圖送給 UI,這樣引擎的瑕疵付出的代價是一個工作行程,而不是整個主應用程式。請刻意做出那道切分,並寫下每條收件路徑落在哪一類,因為猜錯的代價是不對稱的
授權、與安全性相關的 API 介面,以及一份強化檢視器的示範,都在產品頁面上:PDFium Component