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

กั้น CALL กับ WEBSERVICE ใน formula callback ของ HotXLS

HotXLS ปฏิเสธที่จะส่งชื่อสูตรอันตราย 20 ชื่อ รวมถึง CALL, REGISTER.ID, WEBSERVICE และ DDE ไปยัง callback ของ user function ฝั่ง Delphi ของคุณ เว้นแต่คุณจะเลือกเปิดเอง property AllowUnsafeFormulaCallbacks ของเวิร์กบุ๊กค่าเริ่มต้นเป็น False การเช็กรันก่อนที่ argument ใดจะถูกประเมิน และการเรียกที่ถูกปฏิเสธรายงาน xlfeUnsafeFunctionDenied โดยไม่เรียก handler แม้แต่ตัวเดียว

สถานการณ์ที่ทำให้เรื่องนี้จำเป็นนั้นธรรมดามาก มี service รับไฟล์ XLS หรือ XLSX ที่อัปโหลดมา คำนวณใหม่ฝั่งเซิร์ฟเวอร์ แล้วอ่านผลรวมไม่กี่ตัวกลับออกมา แอปโฮสต์ลงทะเบียน handler OnUserFunction ไว้หลายปีก่อนสำหรับ business function สักสองสามตัว และระหว่างทาง handler ตัวนั้นก็งอก branch แบบ catch-all ที่ส่งต่อสิ่งที่มันไม่รู้จักไปยังตาราง plugin ไม่มีใครในทีมพิมพ์ =WEBSERVICE(...) ลงเซลล์เลย แต่คนอัปโหลดพิมพ์ การเก็บสูตรนั้นให้ครบถ้วนผ่านการเปิด, recalc และ save เป็นฟีเจอร์เรื่องความซื่อตรงของไฟล์ ส่วนการปล่อยให้มันไปถึงโค้ดโฮสต์ที่เปิด socket หรือไฟล์ได้เป็นการตัดสินใจเรื่องการอนุญาต และก่อนที่ HotXLS จะแยกสองเรื่องนี้ออกจากกัน ไลบรารีกำลังตัดสินใจแทนคุณอย่างเงียบ ๆ อยู่

ทำไมการเก็บสูตรไว้ถึงกลายเป็นการอนุญาตให้รันมัน

สาเหตุรากเป็นเส้นทาง fallback เส้นเดียว HotXLS parse ชื่อฟังก์ชัน Excel ที่มันรู้จักทุกชื่อ แต่ไม่ใช่ทุกชื่อที่รู้จักจะมี implementation ในเอนจินคำนวณ built-in ที่ถูกจดจำแต่ยังไม่ implement เคยหล่นลง fallback ของ user-defined function ตัวเดียวกับชื่อ custom ของจริง CALL กับ REGISTER.ID จึงใช้เส้นทาง dispatch ร่วมกับ DISCOUNT หรือ REGIONRATE ของคุณ ชื่อที่ไม่รู้จักอย่าง WEBSERVICE หรือ DDE ก็อาจ match entry ชื่อเดียวกันใน registry ระดับเวิร์กบุ๊ก, registry ระดับโปรเซส หรือ event handler กลไกของ fallback นั้นอธิบายไว้ในวิธีที่ HotXLS resolve custom function ผ่าน OnUserFunction ปัญหาคือไม่มีอะไรบนเส้นทางนั้นถามเลยว่าชื่อตัวมันเองเป็นชื่อที่โฮสต์ที่สติสัมปชัญญะควรรันหรือเปล่า

ลำดับการ dispatch มีผลต่อความหมายของคำว่า "ไม่รู้จัก" ในที่นี้ การเรียกที่เอนจินประเมินเองไม่ได้จะถูกยื่นเรียงไปยัง binding แบบ lexical ของ LAMBDA กับ LET ซึ่งฝั่ง closure support ของเอนจินสูตร HotXLS resolve ให้ก่อน ต่อด้วยฟังก์ชันระดับเวิร์กบุ๊กที่ลงทะเบียนด้วย RegisterUserFunction ต่อด้วยฟังก์ชันระดับโปรเซสจาก TXLSWorkbook.RegisterGlobalUserFunction และจบที่ event OnUserFunction กับ OnUserFunctionEx เฉพาะเมื่อทุกขั้นปฏิเสธหมด ฟังก์ชันที่ไม่รู้จักจริง ๆ ถึงจะกลายเป็น #NAME? ทุกขั้นหลังการค้น lambda ส่งการควบคุมไปให้โค้ดที่คุณเขียน ซึ่งเป็นเหตุผลเป๊ะ ๆ ว่าทำไมการเช็กความปลอดภัยต้องนั่งอยู่หน้าห่วงโซ่ทั้งอัน แทนที่จะซ่อนอยู่ใน handler สักตัว

HotXLS กั้น formula callback ที่ไม่ปลอดภัยอย่างไร: GetValueItemUserFunction เช็กชื่อก่อน array ของ argument จะถูกสร้างหรือ resolver ใดจะรัน การเรียกซ้อนแบบ =WEBSERVICE(AUDIT_TOKEN()) จึงจบด้วย lxErrorUnsafeFunctionDenied และ audit log ยังว่างเปล่า ขณะที่การเรียกที่ปลอดภัยเดินตามห่วงโซ่จาก binding LAMBDA กับ LET ลงไปถึง OnUserFunction
เฉพาะเมื่อทุกขั้นปฏิเสธหมด ฟังก์ชันที่ไม่รู้จักจริง ๆ ถึงจะกลายเป็น #NAME? จึงเป็นเหตุผลที่การเช็กความปลอดภัยนั่งอยู่หน้าห่วงโซ่ทั้งอัน ไม่ใช่ข้างใน handler สักตัว

HotXLS บล็อกชื่อฟังก์ชันใดเป็นค่าเริ่มต้น

XLSFormulaCallbackIsUnsafe ใน lxCalc.pas ถือ deny set คงที่ 20 ชื่อ: DDE, CALL, REGISTER, REGISTER.ID, WEBSERVICE, RTD, SQL.REQUEST, EXEC, RUN, CREATE.OBJECT, APP.ACTIVATE, SEND.KEYS, OPEN, SAVE, SAVE.AS, FOPEN, FWRITE, FWRITELN, FCLOSE และ FILE.DELETE พวกมันคือชื่อที่ใน Excel หรือภาษา macro ของมัน โหลด native code, เข้าถึงเครือข่าย, คุยกับโปรเซสอื่น หรือแตะไฟล์ระบบ ก่อนเปรียบเทียบ ฟังก์ชันจะ trim ช่องว่างรอบ ๆ, แปลงชื่อเป็นตัวพิมพ์ใหญ่ และตัด prefix _XLFN. หรือ _XLWS. หนึ่งชั้น _xlfn.webservice ที่ Excel build ใหม่กว่าเขียนไว้จึงถูกจับได้เหมือนชื่อเปล่า ๆ รายการนี้อยู่ที่ขอบเขตของตัวเครื่องคิด ไม่ใช่ใน parser ของ Classic, XLSX และ ODS AST หนึ่งตัว, token stream BIFF หนึ่งชุด และเวิร์กบุ๊กที่แปลงแล้วหนึ่งตัวจึงทำงานเหมือนกันหมด

HotXLS ทำชื่อฟังก์ชันให้เป็นมาตรฐานก่อนการเปรียบเทียบ unsafe callback อย่างไร: ช่องว่างถูก trim, ชื่อถูกแปลงเป็นตัวพิมพ์ใหญ่ และตัด prefix _XLFN. หรือ _XLWS. หนึ่งชั้น เพื่อให้ _xlfn.webservice ถูกจับเหมือนชื่อเปล่า ๆ จากนั้นผลลัพธ์ถูกจับคู่แบบตรงเป๊ะกับ deny set คงที่ 20 ชื่อใน lxCalc.pas
รายการครอบคลุมชื่อที่โหลด native code, เข้าถึงเครือข่าย, คุยกับโปรเซสอื่น หรือแตะไฟล์ระบบ ตั้งแต่ DDE, CALL และ WEBSERVICE ไปจนถึง FWRITE กับ FILE.DELETE

มีขอบเขตสองข้อที่ควรรู้ก่อนคุณจะพึ่งพามัน การ match เป็นแบบตรงเป๊ะ handler ที่คุณลงทะเบียนเป็น MYWEBSERVICE จึงไม่ถูกกระทบ แต่กลับกัน UDF ในบ้านที่ของแท้บังเอิญชื่อ OPEN หรือ RUN ก็ถูกปฏิเสธเป็นค่าเริ่มต้นตั้งแต่บัดนี้ deny set ก็ไม่ใช่ sandbox ให้ handler ของคุณเองด้วย ถ้า branch catch-all ของคุณรันชื่อ plugin ใดก็ได้ gate หยุดแค่ตัวอันตรายที่มีชื่อเสียง นอกนั้นไม่เกี่ยว ทางแก้ที่อยู่ยังเป็น handler ที่ match allowlist อย่างชัดเจนด้วย SameText แล้วปล่อย Handled เป็น False สำหรับทุกอย่างที่มันไม่ใช่เจ้าของ

ทำไม gate ต้องรันก่อนการประเมิน argument

gate ที่ยิงหลัง argument ถูกคำนวณแล้วคือช้าไป เพราะ argument เองก็เรียกโค้ดของคุณได้ GetValueItemUserFunction เช็กชื่อก่อนแล้วจบด้วย lxErrorUnsafeFunctionDenied ก่อนจะสร้าง array ของ argument, ก่อนจะปรึกษา resolver หรือ registry ใด ๆ และก่อนจะแม้แต่สังเกตว่าไม่มี handler ถูก assign เลยด้วยซ้ำ ลำดับแบบนี้แหละที่ชนะเคสซ้อนด้านล่าง ซึ่งการเรียกด้านนอกจะถูกปฏิเสธอยู่แล้ว แต่ถ้าไม่มีลำดับนี้ UDF ด้านในที่ดูไร้พิษภัยจะยิงก่อนแล้วทิ้ง side effect ไว้

procedure TImportService.HandleUdf(Sender: TObject;
  const FunctionName: WideString; const Args: Variant;
  var Value: Variant; var Handled: Boolean);
begin
  if SameText(FunctionName, 'AUDIT_TOKEN') then
  begin
    FAuditLog.Add('AUDIT_TOKEN evaluated');   // side effect ในโค้ดโฮสต์
    Value := 'token-42';
    Handled := True;
  end;
end;

Book.OnUserFunction := HandleUdf;
Eval := Sheet.EvaluateFormulaAt(1, 1, '=WEBSERVICE(AUDIT_TOKEN())');
// Eval.Status = xlfeUnsafeFunctionDenied, Eval.Value = Null,
// Eval.Issue.NativeCode = -106 และ FAuditLog ยังว่างเปล่า

ค่าเริ่มต้นเวิร์กบุ๊กกับ per-call TXLSFormulaEvaluationOptions

flag ระดับเวิร์กบุ๊กเป็นค่าเริ่มต้น และ option ต่อการเรียกคือคำตัดสินสุดท้าย TXLSWorkbook.AllowUnsafeFormulaCallbacks กับ TXLSXWorkbook.AllowUnsafeFormulaCallbacks คุมการ recalc ธรรมดา, Calculate, EvaluateFormulaAt สอง argument, evaluation template, read-only view และบน XLSX คือ worker ทุกตัวใน pool คำนวณขนาน จุดเข้าใดก็ตามที่รับ record TXLSFormulaEvaluationOptions อย่างชัดเจน จะใช้ Options.AllowUnsafeFormulaCallbacks เป็นคำตัดสินของการเรียกนั้น โดยไม่ OR กับ property ของเวิร์กบุ๊ก ความอสมมาตรนี้ตั้งใจให้เป็นแบบนี้: job ภายในที่เชื่อถือได้อนุญาตการค้น RTD หนึ่งครั้งได้โดยไม่ต้องพลิกทั้งเวิร์กบุ๊ก และเวิร์กบุ๊กที่เปิดรับไว้ทั้งตัวก็ยังบังคับการประเมินที่อ่อนไหวกลับไปเป็น deny ได้

var
  Options: TXLSFormulaEvaluationOptions;
  Eval: TXLSFormulaEvaluationResult;
begin
  // เวิร์กบุ๊กยังล็อกอยู่ อนุญาตให้การเรียกที่เชื่อถือได้หนึ่งครั้งผ่าน
  Book.AllowUnsafeFormulaCallbacks := False;
  Options := XLSDefaultFormulaEvaluationOptions;
  Options.AllowUnsafeFormulaCallbacks := True;
  Eval := Sheet.EvaluateFormulaAt(4, 2, '=WEBSERVICE(B1)', xlfrsA1, Options);

  // เวิร์กบุ๊กเปิดรับแล้ว แต่การประเมินข้อความที่อัปโหลดมาครั้งนี้ไม่ใช่
  Book.AllowUnsafeFormulaCallbacks := True;
  Options := XLSDefaultFormulaEvaluationOptions;   // flag กลับเป็น False อีกครั้ง
  Eval := Sheet.EvaluateFormulaAt(4, 2, UploadedFormula, xlfrsA1, Options);
  if Eval.Status = xlfeUnsafeFunctionDenied then
    LogRejected(Eval.Issue.Message);
end;

การสลับ property ของเวิร์กบุ๊กยัง mark dependency graph ให้สกปรกบนทั้งสอง engine ด้วย ถ้าไม่มีขั้นนี้ ผลลัพธ์ที่ cache ไว้จากช่วงที่ยังอนุญาต callback อาจถูกเสิร์ฟต่อหลังถูกเพิกถอนไปแล้ว หรือผล xlfeUnsafeFunctionDenied ที่ cache ไว้อาจอายุยืนกว่าการเปิดรับ status ใหม่ถูกต่อท้าย TXLSFormulaEvaluationStatus หลัง xlfeFailed จึงมี ordinal 10 และ ordinal เดิมทุกตัวคงค่าของมัน กฎต่อท้ายแบบเดียวกันใช้กับ field ของ options record และ getter กับ setter ของ IXLSWorkbook แม้ consumer ที่ build บนรุ่นเก่ากว่ายังต้อง recompile อยู่ดี

ข้อความสูตรที่ไม่รู้จักและไม่ปลอดภัยเป็นอย่างไรตอน save

การเก็บสูตรกับการรันสูตรตอนนี้เป็นสองคำถามแยกกัน และนโยบายรับสูตรตอบแค่คำถามแรก FormulaEntryPolicy บนคลาสเวิร์กบุ๊กทั้งสองแบบพก UnknownFunctionMode กับ UnknownNameMode ทั้งคู่ default เป็น xlfusmReject การ assign สูตรที่มีการเรียกไม่รู้จักผ่าน property Formula ธรรมดาจึงถูกปฏิเสธก่อนค่าเซลล์, cache สูตร หรือ dependency ใดจะเปลี่ยน ValidateFormulaEntry รายงานการตัดสินเดียวกันโดยไม่มี side effect เส้นทางที่เชื่อถือได้อย่างการโหลดไฟล์, การ copy และการแปลงฟอร์แมตข้ามนโยบายรับจากผู้ใช้ไป เพราะค่า default ที่เข้มงวดต้องไม่มีวันปฏิเสธสัญลักษณ์ที่อยู่ในไฟล์ที่คุณแค่กำลังเปิดอยู่แล้ว

var
  Policy: TXLSFormulaEntryPolicy;
begin
  Policy := Book.FormulaEntryPolicy;
  Policy.UnknownFunctionMode := xlfusmPreserve;   // โหมดเข้ากันได้
  Book.FormulaEntryPolicy := Policy;
  Sheet.Cells[3, 1].Formula := '=ACME_RATE(B3)';  // เก็บไว้ได้ แต่ไม่ได้รับอนุญาต
  Book.SaveAs('rates.xls');
end;

ใน BIFF8 คลาสสิก การเรียกที่ไม่รู้จักไม่มี token ของตัวเอง HotXLS จึงเขียนมันแบบที่ Excel เขียนฟังก์ชัน add-in สูตรจะได้ token PtgNameX ($59) ที่ entry XTI ของมันชี้ไปที่ SUPBOOK ของ add-in โดยตั้ง sheet index ทั้งคู่เป็น $FFFE ตามด้วย token ของ argument และ PtgFuncVar ที่พกเลขฟังก์ชัน 255 กับจำนวน argument ที่รวม slot ชื่อเข้าไปด้วย body ExternName ที่รองรับอยู่คือ byte ศูนย์หกตัว, byte ความยาวกับ flag Unicode, ชื่อฟังก์ชัน UTF-16 แล้วตามด้วยสูตรสอง byte คือ $1C $17 ซึ่งเป็น PtgErr ที่ถือ #REF! ตัวเขียนปฏิเสธชื่อที่ยาวเกิน 255 ตัวอักษร, argument เกิน 29 ตัว และปลายทาง BIFF5 วิธีที่ HotXLS จัดหมวดหมู่ entry SUPBOOK ของ add-in พวกนี้เทียบกับลิงก์เวิร์กบุ๊กภายนอก อธิบายไว้ในกฎการจัดหมวดหมู่ SUPBOOK และ XTI สำหรับ external link ของ BIFF XLSX เก็บข้อความฟังก์ชันดิบไว้ และ ODS เก็บสูตร msoxl: ของมัน ทุกฟอร์แมตไฟล์ที่ save =WEBSERVICE(...) ไว้จะเปิดกลับมาด้วยข้อความครบถ้วนและยังประเมินได้ xlfeUnsafeFunctionDenied เป็นค่าเริ่มต้น

HotXLS เขียนการเรียกสูตรที่ไม่รู้จักลง BIFF8 คลาสสิกอย่างไร: สูตรพก token PtgNameX ที่ entry XTI ชี้ไปที่ SUPBOOK ของ add-in โดย sheet index ทั้งคู่เป็น $FFFE ตามด้วย token argument และ PtgFuncVar เลขฟังก์ชัน 255 รองรับด้วย body ExternName ที่จบด้วยสูตรสอง byte $1C $17 เป็น PtgErr ถือ #REF!
XLSX เก็บข้อความฟังก์ชันดิบ และ ODS เก็บสูตร msoxl: ของมัน ไฟล์ที่ save =WEBSERVICE(...) ไว้จึงเปิดกลับมาด้วยข้อความครบถ้วนและยังประเมินได้ xlfeUnsafeFunctionDenied เป็นค่าเริ่มต้น

ถ้า pipeline ของคุณประเมินเวิร์กบุ๊กที่ตัวเองไม่ได้เขียนขึ้น จงปล่อย AllowUnsafeFormulaCallbacks ไว้ที่ False เก็บ handler ไว้บน allowlist อย่างชัดเจน และอนุญาต option ต่อการเรียกเฉพาะที่แหล่งสูตรเป็นของคุณ API callback, นโยบายรับสูตร และการประเมินฉบับเต็มมีเอกสารกำกับที่ HotXLS Delphi spreadsheet component