大きいPDFをZoho関数に載せるのをやめ、画面で確認したbytesをそのまま保存できるようにしました。方針は2点です。Delugeに巨大Base64を載せないことと、保存正本をサーバ再生成結果ではなく利用者が確認したbytesにすることです。
シリーズ「Zoho WidgetとLambdaの連携」の第3回です。
前回: Zoho WidgetとLambdaの連携 第2回:HMAC署名とDelugeのJSON運搬
次回: Zoho WidgetとLambdaの連携 第4回:外部送信の受付判定と自動retry抑止
この記事の内容
- 許可はHMAC、本体は直送に分けた理由
- プレビューと保存で認可方式が異なること
- Base64・bytes・ハッシュの役割と、直送にCORSが必要な理由
構成要素
| 要素 | 役割 |
|---|---|
| Widget | プレビュー表示と直送の起点 |
| Deluge/入口Lambda | 保存許可などメタデータのHMAC経路 |
| 副作用Lambda | PDF生成。プレビューGET用の公開面を持ちうる |
| 直送入口 | 入口Function URL配下。PDF本体のみ受け取る |
単一経路にしない理由
プレビューと保存を同じ経路にした方が単純に見えます。ただしZoho関数やDelugeに巨大PDFを載せると、サイズ制限・破損・遅延が先に問題になります。サーバで再生成して保存すると、画面表示と保存内容の一致保証が弱くなります。URLだけ渡してサーバ取得すると、参照差し替えの余地が残ります。
そのため経路を分けました。許可とメタデータはHMAC経路、PDF本体だけ入口Function URL配下へブラウザ直送します。
Base64・bytes・ハッシュ
| 用語 | 意味 |
|---|---|
| bytes | PDFの実体バイト列 |
| Base64 | バイト列の可逆なテキスト符号化 |
| ハッシュ(SHA-256) | バイト列の固定長ダイジェスト。表示内容と保存内容の一致確認に使う |
上限の定義は次のとおりです。
| 上限 | 意味 |
|---|---|
| PDF本体 6MiB | Base64デコード後のbytes |
| 直送body 8MiB | HTTPリクエスト全体 |
| 許可token 300秒 | 発行からの有効期間 |
直送先のURLとCORS
Same-Origin Policyにより、ブラウザはページのoriginと異なる先への応答読取を、サーバ許可なしでは制限します。別originへの読取許可を示す仕組みがCORSです。CORSは認証ではありません。
直送は入口Function URL配下で行います。CORS許可と、Widgetが知る入口の基底URLが必要です。Widget配信も入口に載せている構成では同一originなので、/api/direct のような相対パスで足りる場合があります。配信ホストとAPIホストが分かれる場合は、配布時設定で入口の基底URLを渡し、DelugeのHMAC先と同系統に揃えます。
許可originはWidgetが動作するexactなHTTPSのみです(具体値は記載しません)。未許可だとiframeからの直送はブラウザ段階で失敗します。
プレビューと保存
| プレビュー | 保存 | |
|---|---|---|
| 目的 | bytesを画面表示する | 確認したbytesを履歴正本にする |
| 主な入口 | 副作用側の短命URLでGET | 入口側の短命token+直送POST |
| 漏洩時のリスク | 有効期限内のPDF読取 | 期限内・claim一致なら保存が実行されうる |
プレビューURL
プレビューGETは通常APIのHMAC経路ではありません。副作用LambdaのFunction URL上で、期限付きの署名付きURLを発行してbytesを取得します。業務の保存・送信POSTは受けません。
漏洩時のリスクは、有効期限内のPDF読取です。送信や保存は開始しません。防衛の主軸はURL秘匿ではなく、短命化と、業務副作用を入口側のtoken/HMACに閉じることです。パラメータ名は記載しません。
PDF保存の流れ
- Widgetがプレビューを要求し、副作用LambdaがPDFを生成する
- Widgetが短命URLでGETして表示する
- 表示bytesのSHA-256を計算する
- HMAC経路で保存許可を取り、履歴を用意する
- 短命tokenを受け取り、本体を直送する
- サーバはtokenとハッシュが一致するときだけ保存する
メタデータは関数経由、本体は直送です。
ZOHO.CRM.FUNCTIONS.execute(fnName, {
arguments: JSON.stringify(saveRequest), // hashやモード。PDF本体は載せない
});
await fetch(entryBaseUrl + "/api/direct", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
dealId,
historyId,
idempotencyKey,
directSaveToken,
previewPdfBase64,
}),
});
entryBaseUrl は空(同一origin)または設定で渡す絶対URLです。SHA-256は crypto.subtle.digest("SHA-256", bytes) です。tokenの存在だけでは保存せず、確認済みbytesのハッシュを許可条件に含めます。
許可tokenの内容
短命の署名付き許可として、中のclaim(条件フィールド)を検証します。形式名(JWT等)には依存しません。含める条件は商談ID、履歴ID、操作者ID(body申告ではなく署名境界内で解決した値)、確認済みPDFのSHA-256、期限、nonceです。tokenと直送内容が一致しない場合は拒否します。
使用済みtokenは、warmコンテナ上でbest-effortに拒否します。同一画面での保存連打では、2回目以降がここで失敗することがあります。インスタンス横断の厳密なone-timeではありません。失敗や期限切れ後の正規手順は、プレビュー再作成からのやり直しです。
直送入口はPDF保存専用です。previewPdfUrl は受け付けません。入力保存、履歴作成、外部送信開始も扱いません。
よくある失敗
| 症状 | 確認/次の手順 |
|---|---|
| PDF保存の認証失敗 | token期限、hash不一致、claim不一致。プレビュー再作成 |
| 保存連打で2回目だけ失敗 | 使用済みtoken。再作成して再保存 |
| PDFが大きすぎる | デコード後6MiB超 |
| リクエストが大きすぎる | body 8MiB超 |
| iframeから直送だけ失敗 | CORS/origin許可 |
| 保存成功後に送信だけ失敗 | 保存と送信は別操作(第4回) |
画面にはclaim名を出しません。
送信との分離と限界
正式フロー以外の保存フォールバックは事故回避用に残し、通常画面では使いません。
PDF保存のあとに外部送信します。送信時に巨大PDFを再送しません。正本は履歴上の保存済みPDFです。
| 保証する範囲 | 保証しない範囲 |
|---|---|
| 画面で計算したbytesと直送bytesの一致 | 画面表示自体が改ざん済みPDFであること |
| tokenの期限・claim一致 | インスタンス横断の厳密one-time |
| 直送入口の専用化 | プレビューURL漏洩時の読取ゼロ |
巨大PDFをDeluge経由で運ぶ構成より、制約と事故面が小さい、というのが採用理由です。