自分の書いたコードに、別のAIを当てた。Codexにセキュリティ監査をさせる。リリース前の、最後の確認のつもりだった。
戻ってきた指摘は、三つとも「P1」だった。最優先。今すぐ直すべき、という重さの印。その中の一つが、背筋を冷やした。
「Webhookの署名を検証していない」
LINEからの通知を、無条件で信じていた
このシステムは、LINEから通知を受け取る。誰かが公式アカウントにメッセージを送ると、LINEがこちらのサーバーに「こういうイベントがありました」とPOSTしてくる。それを受けて、予約の処理や返信が動く。
問題は、そのPOSTを、中身も確かめずに信じていたことだった。
@app.post("/webhook/line")
async def webhook(request: Request):
body = await request.body()
data = json.loads(body)
for event in data.get("events", []):
...
届いたJSONを、そのままLINEのイベントとして処理する。送り主がLINEかどうかを、一度も確かめていない。
つまり、LINEのふりをして、誰でもこのアドレスに偽のイベントを投げ込めた。試しに、署名なしのPOSTを送ってみた。サーバーは 200 OK、status: ok と返した。にこやかに、偽物を受け取っていた。
署名は、送り主の指紋
LINEは、本物のリクエストに署名を付けて送ってくる。X-Line-Signature というヘッダーだ。
署名は、リクエストの本文と、自分だけが知っているチャネルシークレットから、計算で作られる。受け取った側は、同じ本文と同じシークレットで、同じ計算をやり直す。出てきた値が、ヘッダーの署名と一致すれば、それは確かにLINEが、その本文で送ったものだ。シークレットを知らない第三者には、正しい署名は作れない。
mac = hmac.new(CHANNEL_SECRET.encode(), body, hashlib.sha256).digest()
expected = base64.b64encode(mac).decode()
if not hmac.compare_digest(expected, signature):
return JSONResponse({"error": "invalid signature"}, status_code=401)
一致しなければ、401で突き返す。本文を一文字でも書き換えれば、署名は合わなくなる。これで、本文と送り主の両方を、同時に確かめられる。
200が、401になった
直したあと、もう一度、署名なしのPOSTを投げた。
401。拒否された。正しい署名を付けて投げると、200。受け取った。にこやかに偽物を通していた入り口が、本物だけを通す入り口になった。
怖かったのは、これがずっと「動いていた」ことだ。署名を確かめなくても、LINEからの本物のリクエストは普通に処理される。表向き、何の問題もない。誰も偽物を投げてこない限り、欠陥は静かに眠っている。動いているからといって、守られているわけではなかった。
自分のコードは、自分には見えにくい。「ここは大丈夫だろう」という思い込みが、一番点検されない。別のAIに監査させてよかった、と心から思った。
外から来るものを信じる前に、それが本当に名乗っている相手なのかを確かめる。当たり前のことが、自分のコードでは抜けていた。
次回は、もう一つのP1——既知の固定パスワードと共有鍵を、本番を止めずに入れ替えた話を書く。
シンプルシステム株式会社 代表 伊藤勝彦