安全確認
Google Web Risk APIとmshotsを追加
リンク先の安全性を確かめるため、リンク集アプリに管理画面でURLの信頼性をチェックする機能を追加しました。具体的には、Google Safe Browsing APIでURLが安全か否かを問い合わせし、加えてWordPressのmshots機能を使ってリンク先のスクリーンショットを取得し、遷移前に画面を確認します。アプリのバックエンドはLaravelで作っていたもので、AIに聞きながら下記を組み込みました。あと、返ってきた値はデータベースに保存しています。
Google Safe Browsing API V5の実装
それほど難しくなく、Google Cloudでプロジェクトを作って、そこからGoogle Safe Browsing APIのAPI Keyを設定し、Laravel側で記述してあげればOK。個人利用だと無料で、商業利用だとGoogle Web Risk APIを使う必要あり。
実際やってみるとうまく値が返ってきませんでした。てっきり、jsonで返ってくるものと思っていたのです。AIによれば、返り値に含まれるバイナリデータの最初の一文字で判別できるらしく、0aは危険なページを返してくるようです。ちなみに12が安全なページ。悪意あるコンテンツのサンプルページも用意されていて試してみると、危険なページだと返ってきました。
危険かどうかは事前に登録されている必要があるみたいで、Google Safe Browsing APIではURLのみで判定するだけ。安全なページにマルウェア汚染されていても、そこは検知できないようです。Google Web Risk APIだとそこも補えるとAIは説明をしてくれました。無料と有料で差がある感じですね。
$urlCheckStatus = 'safe'; // デフォルト
$apiKey = env('GOOGLE_WEB_RISK_KEY'); // 最初間違ってGoogle Web Risk APIを使った名残
if ($apiKey) {
try {
$safeBrowsingUrl = "https://safebrowsing.googleapis.com/v5alpha1/urls:search";
$safeBrowsingResponse = Http::timeout(10)->get($safeBrowsingUrl, [
'key' => $apiKey,
'urls' => [$this->title->url],
]);
if ($safeBrowsingResponse->successful()) {
$result = $safeBrowsingResponse->json();
if ($response->successful()) {
$stream = $response->toPsrResponse()->getBody();
$firstByte = $stream->read(1);
$hexMarker = bin2hex($firstByte); // 16進数に変換
$stream->close();
// 先頭の1バイト(2文字)の目印で正確にジャッジ
if ($hexMarker === '0a') {
// 0aから始まる場合は、Googleの仕様で「threats」のデータが含まれるため【危険】
$urlCheckStatus = 'unsafe';
Log::warning("⚠️ 危険なURLが検出されました(Safe Browsing V5): {$this->title->url}");
} else {
// 12から始まる場合(120308ac02など)は、キャッシュ期間だけが入った【安全】なURL
$urlCheckStatus = 'safe';
}
}
}
} catch (\Exception $e) {
Log::error("Google Safe Browsing API エラー: " . $e->getMessage());
}
} else {
Log::warning("Google Safe Browsing APIキーが設定されていません。");
}
mshotsの実装
Automattic社が提供しているサービス。WordPressでスクリーンショットを簡単に取得できるようにするためのものらしいです。今回初めてその存在を知りましたが、10年以上前からあったんですね。API Keyなしに使えて、これは便利です。今回は一日一回スケジュールで実行します。ただ、やってみてわかったのですが、リンク先の画像を取得する際、サーバー側でアクセスを拒否したり、ボットじゃありません認証ページが表示されたり、403、404を返されたりすることもあって、万全ではありません。でも、フィッシングサイトへの転身やリンク切れなどではないとわかるので、ある意味安全が確認されたとも言えます。
$cacheBuster = date('Ymd');
$apiUrl = 'https://s.wordpress.com/mshots/v1/' . urlencode($this->title->url) . '?w=800&v=' . $cacheBuster;
try {
// APIにリクエストを送る
$response = Http::withHeaders([
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
])->timeout(15)->get($apiUrl);
if ($response->failed()) {
throw new \Exception('WordPress APIへのリクエストに失敗しました。');
}
$contentType = $response->header('Content-Type');
if (strpos($contentType, 'image') === false) {
throw new \Exception('画像がまだ生成されていません。リトライします。');
}
// 画像の保存
$urlHash = md5($this->title->url);
$fileName = "screenshots/link_{$urlHash}.jpg";
// 保存してDB更新
Storage::disk('public')->put($fileName, $response->body());
$this->title->update([
'screenshot_path' => $fileName,
'url_check' => $urlCheckStatus,
]);
Log::info("新規スクリーンショットを取得・保存しました: Title ID {$this->title->id}");
} catch (\Exception $e) {
Log::warning("スクリーンショット取得リトライ中: " . $e->getMessage());
throw $e;
}
}