このサイト、Dottiq は ChatGPT と一緒に作りました。
わたし自身はプログラミングの素人で、「こういう機能が欲しい」「こういう見た目にしたい」と伝えながら、AIに実装してもらうという進め方です。いわゆるバイブコーディングというやつですね。
動くものができた。デザインも気に入っている。記事も書ける。ツールも公開できる。しばらくそれで満足していました。
ところが先日、ふと思ったのです。
「このサイト、セキュリティ的に大丈夫なんだろうか。」
動いているからといって、安全とは限らない。作ってくれたのはAIだけど、AIが作ったコードが常に安全とも限らない。じゃあ、同じAIにレビューしてもらったらどうなるんだろう。
そんな好奇心から始めた作業の結果が、想像以上に「本当にあった怖い話」になりました。
見つかった穴は、8つ
Claudeに「このサイトのセキュリティをレビューしてほしい」と伝えて、ソースファイル一式を渡しました。しばらくして返ってきた回答は、こうです。
「8つの問題が見つかりました。」
8つ。
わたしは一瞬、自分のサイトのことなのに他人事のような気持ちで「へえ、8つもあるんだ」と思いました。そしてすぐに「いや、自分のサイトだ」と気づきました。
見つかった問題を順番に見ていきます。エンジニアの方には「うわあ…」と笑っていただけるかもしれませんし、同じようにバイブコーディングをしている方には「明日は我が身」として読んでいただけると思います。
① パスワードが平文で保存されていた
まずこれです。管理画面へのログインに使うパスワードが、設定ファイルにそのまま文字列として書かれていました。
// 修正前
define('CMS_PASSWORD', 'mypassword123');
// ログイン処理
if ($_POST['password'] === CMS_PASSWORD) {
// ログイン成功
}
平文、つまり「生のパスワード」をコードの中に書いていた、ということです。
何が起きるか:サーバーに不正アクセスされたとき、設定ファイルを見られただけで管理画面のパスワードがバレます。また、コードをGitHubなどに誤ってアップロードしてしまった場合も同様です。「うっかりpushしてしまった」という話はエンジニアの世界では定番の悲劇です。
修正後はこうなりました。
// 修正後
define('CMS_PASSWORD_HASH', '$2y$10$9blx829VBVr/...(ハッシュ値)');
// ログイン処理
if (password_verify($_POST['password'], CMS_PASSWORD_HASH)) {
// ログイン成功
}
password_hash() でパスワードをハッシュ化し、password_verify() で照合する方式です。ハッシュ値は元のパスワードに戻せないため、ファイルを見られても直接パスワードはわかりません。
② 画像アップロードに認証がなかった
ブログ記事の編集画面には、画像をアップロードする機能があります。TinyMCEというリッチエディタを使っていて、記事中に画像を挿入できる仕組みです。
その画像アップロードを処理する upload_image.php というファイルに、ログインチェックがありませんでした。
// 修正前(認証チェックなし)
if ($_FILES['file']['error'] === UPLOAD_ERR_OK) {
$filename = $_FILES['file']['name'];
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $filename);
echo json_encode(['location' => 'uploads/' . $filename]);
}
何が起きるか:管理画面にログインしていない第三者でも、このURLに直接POSTリクエストを送れば、サーバーに好きなファイルをアップロードできます。
「画像だからいいんじゃないの?」と思うかもしれませんが、問題は画像ファイルに限らずアップロードできてしまう点です。悪意のある人物が .php ファイルをアップロードして、サーバー上でそのファイルにアクセスすれば、任意のコードを実行できます。これを「リモートコード実行」と呼び、ウェブの脆弱性の中でも特に深刻なもののひとつです。
// 修正後
if (!isset($_SESSION['logged_in']) || !$_SESSION['logged_in']) {
http_response_code(403);
exit(json_encode(['error' => '認証が必要です']));
}
// 以降アップロード処理
ログインしていないリクエストは 403 エラーで弾く、それだけです。シンプルですが、こういう一行が重要です。
③ 削除がGETリクエストで実行されていた
管理画面の記事一覧には「削除」ボタンがあります。その実装がこうなっていました。
// 削除リンク(修正前)
<a href="delete.php?id=<?= $article['id'] ?>">削除</a>
// delete.php(修正前)
$id = $_GET['id'];
// 記事を削除する処理
URLに ?id=xxx をつけてアクセスするだけで削除が実行される、という状態です。
何が起きるか:これはCSRF(クロスサイトリクエストフォージェリ)という攻撃に対して無防備です。たとえば、悪意のある人物が次のようなHTMLを含むページを作ったとします。
<img src="https://dottiq.com/cms/blog/delete.php?id=1" style="display:none">
管理画面にログインしたまま、このページをブラウザで開いた瞬間、ブログの記事がひとつ消えます。「画像を読み込もうとした」だけで削除が走るのです。攻撃者は何もしていない。ページを見ただけで起きる。
修正後はPOSTリクエスト+CSRFトークンの組み合わせに変えました。
// 削除フォーム(修正後)
<form method="post" action="delete.php" onsubmit="return confirm('削除しますか?')">
<?= csrf_input() ?> <!-- CSRFトークンを埋め込む -->
<input type="hidden" name="id" value="<?= $article['id'] ?>">
<button type="submit">削除</button>
</form>
// delete.php(修正後)
csrf_verify(); // トークンが一致しなければ403で終了
$id = $_POST['id'];
// 削除処理
CSRFトークンとは、サーバーとフォームの間だけで共有するランダムな文字列です。正規のフォームから送られたリクエストにしか含まれないため、外部サイトからの偽リクエストを弾けます。
④ 並び替え処理に認証がなかった
ツール一覧は管理画面でドラッグ&ドロップして順番を変えられます。その並び替えを処理する reorder.php が、ログインチェックなしで動いていました。
// 修正前(認証なし・デバッグ設定まで残ってた)
ini_set('display_errors', 1);
error_reporting(E_ALL);
$data = json_decode(file_get_contents('php://input'), true);
// そのまま並び替え処理
何が起きるか:誰でもこのエンドポイントにリクエストを送ってツールの並び順を変えられます。実害は小さいかもしれませんが、認証のないAPIエンドポイントは他の攻撃の足がかりになることもあります。
もうひとつ気になったのが、デバッグ用の設定が本番環境にそのまま残っていた点です。
ini_set('display_errors', 1);
error_reporting(E_ALL);
これがあると、PHPのエラーがブラウザにそのまま表示されます。エラーメッセージにはファイルパスや内部構造が含まれることがあり、攻撃者にとって有用な情報になります。開発中は便利な設定ですが、公開サーバーでは外しておくべきものです。
⑤ メールヘッダーインジェクション
サポートフォームから送信されたメッセージを転送する処理に、ヘッダーインジェクションの脆弱性がありました。
// 修正前
$headers = "From: {$email}"; // ユーザー入力をFromに直接使用
mb_send_mail($to, $subject, $body, $headers);
何が起きるか:メールの From: ヘッダーにユーザーが入力したアドレスをそのまま使うと、悪意のある入力値によってメールヘッダーを自由に操作できます。
たとえば、メールアドレスの入力欄に以下のような文字列を入れられると:
attacker@evil.com\r\nBcc: victim1@example.com,victim2@example.com
改行コード(\r\n)以降がヘッダーとして解釈され、大量のBccを仕込んだスパムメール送信機として使われます。自分のサーバーからスパムが大量送信され、送信元のIPがブラックリストに載る、というオチが待っています。
// 修正後
// Fromには自ドメインの固定アドレスを使い、Reply-Toにユーザーアドレスを設定
$headers = "From: noreply@dottiq.com\r\n";
$headers .= "Reply-To: " . mb_encode_mimeheader($name) . " <{$email}>\r\n";
// 念のため改行文字を除去
$email = preg_replace('/[\r\n]/', '', $email);
$name = preg_replace('/[\r\n]/', '', $name);
⑥ session_start() の重複呼び出し
これは地味ですが、複数のファイルで session_start() が重複して呼ばれていました。
// A.php
session_start();
require_once 'config.php'; // config.phpでもsession_start()が呼ばれる
何が起きるか:重複呼び出し自体はPHPが警告を出すだけで、直接的に攻撃に使われるわけではありません。しかし警告が出ていること自体、先ほどの display_errors の問題と組み合わさると情報漏洩につながります。また、コードのどこでセッションが開始されているか管理できていない状態は、セッション固定攻撃などへの対処が難しくなります。
修正は config.php に一元化するだけです。
// config.php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// 他のファイルでは session_start() を呼ばない
⑦ 言語判定をHTTP_REFERERで行っていた
サポートフォームからメールを送信する際、日本語か英語かを以下の方法で判定していました。
// 修正前
$is_en = strpos($_SERVER['HTTP_REFERER'] ?? '', '/en/') !== false;
HTTP_REFERER とは「直前に見ていたページのURL」をブラウザが送るヘッダーです。
何が起きるか:REFERERはブラウザのプライバシー設定やHTTPS→HTTP遷移などの条件によって、送られてこないことがあります。英語ページからお問い合わせしたのに、自動返信メールが日本語で届く、という状態が発生します。セキュリティホールというよりバグですが、ユーザー体験として良くないものです。
<!-- 修正後:フォームに隠しフィールドで言語を明示する -->
<input type="hidden" name="lang" value="en">
// send_mail.phpで参照
$is_en = ($_POST['lang'] ?? '') === 'en';
サーバー環境やネットワーク条件に左右されず、フォームから確実に言語情報を渡す方法です。
⑧ ファイルパスのバグ
記事のスラッグ(URLの一部)が変更されたとき、古いURLから新しいURLへリダイレクトするための情報を書き込む処理で、パスの指定が一部のファイルで間違っていました。
// 修正前(パスが一段ずれていた)
$redirectsFile = '../cms/blog/redirects.json';
// 修正後
$redirectsFile = __DIR__ . '/redirects.json';
記事タイトルを変更すると、リダイレクト情報が正しく保存されず、古いURLにアクセスした人が404ページに飛ばされる、というバグです。セキュリティというより品質の問題ですが、こうした細かいパスのミスもレビューで発見されました。
ここまでのまとめ:何が怖いのか
8つの問題をざっと見てきました。整理するとこうなります。
| 問題 | 最悪のケース | 深刻度 |
|---|---|---|
| パスワード平文保存 | 管理画面を乗っ取られる | 🔴 高 |
| 画像アップロード認証なし | サーバー上でコードを実行される | 🔴 高 |
| CSRF未対策 | ページを見ただけで記事が消える | 🟠 中〜高 |
| 並び替えAPI認証なし | 外部からデータを書き換えられる | 🟡 中 |
| メールヘッダーインジェクション | スパム送信機として使われる | 🟠 中〜高 |
| session_start重複 | セッション管理が不安定になる | 🟡 低〜中 |
| REFERER依存の言語判定 | 自動返信メールの言語が間違う | 🟢 低 |
| ファイルパスのバグ | リダイレクトが機能しない | 🟢 低 |
重大なものからしょうもないものまで混在しています。しかもこのサイトは公開されていた。知らなかっただけで、ずっとこの状態だったわけです。
ついでにJSONからSQLiteに移行した
セキュリティ修正が終わったあと、「ついでにデータ管理の仕組みも見直しませんか」という提案がClaudeからありました。
Dottiqでは記事・ツール・リンクといったデータをすべてJSONファイルで管理していました。
cms/blog/articles.json
cms/links/links.json
tools/tools.json
cms/news/news.json
小規模なサイトであればJSONファイルでも動きます。実際に動いていました。ただ、複数のファイルへの同時書き込みが発生したときの整合性や、記事が増えたときの検索・絞り込みのパフォーマンスなど、成長に伴う課題が出てきやすい構成です。
SQLiteは、サーバー上にひとつのファイルとして存在するデータベースです。MySQLのような別途サーバーを立てる必要がなく、PHPから直接使えます。個人サイトやスモールビジネスのサイトに向いています。
移行後は、バラバラだったデータファイルがひとつのデータベースに集約されました。
// 旧:JSONファイルを読み込む
$articles = json_decode(file_get_contents('articles.json'), true);
// 新:SQLiteを使う
$db = get_db();
$stmt = $db->prepare("SELECT * FROM articles WHERE published = 1 ORDER BY created_at DESC");
$stmt->execute();
$articles = $stmt->fetchAll();
コードがすっきりしたのと、検索やカテゴリフィルタもSQLで書けるので、将来的な機能追加が楽になりました。
AIが作ったコードをAIがレビューして気づいたこと
AIは「動くもの」を作るのが得意です。「記事の保存機能が欲しい」と言えば、それを実現するコードを書いてくれます。そのコードは大体動きます。
一方で、「動くかどうか」と「安全かどうか」は別の話です。特に会話の流れで少しずつ機能を追加していくバイブコーディングのスタイルでは、全体を俯瞰した設計よりも「目の前のタスクを解決すること」が優先されやすい。その積み重ねが、今回のような「動いてはいるけど穴だらけ」の状態を生むのだと思います。
面白かったのは、セキュリティレビューをお願いしたら、最初に作ったAIとは別のAIがレビューをしてくれたという点です。異なるモデル、異なる学習データ、異なる癖。同じ「AI」でも、一方が見落とすものをもう一方が指摘することがある。人間でもセルフレビューは見落としが多い、というのと同じかもしれません。
それから、引き継ぎのために別のAIが作成したドキュメントに、実際とは異なる情報が含まれていたことも今回わかりました。「対応済み」とされていたファイルが実は未対応だったり、発生していないはずのGitコミット履歴が記載されていたり。AIが作成したドキュメントをそのまま信用するのは危ない、という教訓です。
バイブコーダーの皆さんへ
このエントリを書こうと思ったのは、同じようにAIと一緒にものを作っている人に「自分のサイトも一度見てもらってみて」と伝えたかったからです。
セキュリティの問題は、何か起きるまで気づきにくい。そして何か起きてからでは遅いことが多い。今回のDottiqが実際に攻撃を受けていたかどうかは分かりませんが、少なくとも攻撃できる状態ではありました。
AIにセキュリティレビューを頼む方法は難しくありません。「このサイトのPHPファイルを見てセキュリティ上の問題がないか確認してください」と伝えて、ファイルを渡すだけです。無料でできます。10分もかかりません。
エンジニアの方から見れば「そりゃそうだ」という内容ばかりだったかもしれません。でも素人が作ると本当にこういうことになる、という実例として笑って読んでいただければ幸いです。
こういうことができてしまう時代だからこそ、「とりあえず動く」の一歩先に、ちゃんと「安全に動く」も意識していきたいと思っています。