在你自己的应用里预览一份不可信的 PDF 是一个关于执行的决定,而要紧的部分不是查看器的外壳,是这个面板自己拒绝去做的那些事。别把文件写到磁盘上。别让它的链接调起外部程序。别把路径交给它的附件。一份敌意文档造成的损害,大多不是来自引擎漏洞,而是来自查看器拿攻击者提供的输入去做那些再平常不过的事:打开一个指向 UNC 共享的 file:// 链接从而泄漏 NTLM 凭据、在临时目录里留下一份暂存副本、把嵌入的载荷复制到某个文件名字符串指定的任何地方。PDFium Component 是一个面向 Delphi、C++Builder 和 Lazarus 的源码级 PDF 查看器,它把相关的开关都放在你伸手够得着的地方:一个在加载期干掉脚本的标志、可以被你否决的链接点击事件、要经过你自己代码的附件访问,以及你可以读取的权限位。下面的顺序跟着一份文档走,从它落地的那一刻,到用户点击其中某样东西的那一刻
预览面板的威胁模型
对"安全预览"到底买到了什么要诚实。不管你做什么,渲染器都要解析不可信的字节,而引擎自身的加固是你脚下的地板。地板之上的一切都是应用策略:脚本是否初始化、点一下链接会发生什么、嵌入文件能否落到磁盘、剪贴板和打印机是门还是墙。有一样东西要趁早勾销,就是引擎的 FPDF_SetSandBoxPolicy 开关。引擎的大部分限制是编译进去的,这个开关实际上改变不了多少,而把你的隔离故事里的任何一份预算押在它身上,只会造出一种"已经做了点什么"的错觉。当输入真正带着敌意时,比如一个公开的上传门户,唯一真正的隔离是在一个独立的低权限进程里渲染,然后把位图送到界面。进程内的标志是策略,它们不是围栏
有两个面很容易被忘掉,恰恰因为从来没有哪次点击碰到它们。第一个是临时文件。如果你的流水线在预览之前把来件文档暂存到磁盘,那些暂存副本会活过整个会话,除非有什么东西可验证地把它们删掉,而一份"能从临时目录里恢复出来"的文件,已经悄悄击穿了面板本身所强制的每一项控制。改用 TPdfStreamAdapter 从内存加载,让那些敌意字节根本拿不到属于自己的路径。第二个是剪贴板。一个允许选中并复制的预览,已经把文档导出去了,一屏一屏地导,而任何链接拦截都抓不住这件事
在加载时干掉 JavaScript,而不是在界面上
PDFium Component 中的文档 JavaScript 只与表单填充环境一起初始化。因此用 FormFill := False 加载,是从根上禁用脚本,而不是压制它的症状:
procedure TPreviewPane.LoadUntrusted(const FilePath: string);
begin
Pdf.FileName := FilePath;
Pdf.FormFill := False; // 没有表单环境,也就没有 JavaScript 引擎
Pdf.Active := True;
FPermissions := Pdf.Permissions; // 原始标志字;全位置一表示不受限
end;
这个取舍是真实存在的,它属于你的规格书。关掉表单填充之后,合法的 AcroForm 交互和校验脚本也一起没了;字段会以上次保存的外观渲染出来,但不能编辑。对一个预览面板来说这通常是对的选择,因为预览意味着看,不是填。但如果同一个窗口还兼作可信内部文档的表单填写界面,答案是两条加载路径,中间夹一个明确的信任判断,而不是一条路径配一个折中设置——对敌意场景太松,对可信场景又太紧。这条分界的表单填写那一侧有它自己的陷阱,在表单字段导航与外观重生成里讲过
链接:默认处理器会调起外部程序
放着不管,链接点击会直奔操作系统。查看器默认的 LinkOptions 包含 loAutoOpenURI,那正是等着发生的 file:// 转 UNC 共享泄漏。两个事件构成了咽喉要道:OnWebLinkClick 对应页面文本中检测到的 URL,OnAnnotationLinkClick 对应携带 URI 或启动动作的链接注解。在两者中都无条件地、在做任何判断之前先设 Handled := True,然后只把策略允许的重新放行。作为第二层,对敌意输入要把 loAutoOpenURI 从 LinkOptions 里去掉,并确保默认关闭的 loAutoLaunch 绝不会通过一份复制来的配置悄悄溜回来:
procedure TPreviewPane.PdfViewWebLinkClick(Sender: TObject;
const Url: WString; var Handled: Boolean);
begin
Handled := True; // 绝不落到默认的外部调起行为上
if (AnsiStartsText('https://', Url) or AnsiStartsText('http://', Url))
and HostIsAllowed(Url) then
OpenInBrowser(Url)
else
FAudit.LogBlockedLink(FDocumentId, Url);
end;
有两个细节决定这道防线是否真的成立。第一,协议检查必须是在任何解析之前对原始字符串做的前缀检查,因为 file://、UNC 路径和各种冷门协议恰恰是那些能搞崩一个天真 URL 解析器、或者从一个过于热心地做归一化的解析器里溜过去的值。第二,把每一次拦截连同文档身份一起记下来。零星几条被拦的 file:// 链接是背景噪音;短时间内在许多来件文档上出现一串这样的链接,就是一起事故,而你的安全团队更愿意从你这里听说,而不是从别处
附件:扩展名策略与不是你选的那个文件名
PDF 是一种容器,而 AttachmentCount 加上 AttachmentName[] 属性能在任何东西碰到磁盘之前告诉你它携带了什么。这里有两项互相独立的控制要紧,而只有其中一项是显而易见的。显而易见的那项是类型策略:一份允许被导出的扩展名白名单。微妙的那项是:附件的名字就是攻击者控制的数据,没有例外。一个形如 ..\..\Startup\update.exe 的嵌入名字,能把一次粗心的保存变成一次路径穿越,把一个可执行文件丢进 Windows 会在登录时运行的文件夹。组件通过 Attachment[] 把载荷以字节交给你,让你的代码去选路径,所以要用一个净化过的基本文件名来构造那条路径,绝不用原始的嵌入字符串:
procedure TPreviewPane.ExportAttachment(Index: Integer; const TargetDir: string);
var
RawName, SafeName, Ext: string;
Data: TBytes;
begin
RawName := string(Pdf.AttachmentName[Index]);
SafeName := ExtractFileName(RawName); // 剥掉任何路径成分
Ext := LowerCase(ExtractFileExt(SafeName));
if not FAllowedExt.Contains(Ext) then // 白名单,不是黑名单
raise EPreviewPolicy.CreateFmt('Attachment type %s blocked by policy', [Ext]);
Data := Pdf.Attachment[Index]; // 以原始字节形式取出嵌入载荷
TFile.WriteAllBytes(
IncludeTrailingPathDelimiter(TargetDir) + SafeName, Data);
end;
要偏向白名单这个方向。一份"危险"扩展名的黑名单,是一场你会在某人把你从没听说过的扩展名武器化的那天输掉的赛跑;而一份只含 .pdf、.png 和 .csv 的白名单是失败即拒的
加密权限到底承诺了什么
ISO 32000-1 的标准安全处理器为打印、内容复制和修改编码了权限标志,而 Permissions 与 UserPermissions 属性在文档打开之后把它们以原始位掩码的形式浮出来。ISO 32000-1 表 22 定义了这些位,而一份未加密的文件会报告所有位都置一。去读它们、在你的命令层里尊重它们,但要清楚它们是什么。对一份用所有者密码加密、用户密码为空的文档来说,内容在打开时就完全解密了,而那些标志是对合规查看器的一个请求,不是一套强制机制。这带来两个后果,而且它们方向相反。绝不要把权限标志当作用户所收到文档的一项安全属性呈现给他们,因为它不是。与此同时,即便一般性复制(第 5 位)被拒绝,也要尊重无障碍提取位(第 10 位);屏幕阅读器的访问在权限模型里是被特意单独划出来的,以"复制关掉了"为由把它一并剥掉,只会破坏辅助技术而换不来任何安全收益
被拒绝的动作要在命令层强制,而不是靠藏起工具栏按钮。Ctrl+C、右键菜单和拖拽选中都能绕过工具栏;而复制命令内部的一道权限检查什么也绕不过
对于确实需要用户密码的文档,要在 Active := True 之前赋值 Password,并把这个值当作它本来的秘密对待:按会话从你的凭据库取出,别让它进日志和崩溃报告,也绝不要把它和文档存在一起。一个"为了方便"缓存密码的预览面板,已经悄悄变成了一个密码数据库,却一样保护措施都没有
打印值得单独做一个决定,而不是承接复制规则最后落在哪儿。一份纸质打印件按定义就是无法审计的,可一刀切地禁止打印又往往把用户推向截图,而截图在每一个维度上都更糟。一个常见的折中是允许打印,但在每一页上盖上用户身份和时间戳,并在打印命令内部强制执行。只是要对它抱有正确的期待:水印是威慑和归因,它不是防止
接收环节本该已经告诉你的事
当文件带着一份已经附好的档案出现时,预览面板能做出更好的决定:加没加密、有没有 JavaScript、附件清单、表单类型。那趟检视属于查看器的上游,而构建 PDF 接收审查工作台里的做法,产出的正是预览策略想要消费的那些标志。被接收环节标记为有风险的文件自动走加固路径;常规文档保留它们的便利。把这两个阶段绑到同一个共享的策略对象上,而不是两个配置界面——不管你第一次写得多小心,那两个界面到第二个版本就会漂移开来
进程内与跨进程之间的界线画在哪,取决于谁给你发文件。对普通的商务接收来说,发文档的人是已知的,只是粗心而已,那么关掉脚本、拦截链接的进程内预览是一条站得住脚的底线。对匿名的公开上传就不是了,而且再多的进程内标志设置也不会让它变成站得住脚的;那些要在一个独立的低权限工作进程里渲染,只把位图送到界面,这样一个引擎缺陷让你付出的代价是一个工作进程,而不是宿主应用。要有意识地划定这条界线,并把每条接收路径归入哪一类写下来,因为猜错的代价是不对称的
授权、与安全相关的 API 接口,以及一个加固查看器的演示都在产品页上:PDFium Component