Google Apps Script (GAS) で Slackbot や連携ツールを作るとき、
避けて通れないのが「Webアプリの公開設定」です。
Slackからのイベントを受け取るには、アクセス権限を「全員 (Anyone)」にする必要があります。

つまり、URLさえバレれば誰でもスクリプトを実行できてしまう状態です。

Slack公式が推奨する「Signing Secret (署名検証)」を実装しようにも、GASの仕様上HTTPヘッダーが取得できず実装不可能です。かといって、非推奨(Legacy)のVerification Tokenは使いたくない。

そんな課題を解決するために、Google Cloud Functions (GCF) を「認証プロキシ」として配置するアーキテクチャを構築しました。その実装ノウハウを共有します。

課題:GAS単体では「署名検証」ができない

SlackからのWebhookリクエストが「正当なSlackからのものか」を検証するために、通常はHTTPヘッダーに含まれる x-slack-signature を使用します。

しかし、GASの doPost(e) イベントオブジェクトには、HTTPリクエストヘッダーを取得する機能がありません。

これにより、以下のセキュリティリスクを抱えることになります。

  1. なりすまし: URLを知っていれば誰でもリクエストを送れる。
  2. リプレイ攻撃: 通信を傍受された場合、そのリクエストを再送されてしまう。

解決策:Cloud Functionsをプロキシにする

GASの前段に、HTTPヘッダーを扱える Cloud Functionsを配置し、そこで署名検証を行う構成を採用しました。

アーキテクチャ

Slack ➡ GCF ➡ GAS

この構成のポイント

  1. Slack ➡ GCF: Slack推奨のSigning Secretで検証。
  2. GCF ➡ GAS: 独自のHMAC-SHA256署名を付与して転送。
  3. GAS側: IP制限はできないが、GCFが付与した署名を検証することで、実質的にGCF経由以外のアクセスを遮断。

実装の詳細

1. Cloud Functions

GCF側では、以下の2つの責務を担います。

  1. Slackからのリクエストヘッダー (x-slack-signature) を検証する。
  2. 検証OKなら、GASとの共通鍵 (PROXY_SECRET) でペイロードを署名し、GASへ転送する。
// index.js (抜粋)
const functions = require('@google-cloud/functions-framework');
const crypto = require('crypto');

functions.http('slackProxy', async (req, res) => {
  // 1. Slackの署名検証 (Signing Secret)
  if (!verifySlackSignature(req)) {
    return res.status(401).send('Verification failed');
  }

  // 2. GASへ転送するためのプロキシ署名を作成
  // リプレイ攻撃防止のためタイムスタンプを含める
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const bodyString = JSON.stringify(req.body);
  const sigBasestring = `v0:${timestamp}:${bodyString}`;

  // 共通鍵(PROXY_SECRET)でHMAC署名を作成
  const signature = crypto
    .createHmac('sha256', process.env.PROXY_SECRET)
    .update(sigBasestring, 'utf8')
    .digest('hex');

  // GASへPOST (署名はクエリパラメータで渡す)
  const gasUrlObj = new URL(process.env.GAS_URL);
  gasUrlObj.searchParams.append('signature', signature);
  gasUrlObj.searchParams.append('timestamp', timestamp);

  const response = await fetch(gasUrlObj.toString(), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: bodyString,
  });

  // ...レスポンス処理
});

2. Google Apps Script (GAS)

GAS側では、doPost の冒頭でGCFから送られてきた署名を検証します。

GAS標準の Utilities.computeHmacSha256Signature を使うことで、外部ライブラリなしで高速に処理可能です。

// main.gs (抜粋)
const proxySecret = PropertiesService.getDocumentProperties().getProperty("ProxySecret");

function doPost(e) {
  // プロキシ署名の検証
  if (!verifyProxySignature(e)) {
    console.error("不正なアクセスです");
    return ContentService.createTextOutput("Error: Unauthorized");
  }

  // ...メイン処理...
}

function verifyProxySignature(e) {
  const signature = e.parameter.signature;
  const timestamp = e.parameter.timestamp;
  const bodyString = e.postData.getDataAsString();

  // 1. タイムスタンプ検証 (5分以内) -> リプレイ攻撃対策
  const now = Math.floor(new Date().getTime() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  // 2. 署名の再計算と照合
  const sigBasestring = `v0:${timestamp}:${bodyString}`;
  const computedSignature = Utilities.computeHmacSha256Signature(sigBasestring, proxySecret);

  // Byte配列をHex文字列に変換して比較
  const computedHex = computedSignature.reduce((str, byte) => {
      const hex = (byte < 0 ? byte + 256 : byte).toString(16);
      return str + (hex.length === 1 ? "0" : "") + hex;
  }, "");

  return signature === computedHex;
}

まとめ

GASの「手軽さ」を残しつつ、セキュリティ要件を満たすためにCloud Functionsをプロキシとして挟む構成は非常に有効でした。

  • Slack公式推奨 (Signing Secret) に準拠できた。
  • 非推奨機能 (Verification Token) を廃止できた。
  • GAS特有の制約を HMAC署名リレー で安全に回避できた。

同じような構成で悩んでいる方の参考になれば幸いです。