はじめに
前回の記事でとりあえず動くものができました。
が、動いてはいるものの、作りながら「これ大丈夫か?」と思っていた部分がいくつかありました。改めてコードを見直したところ、セキュリティ的にまずい点がいくつか見つかったので、まとめて直しました。あわせて akippa の謎仕様に起因するバグも修正しています。
今回直した内容はざっくりこのあたりです。
- API Gateway に認証を追加(誰でも叩けた状態だった)
- シークレット情報を Secrets Manager に移行(環境変数の平文管理をやめた)
- Lambda のセキュリティ・コード品質レビュー
- GAS のエラー時挙動を改善
- akippa の連続日程予約 CSV のバグ対応
- 全日イベントの対応(おまけ)
1. API Gateway にキー認証を追加
最初に作ったときは API Gateway のエンドポイントが 完全に公開状態 でした。URL さえわかれば誰でも叩けてしまいます。
API キーを作って、x-api-key ヘッダーがないリクエストは弾くようにしました。
AWS 側の設定:
- API キー
akippa-gas-keyを作成 - 使用量プラン
akippa-usage-planを作成して prod ステージに紐付け - API キーを使用量プランに紐付け
- 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 の日時ズレ |
| 機能追加 | 全日イベント対応 |