PDF权限密码检测里,一个length让全禁文件放行

0 阅读8分钟

组里在给内部文档中台做 PDF 批量导入。文件拖进来以后先出缩略图,后面还有压缩和转图两步,碰到加密文件这两步都会失败。我想在前端读文件的那一刻就把结果标在列表上,不让用户排完队才看到失败。检测用的是 pdf.js 6.3.289。为了测它我用脚本生成了 7 份每份 2 页的占位 PDF,正文都是「示例合同(占位文字,仅供测试)」。其中 S0 没加密,S5 要打开密码。另外 5 份打开时不要任何密码但设了权限密码,限制从只禁复制到全禁不等。我写过的一版判断在这 7 份上一个错都没报,5 份带限制的文件却全被标成了没有限制。毛病出在一个 length 上。

先说为什么打开不要密码也算加密。只设权限密码的文件用户密码是空的,阅读器会自动拿空密码把内容解开。可文件里有完整的加密字典,内容流也确实用 AES 或 RC4 加密过。拿能不能打开来做预检是没用的。

检测函数分三步读

整个函数只读不写。文件读成 Uint8Array 以后直接交给页面里的 pdf.js 解析。数据不出页面也不另存。

async function readPdfLock(bytes) {
  const task = pdfjsLib.getDocument({ data: bytes });
  let needOpenPwd = false;
  task.onPassword = () => { needOpenPwd = true; task.destroy(); };
  try {
    const pdf = await task.promise;
    const encrypted = Boolean((await pdf.getMetadata()).info.EncryptFilterName);
    const granted = await pdf.getPermissions();
    await task.destroy();
    return { state: encrypted ? 'restricted' : 'plain', granted };
  } catch (e) {
    if (needOpenPwd) return { state: 'needs-open-password', granted: null };
    throw e;
  }
}

代码说明。第一步挂 onPassword,7 份样本里只有要打开密码的 S5 会触发它。我在回调里记一笔再把加载任务销毁掉,页面上不弹密码框。任务销毁后等着的 promise 会抛 PasswordException。外面那层 try 接住它以后只要标记已经置上就当成预期结果返回。第二步看 getMetadata 返回的 info 里的 EncryptFilterName。没加密的 S0 这一项是空的,另外 5 份能打开的全是 Standard,也就是标准安全处理器。第三步调 getPermissions 拿到允许的操作。这三步的顺序不能换。第一步只回答要不要密码。全禁的文件在这一步和 S0 看起来一模一样,得靠第二步才分得开。

收尾要销毁的是加载任务

第一版我在读完权限后写的是 pdf.destroy()。6.3.289 当场报 TypeError: pdf.destroy is not a function。文档对象上没有这个方法。能销毁的是 getDocument 返回的加载任务,改成 task.destroy() 就过了。批量导入一次可能是几十份。不释放我心里不踏实。

权限结果先归一成Set再判断

拿到权限以后还要翻译成用户看得懂的几项。列表角标里我只放打印、复制文字、修改内容、添加注释这四项,其余的留在详情里。

const LABELS = [['PRINT', '打印'], ['COPY', '复制文字'], ['MODIFY_CONTENTS', '修改内容'], ['MODIFY_ANNOTATIONS', '添加注释']];
function listDenied(granted) {
  if (granted === null) return [];
  const ok = new Set(granted);
  return LABELS.filter(([flag]) => !ok.has(pdfjsLib.PermissionFlag[flag])).map(([, label]) => label);
}

代码说明。页面里的 6.3.289 调 getPermissions 返回的是 Set,用 Object.prototype.toString 打出来是 [object Set]。Firefox 151 自带阅读器里的 pdf.js 5.7.195 返回的却是数组,先 new Set 一下两种形态都能吃。我拿数组 [4, 8, 32] 喂进去返回的是「复制文字」,跟预期一样。开头那句 granted === null 是故意写死的。S0 返回 null 的意思是文件没声明任何限制。S6 的权限位是 0,连无障碍读取都禁了。它返回的是一个空 Set,意思是一项都不允许。这两种结果在判断里必须分开走。

为了看清楚写错会怎样,我把两种错误写法也放进去跑了一遍。写法 A 照数组的习惯直接调 granted.includes。5 份带限制的样本全部抛出 TypeError: granted.includes is not a function。它至少是吵的,测试一跑就能发现。写法 B 才是标题里那个坑。它开头写了一句 if (!granted || !granted.length) return [],本意是没拿到权限数据就当没有限制。Set 没有 length 属性,取出来是 undefined 再取反就是 true。结果 S1 到 S4 加上 S6 这 5 份全部返回空列表并被当成没有任何限制。整个过程不报错,列表上也看不出任何异常。要不是我专门拿全禁的样本对了一遍,这 1 行大概会一直留在代码里。

三个内核跑出的结果一致

检测页我在 Chromium 149 开源构建、Firefox 151、WebKit 26.5 三个内核里各跑了一遍。WebKit 这里测的只是页面里跑的 pdf.js,它不是 Safari 正式版。三个内核的输出逐项相同,全程非 GET 请求都是 0 条。

样本打开密码声明的限制检测结果列表角标
S0无无plain无
S1 AES-256无全禁restricted打印、复制文字、修改内容、添加注释
S2 RC4 128 位无全禁restricted同 S1
S3 AES-128无只禁复制restricted复制文字
S4 AES-256无只禁打印restricted打印
S5 AES-256有全禁needs-open-password无
S6 RC4 40 位无全禁restricted打印、复制文字、修改内容、添加注释

S1、S2、S6 的加密算法各不一样。出来的角标却完全一样,决定结果的只有权限位。S5 的角标是空的,这是因为不给密码根本读不到权限并不是它没有限制。界面上我给它单独配了一句提示,没和 S0 共用一个空状态。

检查页一次读三份测试 PDF 的结果

这张图是我照同样逻辑做的检查页,一次读了三份文件,列的是全部 8 项。第一张卡片是全禁的 S1。标签写「打开不要密码 · 设了权限限制」。8 项里只有无障碍读取是绿色的允许,其余 7 项都是红色的不允许。第二张是只禁复制的 S3,只有复制文字那 1 行标红。第三张是要打开密码的 S5,缩略图位置写着输入密码前看不到内容,下面一行权限都没有。前两张卡片底部还有 1 行小字,页面库读到的文字是 169 字,和没加密的 S0 一样多。pdf.js 当页面库用时只把权限位报给调用方,执不执行由应用自己决定。

提示只写文件声明了什么

检测完前端做什么我定得很窄,就是把结果告诉用户。前端不去掉限制也不另存一份没有限制的副本。最主要的原因是合规。文件作者已经声明了不许复制和修改。系统替用户绕过去的话法务问起来我解释不了。数据整改那三个月我不想再来一遍。另一个原因是这些限制本来就靠阅读软件配合执行,各家也不一样。同一份 S1 在 Chromium 149 自带的阅读器里打印和复制都被拦住,画笔迹却可以。Firefox 151 默认设置下三样都没拦。前端要是照某个阅读器的表现下结论的话换个阅读器就不准了。我的文案只写「文件声明不允许复制文字」,不写「这份文件不能复制」。

角标定下来以后我还想知道下游的压缩碰到这种文件会怎样。我试的是图映 ImgIng 的 PDF 压缩(imging.cn/),用的是自己生成的只… PDF。它在本机处理,整个过程非 GET 请求是 0 条。S1、S2、S3、S5 导入时都只显示待处理,S5 也没弹密码框。点开始压缩以后每一份都标成失败。原话是「这份 PDF 有加密或权限保护,需要先解除保护」。S3 其实只禁复制,还允许修改和组合,照样被拒。界面没说为什么拒绝。我猜是重写文件就要处理原来的加密和限制,它干脆选择不碰。这只是从结果倒推的猜测。失败以后右侧进度卡写的是「全部完成」和「0 个文件已压缩完成」,两处状态对不上。看完这一轮我更确定预检要放在读文件那一刻,不能等用户排到压缩再告诉他。

三种状态我各配了一句话。needs-open-password 提示用户找发件人要打开密码或者请对方发一份不设密码的版本。restricted 先列出角标里那几项再说明后面的压缩和转图会失败。用户可以请原作者发一份不带限制的版本。文件要是用户自己做的,也还记得所有者密码,就请他用当初生成它的软件输入所有者密码后重新导出。plain 直接放行。

还没覆盖到的文件

Acrobat Reader、macOS 预览、Safari 正式版我都没测。文中的阅读器表现只限 Chromium 149 开源构建和 Firefox 151。安全处理器只测了 Standard 一种。证书加密、只加密附件、带数字签名的文件都没试,检测函数碰到它们会走哪条分支我现在说不准。样本全是自己生成的 2 页占位文件,代表不了真实文件的权限位分布。上线前我还要拿一批几百份的导入压一轮,重点检查 task.destroy() 以后 worker 是不是真的释放了,这个我手上还没有数据。