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