บทความเทคนิค

สร้างเวิร์กเบนช์ตรวจสอบ PDF ขาเข้าใน Delphi ด้วย PDFium Component

เวิร์กเบนช์ตรวจรับ PDF ขาเข้า (intake review workbench) คือโปรแกรมเล็ก ๆ ที่มีหน้าที่เดียว: ตรวจดูทุกไฟล์ก่อนที่จะปล่อยให้ขั้นตอนถัดไปแตะต้องมันได้ ในการทำหน้าที่นี้ มันต้องรวมความสามารถหลายอย่างเข้าไว้ในรอบเดียว มันเปิดไฟล์ (โดยไม่เชื่อไฟล์นั้นทันที) อ่านสิ่งที่ไฟล์อ้างว่าเป็นเกี่ยวกับตัวเอง มองหาเนื้อหาที่จะหลอกตัวดึงข้อความแบบไร้เดียงสาหรือแฝงการโจมตี ตัดสินว่ามีข้อความที่ดึงออกมาได้เลยหรือไม่ แล้วจึงส่งเอกสารไปยังคิวตามสิ่งที่พบ ถ้าข้ามการตรวจสอบไป ความล้มเหลวก็จะเป็นแบบเงียบ ๆ: PDF ที่เข้ารหัสด้วย owner password ซึ่งห่อฟอร์ม XFA อยู่ข้างใน จะผ่านตัวดึงข้อความออกมาเป็นสตริงว่างเปล่า ถูกจัดทำดัชนีเป็นเอกสารเปล่า และไม่มีใครสังเกตเห็นจนกว่าจะมีใครในขั้นตอนถัดไปไปตามหาเนื้อหาที่ไม่เคยถูกอ่านเลย PDFium Component เป็นไลบรารีตัวแสดงผลและตรวจสอบแบบ VCL/LCL ที่มาพร้อมซอร์สโค้ดสำหรับ Delphi, C++Builder และ Lazarus และมันเปิดให้ใช้ฟังก์ชันตรวจสอบภายในที่เวิร์กเบนช์นี้ต้องการ ส่วนต่าง ๆ ด้านล่างจะไล่ดูว่าฟังก์ชันไหนตอบคำถามไหน และสองจุดที่ฟังก์ชันที่ดูเหมือนชัดเจนกลับให้คำตอบผิดอย่างมั่นใจ

ห้าคำถามที่ต้องตอบก่อนที่ไฟล์จะถูกส่งไปยังคิว

ตัดกริดและแถบภาพย่อทิ้งไป แล้วการคัดกรองไฟล์ขาเข้าก็เหลือแค่ห้าคำถาม:

แผนภาพเครื่องมือรับไฟล์ PDF ของ Delphi ที่ตอบคำถามคัดแยกห้าข้อในการเปิดครั้งเดียวที่ถูก แล้วนำไฟล์ไปยังสถานะพร้อมใช้ ต้องตรวจ ถูกบล็อก หรือเสียหาย
การคัดแยกขาเข้าตอบห้าคำถามในการเปิดครั้งเดียวที่ต้นทุนต่ำ และกำหนดเส้นทางไฟล์ไปยัง ready, review, blocked หรือ damaged
  • ไฟล์เปิดได้เลยหรือไม่ และต้องใช้รหัสผ่านแบบไหน?
  • ไฟล์อ้างว่าตัวเองเป็นอะไร: ชื่อเรื่อง ผู้เขียน วันที่สร้าง?
  • ไฟล์มีเนื้อหาที่ทำงานได้หรือมีความเสี่ยง เช่น JavaScript ฟอร์ม XFA หรือไฟล์ที่ฝังอยู่ภายในหรือไม่?
  • มีข้อความที่ดึงออกมาได้หรือไม่ หรือเป็นไฟล์สแกนที่ต้องส่งไป OCR?
  • จากทั้งหมดนี้ ไฟล์ควรไปคิวไหน: ประมวลผลต่อเนื่องทันที ตรวจสอบด้วยคน หรือกักกัน?

แต่ละคำถามแมปไปยังฟังก์ชันของ PDFium Component หนึ่งหรือสองตัว สองในบรรดาการแมปเหล่านั้นมีมุมคมที่เป็นต้นเหตุของไฟล์ที่ถูกส่งผิดคิวส่วนใหญ่ที่ผมเคยต้องดีบักในระบบจริง เมทาดาทาของเอกสารอยู่ในสองที่ที่ต่างกันและอาจขัดแย้งกันเองได้ และการเข้ารหัสก็ไม่ได้แปลว่าเอกสารจะเปิดไม่ได้เสมอไป

เปิดไฟล์แบบประหยัดต้นทุน: ปิด form fill และไม่เรนเดอร์แม้แต่หน้าเดียว

การคัดกรองควรเป็นการเปิดไฟล์ที่ประหยัดต้นทุนที่สุดเท่าที่จะทำได้ การกำหนด FormFill := False ก่อน Active := True บอกให้คอมโพเนนต์ข้ามสภาพแวดล้อม form-fill ไปทั้งหมด ซึ่งช่วยลดเวลาโหลด และ (สำคัญไม่แพ้กันสำหรับไฟล์ที่ไม่ทราบที่มา) ยังป้องกัน JavaScript ระดับเอกสารไม่ให้เริ่มทำงานด้วย พร็อพเพอร์ตี้ตรวจสอบที่ใช้ด้านล่างนี้ไม่มีตัวไหนต้องเรนเดอร์หน้าเลย ดังนั้นการคัดกรองจึงไม่ต้องสร้าง bitmap แม้แต่ภาพเดียว

procedure InspectIncoming(const IncomingPath: string; var Rec: TIntakeRecord);
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := IncomingPath;
    Pdf.FormFill := False;     // ไม่มีสภาพแวดล้อมฟอร์ม ไม่มีการเริ่ม JavaScript
    Pdf.Active := True;        // ความล้มเหลวจะเงียบ: Active จะยังคงเป็น False เฉย ๆ

    if not Pdf.Active then
    begin
      Rec.OpenFailed := True;  // ไฟล์เสียหายหรือถูกล็อกด้วย user password
      Exit;                    // บล็อก finally ยังคงทำงานอยู่ดี
    end;

    Rec.PageCount := Pdf.PageCount;
    CollectIdentity(Pdf, IncomingPath, Rec);
    CollectRiskSignals(Pdf, Rec);
  finally
    Pdf.Active := False;
    Pdf.Free;                  // อย่าปล่อยอินสแตนซ์รั่วไหลเมื่อไฟล์ผิดรูปแบบ
  end;
end;

การตรวจสอบหลังการกำหนดค่านั้นไม่ใช่ทางเลือก และมันเป็นการตรวจสอบมากกว่าตัวจัดการข้อยกเว้นด้วยเหตุผลหนึ่ง เมื่อเอนจินโหลดไฟล์ไม่ได้ คอมโพเนนต์จะกลืน EPdfError ภายในไว้เองและปล่อยให้ Active เป็น False แทนที่จะโยนข้อยกเว้นออกมา โค้ดที่รอจับข้อยกเว้นจะยินดีอ่าน PageCount จากเอกสารที่ไม่เคยเปิดได้เลยด้วยซ้ำ ถ้าเวิร์กโฟลว์การปฏิเสธไฟล์ต้องการข้อความข้อผิดพลาดจริงจากเอนจิน ให้อ่านไฟล์เข้ามาเป็นอาเรย์ไบต์แล้วเรียก overload ของ LoadDocument ที่รับ TBytes เส้นทางนั้นจะโยน EPdfError พร้อมข้อความจริง รวมถึงกรณีรหัสผ่านด้วย ส่วน try..finally ก็ยังคงมีที่ทางของมันอยู่ บริการรับไฟล์ขาเข้าทำงานโดยไม่มีคนดูแลเป็นสัปดาห์ ๆ และไม่มีข้อยกเว้นที่มาทีหลังไหนควรทำให้อินสแตนซ์ TPdf รั่วไหลหรือถือ lock ค้างไว้จนรอบ retry มาสะดุดเข้า

อัตราการประมวลผล (throughput) แทบไม่ค่อยกลายเป็นคอขวด เมื่อปิด form fill และไม่เรนเดอร์ การเปิดไฟล์เพื่อคัดกรองจะถูกครอบงำด้วย I/O เป็นหลัก และเวิร์กเกอร์ตัวเดียวก็ตรวจสอบไฟล์ได้หลายไฟล์ต่อวินาทีจากดิสก์ในเครื่องได้อย่างสบาย ๆ ถ้าปริมาณไฟล์ขาเข้าโตเกินกว่าที่เวิร์กเกอร์ตัวเดียวจะรับไหว ให้แบ่งงานตามไฟล์ ไม่ใช่ตามคำถามแต่ละข้อ ห้าคำถามใช้การเปิดไฟล์ร่วมกันเพียงครั้งเดียว การแยกมันไปตามหลายโปรเซสจะทำให้ขั้นตอนที่แพงที่สุดถูกคูณซ้ำแทนที่จะกระจายต้นทุน

เมทาดาทาอยู่สองที่ และทั้งสองที่ไม่ตรงกัน

ISO 32000-1 กำหนดที่อยู่ของเมทาดาทาเอกสารไว้สองแห่ง: document information dictionary (ข้อ 14.3.3) และแพ็กเก็ต XMP ที่แนบอยู่กับ catalog (ข้อ 14.3.2) พร็อพเพอร์ตี้ Title, Author, Subject และ CreationDate อ่านจาก Info dictionary โดยมี MetaText[] สำหรับคีย์อื่น ๆ และ DecodeDate สำหรับแยกวิเคราะห์สตริงวันที่รูปแบบ D:YYYYMMDD... ข้อดักคือผู้ผลิตไฟล์ยุคใหม่เขียนแค่ XMP มากขึ้นเรื่อย ๆ ซึ่งเป็นทิศทางที่ ISO 32000-2 ทำให้เป็นทางการด้วยการเลิกใช้ (deprecate) คีย์ส่วนใหญ่ใน Info dictionary ใน PDF 2.0 อาการในเครื่องมือตรวจรับไฟล์ขาเข้าเป็นรูปธรรมมาก เวิร์กเบนช์ของคุณแสดงชื่อเรื่องว่างเปล่า ในขณะที่ Adobe Acrobat แสดงชื่อเรื่องออกมา เพราะ Acrobat ย้อนกลับไปใช้ dc:title ภายในแพ็กเก็ต XMP ซึ่งพร็อพเพอร์ตี้ของ Info dictionary ไม่เคยแตะถึงเลย

แผนภาพ metadata ของ PDF ที่อยู่สองที่ คือ Info dictionary กับแพ็กเก็ต XMP ซึ่งอาจขัดแย้งกันเรื่อง title ในเครื่องมือรับไฟล์ของ Delphi
metadata ของเอกสารอาศัยอยู่ทั้งใน dictionary หน้า Info และใน packet XMP และสองบ้านหลังนี้อาจให้ข้อมูลชื่อเรื่องไม่ตรงกัน
procedure CollectIdentity(Pdf: TPdf; const FilePath: string;
  var Rec: TIntakeRecord);
begin
  Rec.Title := Pdf.Title;             // ค่าจาก Info dictionary
  Rec.Author := Pdf.Author;
  Rec.CreatedAt := Pdf.CreationDate;  // สตริงวันที่ดิบของ PDF ("D:2026...")

  // ชื่อเรื่องว่างเปล่าใน Info ไม่ได้แปลว่าเอกสารไม่มีชื่อเรื่อง
  // คอมโพเนนต์ไม่เปิดให้เข้าถึงแพ็กเก็ต XMP ดังนั้นให้ตรวจสอบไบต์ดิบ
  // ของไฟล์หา element ชื่อ dc:title ก่อนที่จะเชื่อว่าค่าว่างเปล่านั้นถูกต้อง
  if (Rec.Title = '') and FileContainsText(FilePath, 'dc:title') then
    Include(Rec.Flags, ifTitleInXmpOnly);
end;

แม้แต่การตรวจสอบสตริงย่อยแบบหยาบ ๆ ด้านบนก็ยังคุ้มค่าที่จะมี: ข้อเท็จจริงที่ว่า "มีเมทาดาทาอยู่ แต่ไม่ได้อยู่ในที่ที่เครื่องมือรุ่นเก่ามองหา" เป็นข้อมูลที่เกี่ยวข้องกับการจัดเส้นทางสำหรับไปป์ไลน์การจัดเก็บถาวรใด ๆ ที่จัดทำดัชนีตามชื่อเรื่องหรือผู้เขียน ถ้าดัชนีในขั้นตอนถัดไปของคุณอ่านแค่ Info dictionary ไฟล์ที่ถูกตั้งค่าสถานะแบบนี้จะค้นหาไม่เจอไปอย่างเงียบ ๆ

ไฟล์ที่เข้ารหัสแต่ก็ยังเปิดได้อยู่ดี

เอกสารที่เข้ารหัสไม่จำเป็นต้องเปิดไม่ได้เสมอไป security handler มาตรฐาน (ISO 32000-1 ข้อ 7.6.3) แยกความแตกต่างระหว่าง user password ที่จำเป็นต้องใช้เพื่อเปิดเอกสาร กับ owner password ที่แค่กั้นสิทธิ์อย่างการพิมพ์และการคัดลอกเท่านั้น เอกสารธุรกิจที่ "ป้องกันแล้ว" ส่วนใหญ่มักเข้ารหัสด้วย owner password พร้อม user password ที่ว่างเปล่า มันจึงเปิดได้โดยไม่ถามอะไร ถอดรหัสได้เต็มรูปแบบ และอาศัยให้ตัวแสดงผลสมัครใจเคารพแฟล็กสิทธิ์เอาเอง นั่นคือนโยบาย ไม่ใช่การป้องกันจริง และสถานะการตรวจรับไฟล์ของคุณควรสะท้อนความแตกต่างนี้

การตรวจจับการเข้ารหัสหลังจากเปิดไฟล์สำเร็จใช้การเรียกเอนจินหนึ่งครั้งบวกกับ fallback อีกทาง FPDF_GetSecurityHandlerRevision(Pdf.Document) คืนค่า -1 สำหรับไฟล์ที่ไม่ได้ป้องกัน และคืนเลข revision ของ handler ในกรณีอื่น ส่วน Pdf.Permissions ที่คืนค่าอะไรก็ตามที่ไม่ใช่มาสก์ $FFFFFFFF (ที่ตั้งบิตทั้งหมด) คือสัญญาณยืนยันอีกทาง สำหรับไฟล์ที่ถูกล็อกด้วย user password จริง ๆ ให้กำหนดค่า Password ก่อนตั้ง Active := True ถ้าเปิดยังไม่สำเร็จอีก ให้ส่งไฟล์ไปยังสถานะถูกบล็อกที่ขอข้อมูลรับรองจากผู้ส่งผ่านช่องทางที่ปลอดภัย แทนที่จะลองซ้ำแบบมั่ว ๆ และอย่าตกหลุมพรางที่จะถือว่า "เข้ารหัส" เท่ากับต้องกักกันโดยอัตโนมัติ ในอุตสาหกรรมที่เกี่ยวข้องกับเอกสารจำนวนมาก ไฟล์ที่เข้ารหัสแต่เปิดได้ถือเป็นกรณีปกติ ไม่ใช่กรณีที่น่าสงสัย

เนื้อหาที่ทำงานได้: JavaScript, XFA และไฟล์ที่ฝังอยู่ภายใน

มีสามสิ่งที่ตรวจพบแล้วควรส่งผลไปถึงการตัดสินใจเรื่องเส้นทางเสมอ ข้อแรก JavaScript: เหตุการณ์ OnUnsupportedFeature รายงานฟีเจอร์เชิงโครงสร้าง เช่น XFA หรือเนื้อหา 3D เมื่อเอนจินเจอเข้า แต่มันไม่ตรวจจับ JavaScript ให้ตรวจสอบ JavaScriptActionCount แทน แล้วถือว่าผลลัพธ์ที่ไม่เป็นศูนย์คือเนื้อหาที่ทำงานได้ ข้อสอง XFA: เมื่อ FormType คืนค่า ftXfaFull หน้าที่มองเห็นได้มักจะเป็นแค่การเรนเดอร์เทมเพลต XFA เท่านั้น และการดึงข้อความแบบทั่วไปจะเห็นแค่ข้อความสำเร็จรูป (boilerplate) แทนที่จะเป็นค่าที่กรอกจริง ข้อสาม ไฟล์แนบ: PDF เป็นฟอร์แมตแบบคอนเทนเนอร์ และ AttachmentCount จะบอกคุณว่าไฟล์นี้แบกผู้โดยสารมาด้วยหรือไม่

แผนภาพสัญญาณความเสี่ยงการรับไฟล์ PDF ใน Delphi: รีวิชันของ encryption handler, จำนวน action JavaScript, ชนิดฟอร์ม XFA และไฟล์แนบที่อันตราย
สถานะการเข้ารหัสและจำนวน JavaScript, XFA และไฟล์แนบคือสัญญาณที่ต้องรอดไปถึงการตัดสินใจกำหนดเส้นทาง
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 เป็นพร็อพเพอร์ตี้ต่อหน้า; ให้ไล่ทีละหน้าเพื่อรวมยอด
  // การโหลดออบเจกต์หน้าไม่ได้เรนเดอร์อะไรเลย จึงยังคงมีต้นทุนต่ำ
  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;

มีสองรายละเอียดในลูปนั้นที่ควรใส่ใจ ชื่อไฟล์แนบมาจากภายในเอกสาร ดังนั้นห้ามนำมันไปใช้เป็น output path ซ้ำโดยไม่ทำความสะอาด (sanitize) ก่อนเด็ดขาด ชื่อที่ฝังไว้แบบ ..\..\start.exe คือการโจมตีแบบ path traversal ที่รอเวลาให้การเรียก save แบบไม่ระวังตัวมาติดกับ และ blocklist ของนามสกุลไฟล์เป็นแค่กับดักสะดุด (tripwire) ไม่ใช่การรับประกัน หน้าที่ของมันคือบังคับให้คนตัดสินใจ ไม่ใช่รับรองว่าไฟล์นั้นสะอาด

แปลงสัญญาณที่ตรวจพบให้เป็นสถานะการจัดเส้นทาง

โมเดลสถานะที่ใช้งานได้จริงต้องการสถานะน้อยกว่าที่ทีมส่วนใหญ่คาดไว้: ready (ไม่มีตัวขวาง มีข้อความอยู่), review (เปิดสำเร็จแต่มีบางอย่างต้องให้คนดู เช่น ฟอร์ม XFA, JavaScript, ชั้นข้อความว่างเปล่า หรือชื่อเรื่องที่อยู่ใน XMP เท่านั้น), blocked (ต้องใช้ user password) และ damaged (เปิดไม่สำเร็จ) ให้บันทึกหลักฐานควบคู่ไปกับสถานะด้วย แฮชของไฟล์ จำนวนหน้า แฟล็กที่ตั้งไว้แน่ชัด และข้อความข้อผิดพลาดจากเอนจินสำหรับไฟล์ที่เสียหาย ล้วนสำคัญทั้งหมด เพราะคนที่มาตั้งคำถามกับการตัดสินใจจัดเส้นทางจะทำแบบนั้นในอีกหลายสัปดาห์ให้หลัง กับไฟล์ที่อาจถูกแทนที่หรือแก้ไขไปแล้วตั้งแต่ตอนนั้น

เมื่อผู้ปฏิบัติงานต้องดูไฟล์ที่ถูกกักกันจริง ๆ อย่าส่งมันให้ตัวแสดงผลเริ่มต้นของระบบปฏิบัติการ ให้เรนเดอร์มันภายในพาเนลที่แข็งแกร่งขึ้นโดยปิดสคริปต์และการจัดการลิงก์ ตามแนวทางที่อธิบายไว้ในการสร้างพื้นผิวพรีวิว PDF ที่ปลอดภัยใน Delphi และถ้าระบบรับไฟล์ขาเข้าของคุณป้อนเข้าคลังเก็บถาวรที่มีข้อกำหนดด้าน conformance การคัดกรองก็เป็นจุดที่เหมาะสมโดยธรรมชาติในการนัดหมายการตรวจสอบที่ลึกกว่านั้น การตรวจสอบ preflight แบบแบตช์เทียบกับโปรไฟล์ PDF/A และ PDF/UA จะรับช่วงต่อพอดีตรงจุดที่การตรวจสอบนี้หยุดไว้

หน้าผลิตภัณฑ์ของคอมโพเนนต์ครอบคลุมเรื่องไลเซนส์ API ตรวจสอบแบบเต็ม และดีโมที่แถมมาให้ ซึ่งรวมถึงตัวตรวจสอบเอกสารแบบสไตล์ intake ด้วย: PDFium Component