מאמר טכני

Callback מסוכנים בנוסחאות HotXLS: סינון CALL ו-WEBSERVICE

‏HotXLS מסרב לנתב 20 שמות נוסחה מסוכנים, ובהם CALL, REGISTER.ID, WEBSERVICE ו-DDE, אל callback של פונקציות המשתמש שלכם ב-Delphi אלא אם בחרתם להרשות זאת. המאפיין AllowUnsafeFormulaCallbacks של החוברת ברירת המחדל שלו False, הבדיקה רצה לפני שארגומנט כלשהו מוערך, וקריאה נדחית מדווחת xlfeUnsafeFunctionDenied בלי להפעיל אף handler אחד

התרחיש שהפך את זה להכרחי הוא עלוב למדי. שירות מקבל קבצי XLS או XLSX שהועלו אליו, מחשב אותם מחדש בצד השרת וקורא בחזרה כמה סכומים. יישום המארח רשם handler של OnUserFunction לפני שנים עבור שתי-שלוש פונקציות עסקיות, ואיפשהו בדרך צמח בו ענף catch-all שמעביר כל מה שהוא לא מזהה לטבלת plugins. אף אחד בצוות לא הקליד פעם =WEBSERVICE(...) לתא. המעלה כן. לשמור את הנוסחה הזאת שלמה דרך פתיחה, חישוב חוזר ושמירה זו תכונת נאמנות קובץ. לתת לה להגיע לקוד מארח שיכול לפתוח sockets או קבצים זו החלטת הרשאה, ועד ש-HotXLS הפריד את השניים, הספרייה קיבלה את ההחלטה הזאת בשקט בשמכם

איך שמירת נוסחה הפכה להרשאה להריץ אותה?

שורש הבעיה היה נתיב fallback אחד. HotXLS מפרס כל שם פונקציה של Excel שהוא מכיר, אבל לא לכל שם מוכר יש מימוש במנוע החישוב. פונקציות מובנות שמזוהות אך לא ממומשות נשרו פעם לאותו fallback של פונקציות מוגדרות-משתמש שאליו נופלות שמות מותאמים אמיתיים, כך ש-CALL ו-REGISTER.ID חלקו נתיב ניתוב עם ה-DISCOUNT או ה-REGIONRATE שלכם. שמות לא מוכרים כמו WEBSERVICE או DDE יכלו באותה מידה להתאים לכניסה בעלת אותו שם ברישום החוברת, ברישום רמת התהליך או ב-event handler. המכניקה של ה-fallback הזה מכוסה במאמר על איך HotXLS פותר פונקציות מותאמות דרך OnUserFunction; הבעיה הייתה ששום דבר בנתיב הזה לא שאל אם השם עצמו הוא כזה שמארח שפוי יסכים להריץ

סדר הניתוב משנה לגבי מה ש"לא מוכר" פירושו כאן. קריאה שהמנוע לא יכול להעריך ממילא מוצעת בתורה לכריכות לקסיקליות של LAMBDA ו-LET, ש-תמיכת ה-closures במנוע הנוסחאות של HotXLS פותרת ראשונה, ואז לפונקציות מקומיות לחוברת שנרשמו עם RegisterUserFunction, אחר כך לפונקציות רמת תהליך מ-TXLSWorkbook.RegisterGlobalUserFunction, ולבסוף לאירועים OnUserFunction ו-OnUserFunctionEx. רק כשכולם דוחים הופכת פונקציה לא מוכרת באמת ל-#NAME?. כל שלב אחרי חיפוש ה-lambda מעביר את השליטה לקוד שאתם כתבתם, וזה בדיוק הסיבה שבדיקת הבטיחות חייבת לשבת לפני כל השרשרת ולא בתוך אף handler בודד

איך HotXLS סונן callback מסוכנים בנוסחאות: GetValueItemUserFunction בודק את השם לפני שמערך הארגומנטים נבנה או שכל פותר רץ, כך ש-=WEBSERVICE(AUDIT_TOKEN()) מקונן יוצא עם lxErrorUnsafeFunctionDenied ויומן ה-audit נשאר ריק, בזמן שקריאות בטוחות הולכות בשרשרת מכריכות LAMBDA ו-LET ועד OnUserFunction
רק כשכל שלב דוחה הופכת פונקציה לא מוכרת באמת ל-#NAME?, ומכאן שבדיקת הבטיחות יושבת לפני כל השרשרת ולא בתוך אף handler בודד

אילו שמות פונקציה HotXLS חוסם כברירת מחדל?

‏XLSFormulaCallbackIsUnsafe ב-lxCalc.pas מחזיק קבוצת דחייה קבועה של 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 או בשפת המאקרו שלו טוענים קוד native, מגיעים לרשת, מדברים עם תהליכים אחרים או נוגעים במערכת הקבצים. לפני ההשוואה הפונקציה חותכת רווחים משני הצדדים, ממירה את השם לאותיות גדולות וקורעת קידומת _XLFN. או _XLWS. בודדת, כך ש-_xlfn.webservice שנכתב על ידי build חדש יותר של Excel נתפס בדיוק כמו האיות החשוף. הרשימה יושבת בגבול המחשבון ולא בפרסרים של Classic, XLSX ו-ODS, מה שמשאיר AST אחד, זרם טוקנים אחד של BIFF וחוברת אחת מומרת מתנהגים זהים

איך HotXLS מנרמל שם פונקציה לפני ההשוואה מול קבוצת הדחייה של callback מסוכנים: רווחים נחתכים, השם מומר לאותיות גדולות וקידומת _XLFN. או _XLWS. בודדת נקרעת כך ש-_xlfn.webservice נתפס כמו האיות החשוף, ואז התוצאה מותאמת בהתאמה מדויקת מול קבוצת הדחייה הקבועה של 20 השמות ב-lxCalc.pas
הרשימה מכסה את השמות שטוענים קוד native, מגיעים לרשת, מדברים עם תהליכים אחרים או נוגעים במערכת הקבצים, מ-DDE, CALL ו-WEBSERVICE ועד FWRITE ו-FILE.DELETE

שני קצוות שווים היכרות לפני שסומכים על זה. ההתאמה מדויקת, כך ש-handler שרשמתם בתור MYWEBSERVICE אינו מושפע, ולהפך, UDF פנימי לגיטימי שקוראים לו במקרה OPEN או RUN נדחה כעת כברירת מחדל. קבוצת הדחייה גם אינה sandbox עבור ה-handlers שלכם. אם ענף ה-catch-all שלכם מריץ שמות plugins שרירותיים, השער עוצר את המסוכנים המפורסמים ושום דבר נוסף; התיקון העמיד עדיין הוא handler שמתאים רשימת הרשאות מפורשת עם SameText ומשאיר את Handled על False לכל מה שאינו שלו

למה השער חייב לרוץ לפני הערכת הארגומנטים?

שער שנפתח רק אחרי שהארגומנטים חושבו מאחר מדי, כי הארגומנטים עצמם יכולים לקרוא לקוד שלכם. GetValueItemUserFunction בודק את השם קודם ויוצא עם lxErrorUnsafeFunctionDenied לפני שהוא בונה את מערך הארגומנטים, לפני שהוא נעזר בפותר או באחד הרישומים, ואפילו לפני שהוא מבחין שלא הוקצה handler בכלל. הסדר הזה הוא מה שמכריע את המקרה המקונן להלן, שבו את הקריאה החיצונית היו דוחים בכל מקרה, אבל 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 עדיין ריק

ברירת מחדל של חוברת מול TXLSFormulaEvaluationOptions לכל קריאה

דגל החוברת הוא ברירת המחדל והאפשרות לכל קריאה היא המילה האחרונה. TXLSWorkbook.AllowUnsafeFormulaCallbacks ו-TXLSXWorkbook.AllowUnsafeFormulaCallbacks שולטים בחישוב חוזר רגיל, ב-Calculate, ב-EvaluateFormulaAt עם שני ארגומנטים, בתבניות הערכה, בתצוגות לקריאה בלבד וב-XLSX בכל worker במאגר החישוב החוזר המקבילי. כל נקודת כניסה שמקבלת רשומת TXLSFormulaEvaluationOptions מפורשת מקבלת את Options.AllowUnsafeFormulaCallbacks כפסק הדין לאותה קריאה ולא מבצעת עליה OR עם המאפיין של החוברת. האסימטריה הזאת מכוונת: עבודה פנימית מהימנה יכולה לאשר חיפוש RTD אחד בלי להפוך את כל החוברת, וחוברת שהוסכמה גלובלית יכולה עדיין לכפות הערכה רגישה בחזרה לדחייה

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;   // הדגל שוב False
  Eval := Sheet.EvaluateFormulaAt(4, 2, UploadedFormula, xlfrsA1, Options);
  if Eval.Status = xlfeUnsafeFunctionDenied then
    LogRejected(Eval.Issue.Message);
end;

שינוי המאפיין של החוברת מסמן גם את גרף התלויות כמלוכלך בשני המנועים. בלי הצעד הזה תוצאה ממטמון שחושבה בזמן שה-callbacks היו מורשים יכלה להימסר אחרי שנשללו, או תוצאת xlfeUnsafeFunctionDenied ממטמון יכלה לשרוד מעבר להסכמה. הסטטוס החדש צורף ל-TXLSFormulaEvaluationStatus אחרי xlfeFailed, כך שהערך הסידורי שלו הוא 10 ולכל ערך סידורי קיים נשמר ערכו; אותו כלל של צירוף בזנב חל על שדה רשומת האפשרויות ועל ה-getter וה-setter של IXLSWorkbook, אם כי צרכן שנבנה מול גרסה ישנה יותר עדיין זקוק להידור מחדש

מה קורה לטקסט של נוסחה לא מוכרת ומסוכנת בשמירה?

לשמור נוסחה ולהריץ אותה הן כעת שתי שאלות נפרדות, ומדיניות הכניסה עונה רק על הראשונה. FormulaEntryPolicy בכל אחת ממחלקות החוברת נושאת UnknownFunctionMode ו-UnknownNameMode, שתיהן ברירת מחדל xlfusmReject, כך שהקצאת נוסחה עם קריאה לא מוכרת דרך המאפיין הרגיל Formula נדחית לפני שערך התא, מטמון הנוסחאות או התלויות משתנים. ValidateFormulaEntry מדווח על אותה החלטה בלי side effects. נתיבים מהימנים כמו טעינת קובץ, העתקה והמרת פורמט עוקפים את מדיניות הכניסה של המשתמש, כי ברירת מחדל נוקשה לעולם לא צריכה לדחות סמלים שכבר קיימים בקובץ שאתה רק פותח

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 הקלאסי לקריאה לא מוכרת אין טוקן משלה, ולכן HotXLS כותב אותה באופן ש-Excel כותב פונקציות add-in. הנוסחה מקבלת טוקן PtgNameX ($59) שהכניסה ה-XTI שלו מצביעה על ה-SUPBOOK של ה-add-in כששני אינדקסי הגיליונות מוגדרים $FFFE, ואחריו טוקני הארגומנטים ו-PtgFuncVar שנושא מספר פונקציה 255 ומספר ארגומנטים שכולל את משבצת השם. הגוף התומך של ExternName הוא שישה בייטי אפס, בייט אורך ודגל Unicode, שם הפונקציה ב-UTF-16, ואז נוסחה בת שני בייטים של $1C $17, כלומר PtgErr שמחזיק #REF!. הכותב מסרב לשמות ארוכים מ-255 תווים, יותר מ-29 ארגומנטים, וליעד ה-BIFF5. איך HotXLS מסווג את כניסות ה-SUPBOOK של ה-add-in לצד קישורי חוברות חיצוניים מוסבר במאמר על כללי הסיווג של SUPBOOK ו-XTI לקישורים חיצוניים ב-BIFF. XLSX שומר את טקסט הפונקציה הגולמי ו-ODS שומר את הנוסחה שלו עם msoxl:, ובכל פורמט קובץ ששמר =WEBSERVICE(...) נפתח מחדש עם הטקסט שלם ועדיין מוערך ל-xlfeUnsafeFunctionDenied כברירת מחדל

איך HotXLS כותב קריאה לנוסחה לא מוכרת ל-BIFF8 קלאסי: הנוסחה נושאת טוקן PtgNameX שהכניסה ה-XTI שלו מצביעה על ה-SUPBOOK של ה-add-in עם שני אינדקסי גיליונות $FFFE, ואז טוקני הארגומנטים ו-PtgFuncVar עם מספר פונקציה 255, על גבי גוף ExternName שמסתיים ב-$1C $17 בן שני בייטים, PtgErr שמחזיק #REF!
XLSX שומר את טקסט הפונקציה הגולמי ו-ODS שומר את הנוסחה שלו עם msoxl:, כך שקובץ ששמר =WEBSERVICE(...) נפתח מחדש עם הטקסט שלם ועדיין מוערך ל-xlfeUnsafeFunctionDenied כברירת מחדל

אם ה-pipeline שלכם מעריך חוברות שהוא לא כתב, השאירו את AllowUnsafeFormulaCallbacks על False, החזיקו handlers על רשימת הרשאות מפורשת, והעניקו אפשרויות לכל קריאה רק היכן שמקור הנוסחה הוא שלכם. ממשק ה-API המלא של ה-callback, מדיניות הכניסה וההערכה מתועד עם רכיב הגיליונות HotXLS Delphi