Bài viết kỹ thuật

Tạo bàn làm việc xem xét tiếp nhận PDF trong Delphi bằng PDFium Component

Một workbench kiểm tra tiếp nhận PDF là một chương trình nhỏ với một nhiệm vụ duy nhất: xem xét từng file trước khi bất cứ thứ gì ở phía sau được phép chạm vào nó. Để làm nhiệm vụ đó, nó phải kết hợp một số khả năng vào một lượt xử lý. Nó mở file (mà không tin tưởng nó), đọc những gì file tự khai báo về chính nó, tìm kiếm nội dung có thể đánh lừa một bộ trích xuất ngây thơ hoặc mang theo một cuộc tấn công, quyết định xem có văn bản nào có thể trích xuất được hay không, rồi định tuyến tài liệu vào một hàng đợi dựa trên những gì nó tìm thấy. Bỏ qua bước kiểm tra này và các lỗi sẽ âm thầm xảy ra: một PDF được mã hóa bằng mật khẩu chủ sở hữu bọc một form XFA sẽ trôi qua bộ trích xuất văn bản dưới dạng chuỗi rỗng, được lập chỉ mục như một tài liệu trống, và không ai nhận ra cho đến khi ai đó ở phía sau đi tìm nội dung chưa từng được đọc. PDFium Component là một thư viện viewer và kiểm tra VCL/LCL có mã nguồn cho Delphi, C++Builder và Lazarus, và nó phơi bày các lệnh gọi kiểm tra nội tại mà workbench này cần. Các phần dưới đây đi qua lệnh gọi nào trả lời câu hỏi nào, và hai chỗ mà lệnh gọi hiển nhiên lại cho bạn một câu trả lời sai một cách chắc chắn

Năm câu hỏi cần trả lời trước khi một file được định tuyến

Bỏ đi lưới hiển thị và dải hình thu nhỏ, việc phân loại tiếp nhận quy về năm câu hỏi:

Sơ đồ bàn làm việc tiếp nhận PDF Delphi trả lời năm câu hỏi phân loại trong một lượt mở rẻ tiền và định tuyến các tệp tới các trạng thái sẵn-sàng, cần-xem-xét, bị-chặn hoặc hỏng
Triage tiếp nhận trả lời năm câu hỏi chỉ trong một lần mở rẻ tiền và định tuyến tệp vào ready, review, blocked, hay damaged
  • File có thể mở được không, và dưới mật khẩu nào?
  • Nó tự khai báo là gì: tiêu đề, tác giả, ngày tạo?
  • Nó có mang nội dung hoạt động hoặc rủi ro như JavaScript, form XFA, hoặc file nhúng không?
  • Có văn bản trích xuất được không, hay đây là bản quét cần OCR?
  • Với tất cả những điều đó, hàng đợi nào sẽ nhận nó: xử lý trực tiếp, xem xét thủ công, hay cách ly?

Mỗi câu hỏi ánh xạ vào một hoặc hai lệnh gọi PDFium Component. Hai trong số các ánh xạ đó có những góc sắc nhọn chiếm phần lớn các file bị định tuyến sai mà tôi từng phải gỡ lỗi trong môi trường thực tế. Metadata tài liệu sống ở hai nơi khác nhau có thể không khớp nhau, và mã hóa không nhất thiết ngăn một tài liệu mở ra

Mở với chi phí thấp: tắt form fill, không render trang nào

Phân loại nên là kiểu mở rẻ nhất có thể. Đặt FormFill := False trước Active := True báo cho component bỏ qua hoàn toàn môi trường form-fill. Điều đó rút ngắn thời gian tải, và (cũng quan trọng không kém đối với các file có nguồn gốc chưa rõ) nó ngăn bất kỳ JavaScript ở cấp tài liệu nào khởi tạo. Không thuộc tính kiểm tra nào dùng bên dưới yêu cầu render một trang, vì vậy một lượt phân loại không bao giờ cần tạo ra dù chỉ một bitmap

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // không có môi trường form, không khởi tạo JavaScript
    Pdf.Active := True;        // lỗi diễn ra âm thầm: Active chỉ đơn giản giữ nguyên False

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // file hỏng hoặc bị khóa bằng mật khẩu người dùng
      Exit;                    // khối finally vẫn chạy
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // không bao giờ để rò rỉ instance với file dị dạng
  end;
end;

Việc kiểm tra sau phép gán không phải là tùy chọn, và nó là một phép kiểm tra chứ không phải một trình xử lý ngoại lệ vì một lý do. Khi engine không thể tải file, component nuốt EPdfError nội bộ và để ActiveFalse thay vì lan truyền nó ra ngoài. Mã nguồn chờ một ngoại lệ sẽ vui vẻ đọc PageCount từ một tài liệu chưa từng mở được. Nếu quy trình từ chối cần văn bản lỗi thực sự của engine, hãy đọc file vào một mảng byte và gọi phiên bản overload LoadDocument nhận TBytes; đường đó thực sự ném ra EPdfError kèm thông điệp, bao gồm cả trường hợp mật khẩu. try..finally vẫn xứng đáng có chỗ đứng của nó. Các dịch vụ tiếp nhận chạy không giám sát trong nhiều tuần, và không ngoại lệ nào về sau được phép làm rò rỉ instance TPdf hoặc giữ một khóa mà lượt thử lại sẽ vấp phải

Thông lượng hiếm khi trở thành điểm nghẽn. Với form fill bị tắt và không render, một lần mở phân loại bị chi phối bởi I/O, và một worker duy nhất kiểm tra thoải mái vài file mỗi giây từ đĩa cục bộ. Nếu khối lượng tiếp nhận có lúc vượt quá một worker, hãy chia công việc theo file chứ không theo phép kiểm tra. Năm câu hỏi dùng chung một lần mở, và việc chia chúng ra nhiều tiến trình sẽ nhân bước tốn kém nhất lên thay vì phân bổ chi phí của nó

Metadata sống ở hai nơi, và chúng không khớp nhau

ISO 32000-1 định nghĩa hai nơi chứa metadata tài liệu: document information dictionary (điều khoản 14.3.3) và một gói XMP đính kèm vào catalog (điều khoản 14.3.2). Các thuộc tính Title, Author, SubjectCreationDate đọc Info dictionary, với MetaText[] cho bất kỳ khóa nào khác và DecodeDate để phân tích chuỗi ngày D:YYYYMMDD.... Điểm mắc là các phần mềm tạo file hiện đại ngày càng chỉ ghi XMP, một hướng đi mà ISO 32000-2 chính thức hóa bằng cách loại bỏ hầu hết các khóa của Info dictionary trong PDF 2.0. Triệu chứng trong một công cụ tiếp nhận rất cụ thể. Workbench của bạn hiển thị tiêu đề rỗng trong khi Adobe Acrobat hiển thị một tiêu đề, bởi vì Acrobat đã lùi về dc:title bên trong gói XMP, thứ mà các thuộc tính Info dictionary không bao giờ chạm tới

Sơ đồ metadata PDF sống tại hai nơi, từ điển Info và gói XMP, vốn có thể bất đồng về tiêu đề trong một công cụ tiếp nhận Delphi
Metadata tài liệu sống trong dictionary Info và trong packet XMP, và hai mái nhà này có thể bất đồng về tiêu đề
procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // giá trị Info dictionary
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // chuỗi ngày PDF thô ("D:2026...")

  // Tiêu đề Info rỗng không có nghĩa tài liệu không có tiêu đề. Component
  // không phơi bày gói XMP, vì vậy hãy dò byte thô của file để tìm
  // phần tử dc:title trước khi tin vào ô trống.
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

Ngay cả phép dò chuỗi con thô sơ ở trên cũng xứng đáng: "có metadata, nhưng không nằm ở chỗ các công cụ cũ tìm kiếm" là một sự thật liên quan đến định tuyến đối với bất kỳ pipeline lưu trữ nào lập chỉ mục theo tiêu đề hoặc tác giả. Nếu chỉ mục ở phía sau của bạn chỉ đọc Info dictionary, các file được đánh dấu theo cách này sẽ âm thầm trở nên không thể tìm kiếm được

File mã hóa nhưng vẫn mở được

Một tài liệu được mã hóa không nhất thiết mở thất bại. Trình xử lý bảo mật chuẩn (ISO 32000-1 điều khoản 7.6.3) phân biệt mật khẩu người dùng, bắt buộc để mở tài liệu, với mật khẩu chủ sở hữu vốn chỉ khóa các quyền như in ấn và sao chép. Một phần lớn tài liệu doanh nghiệp "được bảo vệ" được mã hóa bằng mật khẩu chủ sở hữu và mật khẩu người dùng rỗng. Chúng mở ra mà không hỏi, giải mã hoàn toàn, và dựa vào việc các viewer tự nguyện tôn trọng các cờ quyền. Đó là chính sách, không phải bảo vệ, và các trạng thái tiếp nhận của bạn nên phản ánh sự khác biệt đó

Phát hiện mã hóa sau khi mở thành công chỉ cần một lệnh gọi engine cộng thêm một phương án dự phòng. FPDF_GetSecurityHandlerRevision(Pdf.Document) trả về -1 cho các file không được bảo vệ và phiên bản trình xử lý trong trường hợp khác, và Pdf.Permissions trả về bất cứ giá trị nào khác ngoài mặt nạ toàn bit $FFFFFFFF là tín hiệu xác nhận. Với các file thực sự bị khóa bằng mật khẩu người dùng, hãy gán Password trước khi đặt Active := True; nếu việc mở vẫn thất bại, định tuyến file vào trạng thái bị chặn yêu cầu thông tin xác thực từ người gửi qua một kênh an toàn thay vì thử lại mù quáng. Và hãy cưỡng lại cám dỗ coi "được mã hóa" là cách ly tự động. Trong hầu hết các ngành nhiều tài liệu, file mã hóa nhưng mở được là trường hợp bình thường, không phải trường hợp đáng ngờ

Nội dung hoạt động: JavaScript, XFA, và file nhúng

Ba phát hiện luôn nên được đưa vào quyết định định tuyến. Thứ nhất, JavaScript: sự kiện OnUnsupportedFeature báo cáo các đặc trưng cấu trúc như XFA hay nội dung 3D khi engine gặp phải chúng, nhưng nó không phát hiện JavaScript. Thay vào đó hãy kiểm tra JavaScriptActionCount và coi kết quả khác không là nội dung hoạt động. Thứ hai, XFA: khi FormType trả về ftXfaFull, các trang hiển thị thường chỉ là bản render của template XFA, và việc trích xuất văn bản thông thường sẽ thấy văn bản khuôn mẫu thay vì các giá trị đã điền. Thứ ba, tệp đính kèm: PDF là một định dạng chứa đựng, và AttachmentCount cho bạn biết liệu file này có mang theo hành khách hay không

Sơ đồ các tín hiệu rủi ro tiếp nhận PDF trong Delphi: bản sửa của bộ xử lý mã hóa, số lượng action JavaScript, kiểu biểu mẫu XFA, và các tệp đính kèm nguy hiểm
Trạng thái mã hóa cùng số đếm JavaScript, XFA và attachment là những tín hiệu phải sống sót vào quyết định định tuyến
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 là thuộc tính theo từng trang; duyệt qua các trang để
  // cộng dồn nó. Việc tải đối tượng trang không render gì cả, nên việc này vẫn rẻ.
  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;

Hai chi tiết trong vòng lặp đó đáng chú ý. Tên tệp đính kèm đến từ bên trong tài liệu, vì vậy đừng bao giờ tái sử dụng nó làm đường dẫn đầu ra mà không làm sạch trước; một tên nhúng như ..\..\start.exe là một cuộc tấn công path traversal đang chờ một lệnh lưu bất cẩn. Và một danh sách chặn theo phần mở rộng là một dây bẫy, không phải một sự đảm bảo. Nhiệm vụ của nó là buộc phải có một quyết định của con người, không phải chứng nhận file đó sạch

Chuyển tín hiệu thành trạng thái định tuyến

Một mô hình trạng thái khả thi cần ít trạng thái hơn hầu hết các nhóm mong đợi: sẵn sàng (không có gì cản trở, có văn bản), xem xét (mở thành công nhưng có gì đó cần con mắt kiểm tra, như một form XFA, JavaScript, một lớp văn bản rỗng, hoặc tiêu đề chỉ có trong XMP), bị chặn (cần mật khẩu người dùng), và hỏng (mở thất bại). Ghi lại bằng chứng cùng với trạng thái. Mã băm file, số trang, các cờ chính xác, và thông điệp lỗi của engine đối với file hỏng đều quan trọng, bởi vì người đặt câu hỏi về một quyết định định tuyến sẽ làm điều đó nhiều tuần sau, đối với một file có thể đã bị thay thế hoặc sửa đổi từ đó

Khi một người vận hành thực sự cần xem một file đang bị cách ly, đừng đưa nó cho trình xem mặc định của hệ điều hành. Hãy render nó bên trong một khung được gia cố với scripting và xử lý liên kết bị tắt, cách tiếp cận được mô tả trong xây dựng một bề mặt xem trước PDF an toàn trong Delphi. Và nếu quy trình tiếp nhận của bạn nuôi một kho lưu trữ có yêu cầu tuân thủ, lượt phân loại là nơi tự nhiên để lên lịch một kiểm tra sâu hơn; xác thực preflight hàng loạt theo hồ sơ PDF/A và PDF/UA tiếp tục chính xác từ nơi việc kiểm tra này dừng lại

Trang sản phẩm của component bao gồm cấp phép, đầy đủ API kiểm tra, và các demo đi kèm, bao gồm một trình kiểm tra tài liệu kiểu tiếp nhận: PDFium Component