🎃

GASからAWSのS3にアクセスキーを使わずにアクセスする

に公開

🎄Merry Christmas🎄 WWWAVE アドベントカレンダー 12/13の記事です】

概要

  • GAS(Google Apps Script)を使いAWSのS3にcsvをアップロードする処理があった
  • 認証にアクセスキーを使っていたがセキュリティ的なリスクがある
  • よりセキュアな運用をするためアクセスキーではなくIDフェデレーションで認証するようにする

きっかけ

AWSを外部から操作する手段としてアクセスキーを利用するケースはあるかと思います。
実際にGASからS3にcsvをアップロードする処理があり、その認証にアクセスキーを使っておりそれを当たり前として運用していました。

しかし現在ではアクセスキーのような長期的な認証情報は非推奨となっています。
とはいえ運用の都合などでAWSのアクセスキーを使う場合もあると思います。
通常はリスクを減らすためアクセスキーを使う場合は必ずポリシーの設定でIP制限を入れていました。
しかしGASから利用する場合はIPが固定でないためポリシーによるIP制限をすることができません。
GASを使わないという選択肢もあると思いますが、ちょっとした処理を実装するにはGASは便利なのでできればGASで実現したい。

まぁ設定できないししょうがないよね!
……ではダメなので、アクセスキーに変わる実装を検討しました。

アクセスキーの代替手段

AWSはアクセスキー以外にも様々な認証手段を用意しています。
とはいえどれが使えるのかパッとわからないということで、AIに相談してOIDCフェデレーションによる認証で実装することにしました。

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_roles_providers_oidc.html

これによってアクセスキーのような認証情報をGASで保持する必要がなくなります。

実装

  1. GoogleCloudのプロジェクトを用意する

GoogleCloudのプロジェクトを使うので用意します。
OAuth2.0 クライアントID使うので予め同意画面の設定をしておきます。
https://developers.google.com/workspace/guides/configure-oauth-consent?hl=ja

※今回は組織のGoogleアカウントで作業しています。そのため同意画面の設定でuser_typeを「内部」にして社内でのみ利用することを前提としています。無料のGoogleアカウントでは同意画面の設定でuser_typeを「外部」しか選べないためご注意ください。

  1. GASの設定でデフォルトから用意したGoogleCloudのプロジェクトに変更する

GASの設定画面にプロジェクトの設定があるのでここで変更します画像1

  1. OAuth2.0 クライアントIDを確認する

GASでプロジェクトの設定をするとAppScriptという名前でOAuth2.0クライアントIDが自動で生成されるのでIDを確認して控えておきます。

  1. GASからフェデレーションで利用するRoleを作成する

Role作成を行う際に信頼されたエンティティを変更します。
アイデンティティプロバイダーをGoogleにして、Audienceの値は控えておいたOAuth2.0クライアントIDの値を入力しておきます。
あとは通常のRoleの作成と同様に目的のAWS操作ができるポリシーを設定しておけばOKです。

画像2

作成後にRoleの信頼関係のタブを確認すると以下のようなjsonが設定されていることが確認できます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "accounts.google.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "accounts.google.com:aud": "クライアントID"
                }
            }
        }
    ]
}

動作確認

これで設定が完了なのでGASからS3にファイルをアップロードする処理を実行してみたいと思います。
GASではAWS SDKのようなツールが使えないので認証処理も含めて実装する必要があります。

mainのコードを作成する前にappsscript.jsonに次のコードを追加します。

  ...
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request",
    "openid"
  ]
  ...

mainのコードは以下になります。
こちらのコードは全てAIに任せて作りました。

サンプルコード
// --- 設定項目 ---
const CONFIG = {
  // AWS IAMロールのARN
  ROLE_ARN: 'arn:aws:iam::123456789012:role/YourGoogleFederatedRole',
  // ロールセッション名
  SESSION_NAME: 'GAS_S3_Session',
  // アップロード先のバケット名
  BUCKET_NAME: 'your-s3-bucket-name',
  // アップロード先のリージョン
  REGION: 'ap-northeast-1',
  // テスト用ファイル名とコンテンツ
  FILE_NAME: 'test_upload.txt',
  FILE_CONTENT: 'Hello from Google Apps Script with Federation!'
};

/**
 * メイン関数
 */
function main() {
  try {
    console.log('処理を開始します...');

    // 1. GASのOIDCトークンを取得
    const idToken = ScriptApp.getIdentityToken();
    if (!idToken) {
      throw new Error('OIDCトークンが取得できませんでした。appsscript.jsonのoauthScopesを確認してください。');
    }

    // 2. AWS STSで一時クレデンシャルを取得
    const credentials = getAwsTempCredentials(idToken);
    console.log(`AWS認証成功: AccessKeyId prefix=${credentials.accessKeyId.substring(0, 5)}...`);

    // 3. S3へファイルをアップロード
    const blob = Utilities.newBlob(CONFIG.FILE_CONTENT, 'text/plain', CONFIG.FILE_NAME);
    
    uploadToS3(blob, credentials);
    
  } catch (e) {
    console.error('エラーが発生しました: ' + e.toString());
    if (e.stack) console.error(e.stack);
  }
}

/**
 * AWS STS AssumeRoleWithWebIdentity を実行して一時クレデンシャルを取得する
 */
function getAwsTempCredentials(idToken) {
  const stsEndpoint = 'https://sts.amazonaws.com/';
  const params = {
    'Action': 'AssumeRoleWithWebIdentity',
    'RoleArn': CONFIG.ROLE_ARN,
    'RoleSessionName': CONFIG.SESSION_NAME,
    'WebIdentityToken': idToken,
    'Version': '2011-06-15'
  };

  const queryString = Object.keys(params).map(key => {
    return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
  }).join('&');

  const url = stsEndpoint + '?' + queryString;
  const options = { 'method': 'post', 'muteHttpExceptions': true };
  const response = UrlFetchApp.fetch(url, options);
  
  if (response.getResponseCode() !== 200) {
    throw new Error('STS Error: ' + response.getContentText());
  }

  const xml = response.getContentText();
  const accessKeyId = xml.match(/<AccessKeyId>(.*?)<\/AccessKeyId>/)[1];
  const secretAccessKey = xml.match(/<SecretAccessKey>(.*?)<\/SecretAccessKey>/)[1];
  const sessionToken = xml.match(/<SessionToken>(.*?)<\/SessionToken>/)[1];

  return { accessKeyId, secretAccessKey, sessionToken };
}

/**
 * AWS Signature Version 4 を使用して S3 にファイルをアップロードする
 */
function uploadToS3(blob, credentials) {
  const method = 'PUT';
  const service = 's3';
  const host = `${CONFIG.BUCKET_NAME}.s3.${CONFIG.REGION}.amazonaws.com`;
  
  const encodedFilename = encodeURIComponent(blob.getName());
  const endpoint = `https://${host}/${encodedFilename}`;
  
  const contentType = blob.getContentType();
  const payload = blob.getBytes();

  // 日時情報の準備
  const now = new Date();
  const amzDate = now.toISOString().replace(/[:\-]|\.\d{3}/g, '');
  const dateStamp = amzDate.substr(0, 8);

  // --- 署名の作成 (SigV4) ---

  // 1. Canonical Request
  const canonicalUri = '/' + encodedFilename;
  const canonicalQuerystring = '';
  const canonicalHeaders = 
    `content-type:${contentType}\n` +
    `host:${host}\n` +
    `x-amz-content-sha256:UNSIGNED-PAYLOAD\n` +
    `x-amz-date:${amzDate}\n` +
    `x-amz-security-token:${credentials.sessionToken}\n`;
  
  const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token';
  const payloadHash = 'UNSIGNED-PAYLOAD'; 

  const canonicalRequest = 
    method + '\n' +
    canonicalUri + '\n' +
    canonicalQuerystring + '\n' +
    canonicalHeaders + '\n' +
    signedHeaders + '\n' +
    payloadHash;

  // 2. String to Sign
  const algorithm = 'AWS4-HMAC-SHA256';
  const credentialScope = `${dateStamp}/${CONFIG.REGION}/${service}/aws4_request`;

  // Canonical Requestのハッシュ化はHMAC(鍵付き)ではなく、純粋なSHA-256(鍵なし)で行う
  const canonicalRequestHashBytes = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, canonicalRequest);
  const canonicalRequestHash = canonicalRequestHashBytes.map(b => ('0' + (b & 0xFF).toString(16)).slice(-2)).join('');

  const stringToSign = 
    algorithm + '\n' +
    amzDate + '\n' +
    credentialScope + '\n' +
    canonicalRequestHash;

  // 3. Signing Key
  const kSecret = 'AWS4' + credentials.secretAccessKey;
  const kDate = hmacSha256(dateStamp, kSecret);
  const kRegion = hmacSha256(CONFIG.REGION, kDate);
  const kService = hmacSha256(service, kRegion);
  const kSigning = hmacSha256('aws4_request', kService);

  // 4. Signature
  const signature = hmacSha256Hex(stringToSign, kSigning);

  // 5. Authorization Header
  const authorization = 
    `${algorithm} Credential=${credentials.accessKeyId}/${credentialScope}, ` +
    `SignedHeaders=${signedHeaders}, Signature=${signature}`;

  // リクエスト送信ヘッダー (HostはGASが自動付与するため除外)
  const headers = {
    'Content-Type': contentType,
    'X-Amz-Content-Sha256': payloadHash,
    'X-Amz-Date': amzDate,
    'X-Amz-Security-Token': credentials.sessionToken,
    'Authorization': authorization
  };

  const options = {
    'method': 'put',
    'headers': headers,
    'payload': payload,
    'muteHttpExceptions': true
  };

  console.log('S3へアップロード中...');
  const response = UrlFetchApp.fetch(endpoint, options);
  
  if (response.getResponseCode() >= 200 && response.getResponseCode() < 300) {
    console.log(`アップロード成功: ${endpoint}`);
  } else {
    throw new Error(`S3 Upload Failed: ${response.getResponseCode()} ${response.getContentText()}`);
  }
}

// --- ヘルパー関数 ---

function toBytes(content) {
  if (typeof content === 'string') {
    return Utilities.newBlob(content).getBytes();
  }
  return content;
}

function hmacSha256(data, key) {
  const dataBytes = toBytes(data);
  const keyBytes = toBytes(key);
  return Utilities.computeHmacSha256Signature(dataBytes, keyBytes);
}

function hmacSha256Hex(data, key) {
  const signature = hmacSha256(data, key);
  return signature.map(b => ('0' + (b & 0xFF).toString(16)).slice(-2)).join('');
}

終わりに

これで認証情報の漏洩などのセキュリティリスクが減らせました。
置き換えてみて思っていたよりは簡単に設定できたかなという感じです。
最近は固定の認証情報というだけでセキュリティ的なリスクの要因となりえるので、そこを気にせず使えるようになったのは良かったです。

また認証とは別で困ったのはGASではAWS SDKといったツールが使えないためどうやって認証処理の部分を実装するかということでした。
最終的には認証処理やS3の操作部分をAIに作らせて、それをGASのライブラリとしてデプロイして利用する形にしました。
社内用として使うレベルなら自前でライブラリを実装して使うという選択肢がAIのおかげで気軽にできるようになったのを実感しました。

wwwave's Techblog

Discussion