스프레드시트 엔진은 인수를 받아 먹을 함수를 부르기 전에 인수를 평가합니다. 그래서 =WEBSERVICE(AUDIT_TOKEN())을 담은 셀은 AUDIT_TOKEN을 먼저 실행하고, 호스트 애플리케이션이 사용자 정의 함수 핸들러로 AUDIT_TOKEN에 응답한다면 그 핸들러는 아무도 WEBSERVICE라는 단어를 살펴보기 전에 이미 로그를 남기고, 카운터를 올리고, 연결을 열어 둔 상태입니다. 핸들러 안의 블록리스트는 너무 늦고, 인수가 돌아온 뒤 바깥 이름을 검사하는 것도 늦습니다. 거부는 바깥 이름에서, 어떤 인수도 만지기 전에, 호스트가 쓴 코드가 차례를 받기 전에 결정되어야 합니다
이게 물리는 설정은 평범합니다. 서비스가 업로드된 XLS나 XLSX 파일을 받아 서버에서 재계산하고 합계 몇 개를 돌려줍니다. 몇 년 전 누군가 비즈니스 함수 두 개를 위해 사용자 함수 핸들러를 등록했고, 가는 길에 모르는 이름을 플러그인 테이블로 전달하는 catch-all 가지가 자라 났습니다. 팀 누구도 셀에 =WEBSERVICE(...)를 입력한 적이 없습니다. 업로더가 그랬죠. 열기, 재계산, 저장을 통해 그 수식을 그대로 보존하는 것은 파일 충실성 작업이고, 소켓을 열거나 파일을 쓸 수 있는 호스트 코드에 닿게 두는 것은 인가 결정입니다. 이 둘은 다른 소유자를 필요로 합니다
보존된 수식이 Delphi 콜백에 닿는 경로
HotXLS 수식 엔진에서 계산기가 네이티브로 평가하지 못하는 호출은 하나의 공유 디스패치 체인으로 떨어지고, 위험한 이름은 두 갈래 길로 그곳에 도착합니다. 한 갈래는 파서는 알지만 계산기에 구현이 없는 빌트인입니다. CALL과 REGISTER.ID는 실제 Excel 함수 이름이라 깨끗하게 파싱된 뒤 여러분의 DISCOUNT나 REGIONRATE와 같은 폴백에 착지합니다. 다른 갈래는 파서가 전혀 모르는 이름, 이를 테면 WEBSERVICE나 DDE로, 레지스트리의 같은 이름 항목과 맞거나 catch-all 이벤트 핸들러가 가져갈 수 있습니다. 그 폴백의 역학은 HotXLS가 OnUserFunction으로 커스텀 함수를 처리하는 방식에서 다룹니다
체인의 순서가 '모른다'의 의미를 결정합니다. 이름은 무엇보다 먼저 LAMBDA 장치로 갑니다. LET가 묶은 클로저나, 수식이 LAMBDA인 정의 이름이 그 대상인데 둘 다 HotXLS 수식 엔진의 클로저 지원에 설명돼 있습니다. 그다음은 RegisterUserFunction으로 등록된 통합 문서 로컬 함수, 이어서 클래스 메서드 TXLSWorkbook.RegisterGlobalUserFunction의 프로세스 전역 함수, 마지막이 이벤트입니다. OnUserFunctionEx는 할당돼 있으면 실행되고 OnUserFunction은 OnUserFunctionEx가 없을 때만 실행됩니다. 진짜 모르는 이름은 모든 단계가 사양하면 #NAME?이 되고, 알려졌지만 구현이 없는 빌트인은 대신 미지원 함수를 보고합니다
LAMBDA 조회 다음의 모든 단계는 여러분이 쓴 코드에 제어를 넘깁니다. 그래서 HotXLS는 안전 검사를 어느 한 단계 안이 아니라 여러분 코드에 닿는 모든 단계 앞에 두고, 검사가 세 파일 리더가 아니라 계산기 자체에 자리합니다. Classic BIFF 토큰에서, XLSX 텍스트에서, ODS msoxl: 수식에서 실려 온 수식은 결국 같은 파싱된 식이 되므로, 검사 하나가 세 포맷을 모두 커버하고 변환된 통합 문서도 원본과 똑같이 동작합니다
HotXLS는 기본적으로 어떤 수식 이름을 거부할까?
HotXLS는 콜백이 명시적으로 허용되지 않는 한 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과 그 매크로 언어에서 이 이름들은 네이티브 코드를 실어 오거나, 네트워크에 닿거나, 다른 프로세스와 통신하거나, 파일 시스템을 만지는 이름들입니다. 비교에 앞서 엔진은 주변 공백을 잘라 내고 이름을 대문자로 바꾸며 _XLFN.이나 _XLWS. 접두사 하나를 떼어 냅니다. 더 새 Excel 빌드가 쓴 _xlfn.webservice로 저장된 파일은 맨철자와 정확히 같이 걸립니다
매치는 정확 일치라서 양날입니다. MYWEBSERVICE로 등록한 핸들러는 영향받지 않습니다. 우연히 OPEN이나 RUN이라 불리는 사내 UDF는 기본값으로 거부되고, 유혹적인 수리법은 그 하나의 이름을 되살리려고 통합 문서의 AllowUnsafeFormulaCallbacks를 True로 두는 것입니다. 대신 함수 이름을 바꾸세요. 사내 이름 하나를 구하려고 통합 문서 전체를 옵트인하면 그 파일의 모든 수식에 대해 CALL, DDE, FILE.DELETE도 다시 활성화됩니다. 여러분이 쓰지 않은 수식까지 포함해서요
거부 집합은 여러분 자체 핸들러를 울타리 안에 넣지도 않습니다. catch-all 가지가 임의의 플러그인 이름을 실행한다면, 게이트는 잘 알려진 위험한 20개는 막고 나머지는 전부 여러분 코드로 통과시킵니다. 오래가는 답은 catch-all 핸들러 쓰기를 그만두는 것입니다. RegisterUserFunction으로 함수마다 이름을 등록하면 레지스트리 자체가 allowlist가 되고, 수식 진입 검사도 의미 있게 유지됩니다. OnUserFunction이나 OnUserFunctionEx가 할당되면 엔진은 이벤트가 어떤 이름을 가져갈지 알 수 없으므로, 진입 검증은 모르는 함수 이름 검사를 아예 접습니다
procedure TImportService.WireFunctions;
begin
// 이름마다 등록 하나: 레지스트리가 곧 allowlist입니다
FBook.RegisterUserFunction('DISCOUNT', HandleDiscount);
FBook.RegisterUserFunction('REGIONRATE', HandleRegionRate);
// 두 이벤트를 모두 할당하지 않고 두면 모르는 이름은 여전히
// 진입에서 거부되고 평가에서는 #NAME?이 됩니다
FBook.OnUserFunction := nil;
FBook.OnUserFunctionEx := nil;
end;
procedure TImportService.HandleDiscount(Sender: TObject;
const FunctionName: WideString; const Args: Variant;
var Value: Variant; var Handled: Boolean);
begin
// Args는 이미 평가된 인수의 0 기반 variant 배열입니다
if VarIsArray(Args) and (VarArrayHighBound(Args, 1) = 1) then
begin
Value := Args[0] * (1 - Args[1]);
Handled := True;
end;
end;
게이트는 왜 인수 평가 전에 돌아야 할까?
HotXLS는 함수 이름을 먼저 검사하고 인수 배열을 만들기 전에, resolver나 어느 레지스트리에 묻기 전에, 심지어 핸들러가 아예 할당되지 않았다는 것을 알아차리기 전에 호출을 거부합니다. 이 순서가 서두의 중첩 수식을 무력화하는 지점입니다. 바깥의 WEBSERVICE는 AUDIT_TOKEN이 아직 평가되지 않은 자식 노드로 남아 있을 때 거부되므로, 호스트 핸들러는 한 번도 실행되지 않습니다. 순서의 마지막 부분은 로깅을 위해 중요합니다. 핸들러가 없는 통합 문서와 다섯 개인 통합 문서는 =WEBSERVICE(...)에 대해 같은 상태를 보고하므로, 나중에 누군가 핸들러를 물려도 거부 로그의 의미가 변하지 않습니다
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'); // 호스트 코드의 부작용
Value := 'token-42';
Handled := True;
end;
end;
procedure TImportService.ProbeNestedCall;
var
Sheet: TXLSWorksheet;
Eval: TXLSFormulaEvaluationResult;
begin
FBook.OnUserFunction := HandleUdf;
Sheet := FBook.Sheets[1]; // 워크시트 인덱스는 1부터 시작
Eval := Sheet.EvaluateFormulaAt(1, 1, '=WEBSERVICE(AUDIT_TOKEN())');
// 기대: Eval.Status = xlfeUnsafeFunctionDenied, Eval.Value = Null,
// Eval.Issue.NativeCode = -106, FAuditLog.Count = 0
end;
네이티브 코드 -106은 공개 상수 lxErrorUnsafeFunctionDenied이고, Eval.Issue.Message는 "The formula callback is blocked by the safety policy"를 읽어 줍니다. xlfeUnsafeFunctionDenied는 xlfeFailed 뒤에 이어지는 TXLSFormulaEvaluationStatus의 마지막 멤버입니다. 상태를 case와 일반 실패용 else 가지로 매핑하는 코드는 상태를 명시적으로 이름 대지 않는 한 거부를 '평가 실패'로 분류하는데, 업로드 파이프라인에서는 그 결정이 업로더에게 보고할 만한 단 하나의 결말입니다
통합 문서 기본값과 호출별 TXLSFormulaEvaluationOptions
통합 문서 플래그가 기본값을 정하고 호출별 옵션이 최종 결정을 내립니다. TXLSWorkbook.AllowUnsafeFormulaCallbacks와 TXLSXWorkbook.AllowUnsafeFormulaCallbacks는 False로 시작하며 평범한 재계산, Calculate, 세 인수짜리 EvaluateFormulaAt, 평가 템플릿의 EvaluateAt, 데이터 유효성 목록 수식, 읽기 전용 뷰, 그리고 XLSX에서는 병렬 재계산 풀의 모든 워커를 다룹니다. 명시적 TXLSFormulaEvaluationOptions 레코드를 받는 진입점은 Options.AllowUnsafeFormulaCallbacks를 그 호출의 판정으로 쓰고 통합 문서 프로퍼티와 OR하지 않습니다
이 비대칭은 의도된 것이고 옳은 기본값입니다. 신뢰하는 내부 작업은 통합 문서 전체를 뒤집지 않고 RTD 조회 하나를 승인할 수 있고, 전역으로 옵트인된 통합 문서도 민감한 평가 하나를 거부로 밀어넣을 수 있습니다. XLSDefaultFormulaEvaluationOptions는 플래그를 False로 돌려주므로, 그 함수로 레코드를 만들면 일부러 설정하지 않는 한 거부입니다. 플래그를 완전히 무시하는 경로가 하나 있습니다. 렌더링을 위해 Classic 조건부 서식 규칙을 평가할 때는 통합 문서가 뭐라 하든 콜백이 거부됩니다
var
Sheet: TXLSWorksheet;
Options: TXLSFormulaEvaluationOptions;
Eval: TXLSFormulaEvaluationResult;
begin
Sheet := FBook.Sheets[1];
// 통합 문서는 잠긴 채 유지, 신뢰하는 호출 하나만 통과
FBook.AllowUnsafeFormulaCallbacks := False;
Options := XLSDefaultFormulaEvaluationOptions;
Options.AllowUnsafeFormulaCallbacks := True;
Eval := Sheet.EvaluateFormulaAt(4, 2, '=WEBSERVICE(B1)', xlfrsA1, Options);
// 통합 문서는 옵트인, 그러나 업로드 텍스트의 이 평가는 그렇지 않음
FBook.AllowUnsafeFormulaCallbacks := True;
Options := XLSDefaultFormulaEvaluationOptions; // 플래그는 다시 False
Eval := Sheet.EvaluateFormulaAt(4, 2, UploadedFormula, xlfrsA1, Options);
if Eval.Status = xlfeUnsafeFunctionDenied then
LogRejected(Eval.Issue.Message); // LogRejected는 자체 루틴
end;
통합 문서 프로퍼티를 바꾸면 두 통합 문서 클래스 모두에서 의존성 그래프의 모든 수식이 dirty로 표시됩니다. 이 단계가 없으면 콜백이 허용되던 때에 캐시된 값이 허용이 철회된 뒤에도 나돌거나, 캐시된 거부가 옵트인보다 오래살 수 있습니다. 이미 갖고 있는 값으로 프로퍼티를 설정하는 것은 no-op이고 캐시는 그대로 두므로, 루프 안에서 토글해도 값이 실제로 바뀔 때만 전체 재계산 비용이 듭니다
저장 시 모르는 수식과 위험한 수식 텍스트는 어떻게 될까?
수식을 보존하는 것과 수식을 실행하는 것은 별개의 질문이고, 진입 정책은 첫 번째에만 답합니다. 어느 쪽 통합 문서 클래스의 FormulaEntryPolicy도 UnknownFunctionMode와 UnknownNameMode를 갖고 둘 다 xlfusmReject가 기본입니다. 모르는 호출을 담은 수식을 평범한 Formula 프로퍼티로 할당하면 셀 값, 수식 캐시, 의존성이 바뀌기 전에 "Formula entry rejected: unknown function" 메시지의 EArgumentException이 발생합니다. ValidateFormulaEntry는 부작용 없이 같은 결정을 레코드로 돌려줍니다. 파일 로드, 복사, 포맷 변환 같은 신뢰 경로는 사용자 진입 정책을 거치지 않습니다. 엄격한 기본값이 막 열고 있는 파일에 이미 존재하는 심볼을 거부해서는 안 되기 때문입니다
var
Sheet: TXLSWorksheet;
Policy: TXLSFormulaEntryPolicy;
Check: TXLSFormulaEntryValidationResult;
begin
Sheet := FBook.Sheets[1];
Check := Sheet.ValidateFormulaEntry(3, 1, '=ACME_RATE(B3)');
// 기본 정책, ACME_RATE 핸들러 없음:
// Check.Issue = xlfeiUnknownFunction, Check.Action = xlfeaReject
Policy := FBook.FormulaEntryPolicy;
Policy.UnknownFunctionMode := xlfusmPreserve; // 호환 진입
FBook.FormulaEntryPolicy := Policy;
Sheet.Cells[3, 1].Formula := '=ACME_RATE(B3)'; // 저장은 되지만 승인된 것은 아님
FBook.SaveAs('rates.xls');
end;
Classic BIFF8에는 임의의 함수 이름을 위한 토큰이 없으므로, HotXLS는 모르는 호출을 Excel이 애드인 함수를 쓰는 방식으로 기록합니다. 수식은 XTI 항목이 두 시트 인덱스 모두 $FFFE로 설정된 애드인 SUPBOOK을 가리키는 PtgNameX 토큰($59)을 얻고, 뒤이어 인수 토큰들과 이름 슬롯까지 센 인수 개수를 실은 함수 번호 255의 PtgFuncVar가 따라옵니다. 뒷받침하는 ExternName 본문은 6개의 0 바이트, 길이 바이트 하나, 유니코드 플래그, UTF-16 함수 이름, 그리고 #REF!를 담은 PtgErr인 2바이트 수식 $1C $17로 끝납니다. 라이터는 최대 29개 인수와 함께 1에서 255자의 이름을 받아들이며 BIFF8만 대상입니다. BIFF5 저장은 이 호출을 실을 수 없습니다. 이런 애드인 SUPBOOK 항목이 외부 통합 문서 링크 옆에 어떻게 앉는지는 BIFF 외부 링크의 SUPBOOK과 XTI 분류 규칙에 설명돼 있습니다
XLSX는 함수 텍스트 원본을 유지하고 ODS는 msoxl: 수식을 유지합니다. 세 포맷 모두에서 =WEBSERVICE(...)를 저장한 파일은 이름이 온전한 채로 다시 열리고, 기본 평가는 호스트를 부르지 않은 채 여전히 xlfeUnsafeFunctionDenied를 반환합니다. 저장은 수식의 권한을 절대 승격시키지 않습니다. 왕복 전에 거부됐던 텍스트는 왕복 뒤에도 거부됩니다
자기가 작성하지 않은 통합 문서를 재계산하는 서버라면 버티는 설정은 짧습니다. AllowUnsafeFormulaCallbacks를 False로 두고, catch-all 이벤트 대신 함수를 이름으로 등록하고, 호출별 옵션은 수식 텍스트가 여러분 코드에서 나오는 곳에만 넘기세요. 콜백, 진입 정책, 평가 레퍼런스는 HotXLS Delphi 스프레드시트 컴포넌트 문서에 실려 나갑니다