0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

akippaの予約CSVからGoogleカレンダーに転記するLambda関数 #3(リファクタリング編)

0
Last updated at Posted at 2026-04-06

はじめに

前回の記事でとりあえず動くものができました。

が、動いてはいるものの、作りながら「これ大丈夫か?」と思っていた部分がいくつかありました。改めてコードを見直したところ、セキュリティ的にまずい点がいくつか見つかったので、まとめて直しました。あわせて akippa の謎仕様に起因するバグも修正しています。

今回直した内容はざっくりこのあたりです。

  • API Gateway に認証を追加(誰でも叩けた状態だった)
  • シークレット情報を Secrets Manager に移行(環境変数の平文管理をやめた)
  • Lambda のセキュリティ・コード品質レビュー
  • GAS のエラー時挙動を改善
  • akippa の連続日程予約 CSV のバグ対応
  • 全日イベントの対応(おまけ)

1. API Gateway にキー認証を追加

最初に作ったときは API Gateway のエンドポイントが 完全に公開状態 でした。URL さえわかれば誰でも叩けてしまいます。

API キーを作って、x-api-key ヘッダーがないリクエストは弾くようにしました。

AWS 側の設定:

  1. API キー akippa-gas-key を作成
  2. 使用量プラン akippa-usage-plan を作成して prod ステージに紐付け
  3. API キーを使用量プランに紐付け
  4. ANY メソッドに apiKeyRequired: true を設定して再デプロイ

GAS 側の修正:

var options = {
  "method": "post",
  "contentType": "application/json",
  "headers": {
    "x-api-key": PropertiesService.getScriptProperties().getProperty("API_KEY")
  },
  "payload": JSON.stringify(payload),
  "muteHttpExceptions": true
};

API キーはスクリプトプロパティに保存して、コードに直書きしないようにしています。


2. シークレットを Secrets Manager に移行

Lambda の環境変数に akippa のログイン情報や Google カレンダーのサービスアカウント JSON を 平文で 置いていました。これはさすがにまずい。

AWS Secrets Manager に移行しました。

Secrets Manager に格納した値:

キー 内容
username akippa オーナーサイトのメールアドレス
pass akippa オーナーサイトのパスワード
calendar_id 書き込み先 Google カレンダー ID
GOOGLE_APPLICATION_CREDENTIALS_JSON サービスアカウント認証情報 JSON

Lambda の IAM ロールに SecretsManagerReadPolicy を付与して、コードからは boto3 で取得するように変更しました。

def get_secrets():
    client = boto3.client('secretsmanager', region_name='ap-northeast-1')
    response = client.get_secret_value(SecretId='akippa/credentials')
    return json.loads(response['SecretString'])

取得後は Lambda の環境変数をすべて削除しています。


3. Lambda のセキュリティ・コード品質レビュー

コードをあらためて見直したところ、いくつか気になる点があったので直しました。

🔴 送信者検証の追加

受信したメールの from フィールドを検証していませんでした。support@akippa.com 以外からのリクエストは処理しないようにしました。

AKIPPA_SENDER = 'support@akippa.com'

if AKIPPA_SENDER not in sender:
    print(f"Rejected: unexpected sender: {sender}")
    return {'statusCode': 200, 'body': '不正な送信者のためスキップしました'}

これがないと、誰かが Lambda に偽のキャンセルリクエストを送りつけてカレンダーを荒らすことができてしまいます。

🟠 PII のログ削減

CSV の各行をそのままログに書き出していたので、CloudWatch に 車両ナンバーや車種情報 が残り続けていました。個人情報な気もするのでログには予約 ID だけ残すように変更しました。

# 修正前
print(f"Processing row {i}: {row}")

# 修正後
print(f"Processing row {i}: reservation_id={row[8]}")

🟠 CSRF トークンのログ削除

ログイン処理で取得した CSRF トークンをうっかりログに出力していました。認証情報なのでログから削除しました。

🟡 例外の詳細をレスポンスから除去

エラー時に str(e) をそのままレスポンスボディに返していました。内部の実装詳細が外部に漏れるのでよくないです。

# 修正前
return {'statusCode': 500, 'body': f'エラー: {str(e)}'}

# 修正後
return {'statusCode': 500, 'body': '内部エラーが発生しました'}

詳細は CloudWatch のログにだけ残しています。

🟡 行レベルのエラーハンドリング

日時のパースに失敗したとき、そこで例外が発生して残りの行も処理されなくなっていました。1行の失敗で処理全体が止まらないよう、行単位で try/except を入れました。

try:
    start_dt = datetime.strptime(f"{actual_date} {start_time}", '%Y/%m/%d %H:%M')
    end_dt   = datetime.strptime(f"{actual_date} {end_time}",   '%Y/%m/%d %H:%M')
except ValueError as e:
    print(f"Row {i} date parse error: {str(e)}, skipping")
    continue

🟡 キャンセル検索のページネーション対応

カレンダーのイベント検索が1ページ目しか取得できていませんでした。予約件数が多くなると取りこぼしが出る可能性があったので、nextPageToken を追って全件取得するように直しました。


4. GAS のエラー時挙動を改善

GAS は API Gateway にリクエストを投げた後、常に akippa ラベルを削除していました。API 側でエラーが起きてもラベルを消してしまうので、失敗したメールが再処理されない状態でした。

muteHttpExceptions: true でレスポンスコードを取得し、200 以外のときはラベルを残して次回のトリガー実行時に再処理できるようにしました。

var statusCode = response.getResponseCode();
if (statusCode !== 200) {
  Logger.log("API error for message " + message.getId() + ": HTTP " + statusCode);
  allSucceeded = false;
}

// 全メッセージが成功したときだけラベルを削除
if (allSucceeded) {
  thread.removeLabel(label);
}

5. akippa の連続日程予約 CSV のバグ対応

akippa には複数日にまたがる予約があります。たとえば4月5日〜4月7日の3日間予約があったとき、CSV は3行に分かれて出力されます。

ここで問題なのが、全行の「利用日時」に「初日の開始〜最終日の終了」が入っている という仕様です。

# 実際のCSV(イメージ)
利用日,     予約日時,  利用日時(問題の列),              ...
2026/4/5(土), ..., 2026/4/5(土)9:00〜2026/4/7(月)18:00, ...
2026/4/6(日), ..., 2026/4/5(土)9:00〜2026/4/7(月)18:00, ...  ← 同じ値が入ってる
2026/4/7(月), ..., 2026/4/5(土)9:00〜2026/4/7(月)18:00, ...  ← 同じ値が入ってる

なんなんだこの仕様は…🙃

「利用日時」の列をそのまま使うと、3行とも「4/5 9:00〜4/7 18:00」のイベントになってしまい、カレンダーには1件しか作られません(同じIDのイベントとして重複チェックでスキップされる)。

row[0](利用日)には各行に正しい個別の日付が入っているので、row[0] の日付 + row[2] の時間部分を組み合わせる ことで、各行に正しい日時を生成するよう修正しました。

use_dates = row[2].split('')  # 例: ['2026/4/5(土)9:00', '2026/4/7(月)18:00']

actual_date = parse_date_only(row[0])      # '2026/4/5'
start_time  = extract_time(use_dates[0])   # '9:00'
end_time    = extract_time(use_dates[1])   # '18:00'

# row[0]の日付 + row[2]の時間 で組み合わせる
start_dt = datetime.strptime(f"{actual_date} {start_time}", '%Y/%m/%d %H:%M')
end_dt   = datetime.strptime(f"{actual_date} {end_time}",   '%Y/%m/%d %H:%M')

これで4月5日・6日・7日それぞれに正しい時刻でイベントが作られるようになりました。


6. 全日イベントの対応(おまけ)

0:00〜23:59 の終日予約が時刻付きイベントとして登録されていたので、全日イベントとして登録するようにしました。

Google Calendar API で全日イベントを作るには、dateTime ではなく date を使います。end.date は除外(exclusive)なので翌日を指定します。

if start_time == '0:00' and end_time == '23:59':
    date_str = start_dt.strftime('%Y-%m-%d')
    event = {
        'start': {'date': date_str},
        'end':   {'date': (start_dt + timedelta(days=1)).strftime('%Y-%m-%d')},
        ...
    }
else:
    event = {
        'start': {'dateTime': start_dt.isoformat() + '+09:00', 'timeZone': 'Asia/Tokyo'},
        'end':   {'dateTime': end_dt.isoformat()   + '+09:00', 'timeZone': 'Asia/Tokyo'},
        ...
    }

まとめ

動いていたコードを見直したらけっこう直すところがありました。特に最初は「とりあえず動けばいい」という気持ちで作っていたので、セキュリティ面は後回しにしていた部分が多かったです。

今回対応した内容:

分類 対応内容
セキュリティ API Gateway キー認証、Secrets Manager 移行、送信者検証、PII ログ削減、例外詳細の非開示
堅牢性 行レベルエラーハンドリング、ページネーション対応、GAS ラベル保持
バグ修正 連続日程予約 CSV の日時ズレ
機能追加 全日イベント対応
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?