技術文章

在 Delphi 中使用 PDFium Component 建置 PDF 接收審查工作台

PDF 接收審查工作台是一個只有一項工作的小程式:在任何下游程式被允許接觸檔案之前,先檢查每個檔案。為了完成這項工作,它必須在一次作業中組裝少數幾種功能。它開啟檔案(不信任它)、讀取檔案聲稱的關於自身的資訊、尋找會誤導天真的擷取器或夾帶攻擊的內容、決定是否根本有可擷取的文字,然後根據其發現將檔案路由到一個佇列中。略過檢查的話,失敗將會是無聲無息的:一個以擁有者密碼加密、內部包裝著 XFA 表單的 PDF,會像空字串一樣順利通過文字擷取器,被編製成空白檔案的索引,然後沒人會注意到,直到下游某人去尋找從未被讀取過的內容。PDFium Component 是 Delphi、C++Builder 與 Lazarus 適用的原始碼 VCL/LCL 檢視器與檢查函式庫,它暴露了這個工作台所需的內部檢查呼叫。以下各節將逐步解說哪個呼叫可回答哪個問題,以及在兩個地方,明顯的呼叫會給出自信滿滿卻錯誤的答案

檔案路由前要回答的五個問題

撇開網格與縮圖列不談,接收分流可簡化為五個問題:

  • 檔案到底能不能開啟,而且是用哪個密碼?
  • 它聲稱自己是什麼:標題、作者、建立日期?
  • 它是否夾帶主動或危險內容,例如 JavaScript、XFA 表單或內嵌檔案?
  • 是否有可擷取的文字,還是這是一份準備進行 OCR 的掃描檔?
  • 考量上述所有情況,哪個佇列會接收它:直接處理、人工審查,還是隔離?

每個問題都對應到一兩個 PDFium Component 呼叫。其中兩個對應具有危險的死角,這是我在生產環境中必須除錯的大多數路由錯誤檔案的原因。檔案中繼資料存在於兩個可能互不一致的不同地方,而且加密不一定會阻止檔案被開啟

低成本開啟:關閉表單填寫,不渲染任何頁面

分流應該是成本最低的開啟方式。在 Active := True 之前設定 FormFill := False,是告訴元件完全略過表單填寫環境。這會縮短載入時間,而且(對於來源不明的檔案來說同樣重要)它能防止任何檔案層級的 JavaScript 初始化。以下使用的檢查屬性都不需要渲染頁面,因此分流階段絕不需要產生任何一個點陣圖

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // no form environment, no JavaScript init
    Pdf.Active := True;        // failure is silent: Active simply stays False

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // damaged file or user-password lock
      Exit;                    // the finally block still runs
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // never leak the instance on a malformed file
  end;
end;

指派之後的檢查不是可有可無的,它是一個檢查而不是一個例外處理常式是有原因的。當引擎無法載入檔案時,元件會吞噬內部的 EPdfError 並讓 Active 保持在 False,而不是傳播它。等待例外的程式碼會開心地從從未開啟的檔案中讀取 PageCount。如果拒絕工作流程需要引擎實際的錯誤文字,請將檔案讀入位元組陣列,並呼叫接受 TBytesLoadDocument 多載方法;那條路徑確實會引發帶有訊息的 EPdfError,包括密碼情況。try..finally 仍然有其作用。接收服務會在無人看管的情況下執行數週,任何後續的例外都不能洩漏 TPdf 執行個體,或是持有一個會讓重試路徑絆倒的鎖定

吞吐量很少成為瓶頸。在停用表單填寫且無渲染的情況下,分流開啟主要受限於 I/O,單一工作程式可以輕鬆地從本機磁碟每秒檢查數個檔案。如果接收量真的超出了單一工作程式的負荷,請以檔案而不是以檢查項目來劃分工作。這五個問題共用一次開啟,將它們分散到不同處理程序中,會使最昂貴的步驟倍增,而不是攤提它

中繼資料存在於兩個地方,且它們可能互不一致

ISO 32000-1 定義了檔案中繼資料的兩個歸宿:檔案資訊字典(第 14.3.3 條款)與附加到型錄的 XMP 封包(第 14.3.2 條款)。TitleAuthorSubjectCreationDate 屬性讀取 Info 字典,使用 MetaText[] 讀取任何其他鍵值,並使用 DecodeDate 來解析 D:YYYYMMDD... 日期字串。問題在於現代的產生器越來越常只寫入 XMP,這是 ISO 32000-2 透過在 PDF 2.0 中棄用大多數 Info 字典鍵值而使其正式化的一個方向。在接收工具中的症狀很具體。您的工作台顯示空的標題,而 Adobe Acrobat 卻顯示了一個,因為 Acrobat 退而求其次使用了 XMP 封包內的 dc:title,而這是 Info 字典屬性永遠不會碰到的

procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // Info dictionary value
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // raw PDF date string ("D:2026...")

  // An empty Info title does not mean the document is untitled. The
  // component does not expose the XMP packet, so probe the raw file
  // bytes for the dc:title element before trusting the blank.
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

即便上述粗糙的子字串探測也能發揮作用:「存在中繼資料,但不在舊版工具尋找的地方」,對於任何依據標題或作者建立索引的封存管線來說,這是一個與路由相關的事實。如果您的下游索引只讀取 Info 字典,以這種方式標記的檔案將會默默地變得無法搜尋

無論如何都能開啟的加密檔案

一個已加密的檔案不一定會無法開啟。標準的安全處理常式(ISO 32000-1 第 7.6.3 條款)區分了需要用來開啟檔案的使用者密碼,以及僅僅用來控管列印和複製等權限的擁有者密碼。很大一部分「受保護」的商業檔案是使用擁有者密碼和一個 空白 的使用者密碼進行加密的。它們可以在沒有提示的情況下開啟,完全解密,並依賴檢視器自願遵守權限標誌。那是政策,而不是保護,您的接收狀態應該反映出這個差異

在成功開啟後偵測加密需要一次引擎呼叫加上一個後備方案。FPDF_GetSecurityHandlerRevision(Pdf.Document) 會為未受保護的檔案傳回 -1,否則會傳回處理常式的修訂版本;而 Pdf.Permissions 傳回任何不是所有位元都設定的 $FFFFFFFF 遮罩的值,就是一個佐證訊號。對於真正被使用者密碼鎖定的檔案,在設定 Active := True 之前先指派 Password;如果開啟仍然失敗,請將檔案路由到一個受阻擋的狀態,透過安全通道向發送者請求憑證,而不是盲目重試。另外,請抗拒將「加密」視為自動隔離的誘惑。在大多數檔案繁重的產業中,已加密但可開啟的檔案是正常情況,而不是可疑情況

主動內容:JavaScript、XFA 與內嵌檔案

有三項發現應該始終影響路由決策。首先,JavaScript:OnUnsupportedFeature 事件會在引擎遇到 XFA 或 3D 內容等結構特徵時回報它們,但它不會偵測 JavaScript。請改為檢查 JavaScriptActionCount,並將非零結果視為主動內容。其次,XFA:當 FormType 傳回 ftXfaFull 時,可見頁面通常只不過是 XFA 範本的渲染結果,傳統的文字擷取將會看到樣板文字,而不是填寫的值。第三,附件:PDF 是一種容器格式,AttachmentCount 可以告訴您這份檔案是否夾帶了乘客

procedure CollectRiskSignals(Pdf: TPdf; var Rec: TIntakeRecord);
var
  i, PageNo: Integer;
  Ext: string;
begin
  Rec.IsEncrypted := Assigned(FPDF_GetSecurityHandlerRevision) and
    (FPDF_GetSecurityHandlerRevision(Pdf.Document) <> -1);
  Rec.HasForms := Pdf.FormType <> ftNone;
  Rec.IsXfa := Pdf.FormType = ftXfaFull;
  Rec.HasJavaScript := Pdf.JavaScriptActionCount > 0;

  // AnnotationCount is a per-page property; walk the pages to total
  // it. Loading a page object renders nothing, so this stays cheap.
  Rec.Annotations := 0;
  for PageNo := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := PageNo;
    Inc(Rec.Annotations, Pdf.AnnotationCount);
  end;

  Rec.Attachments := Pdf.AttachmentCount;

  for i := 0 to Rec.Attachments - 1 do
  begin
    Ext := LowerCase(ExtractFileExt(string(Pdf.AttachmentName[i])));
    if (Ext = '.exe') or (Ext = '.js') or (Ext = '.vbs') or (Ext = '.dll') then
      Include(Rec.Flags, ifDangerousAttachment);
  end;
end;

該迴圈中有兩個細節值得注意。附件名稱來自檔案內部,因此在沒有先進行淨化的情況下,絕對不要重複使用它作為輸出路徑;一個像 ..\..\start.exe 的內嵌名稱是一個路徑遍歷,正等著粗心的儲存呼叫。此外,副檔名封鎖清單是一條絆線,而不是保證。它的工作是強制進行人工決策,而不是證明檔案是乾淨的

將訊號轉換為路由狀態

一個可行的狀態模型所需要的狀態數比大多數團隊預期的還要少:準備就緒 (ready)(沒有阻礙,有文字)、審查 (review)(開啟成功但有些東西需要留意,例如 XFA 表單、JavaScript、空的文字層,或標題只在 XMP 中)、受阻 (blocked)(需要使用者密碼)與 損壞 (damaged)(開啟失敗)。在狀態旁邊記錄證據。檔案雜湊值、頁數、精確的標誌以及損壞檔案的引擎錯誤訊息都很重要,因為對路由決策提出質疑的人,會在數週後針對可能已經被替換或修改的檔案提出質疑

當操作員確實需要查看被隔離的檔案時,不要將它交給預設的殼層(shell)檢視器。請在一個停用指令碼與連結處理的強化窗格內渲染它,這是在在 Delphi 中建置安全的 PDF 預覽介面中所描述的方法。而且,如果您的接收作業是要將資料送入有符合性要求的封存中,分流階段自然是排程進行更深入檢查的好地方;針對 PDF/A 與 PDF/UA 設定檔的批次預檢驗證會精準地從這個檢查停止的地方接續進行

元件的產品頁面涵蓋了授權、完整的檢查 API 以及隨附的示範,包括一個接收風格的檔案檢查器:PDFium Component