こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第55回です。
第2部までで、WebRTCを活用した完全暗号化P2Pチャットという次世代リアルタイム機能を完成させました。
そして今回からは、いよいよ 「第3部:モダンWebの表現力と専ブラ互換の超克」 のスタートです!
古き良き2ちゃんねるの魅力といえば、飾り気のないシンプルなテキスト文化でした。
しかし、Discord、X(旧Twitter)、Slackなどが当たり前になった令和の時代において、スレッドやチャットに青いURL文字列がただポツンと貼られているだけの画面は、どこか寂しく、クリックするまで何の記事かも分かりません。
「ニュース記事やYouTubeのURLを貼ったら、 サムネイル画像やタイトル、記事の概要がパッとカード状にプレビュー表示されるようにしたい! 」
しかし、これをWebブラウザ上で実現しようとすると、 「ブラウザのCORS制限」 と、それを回避するために設置するサーバーサイドプロキシが抱える 「SSRF(サーバーサイド・リクエスト・フォージェリ)攻撃の恐怖」 という巨大な壁が立ちはだかります。
今回は、安全なOGPプロキシAPIの設計と、モダンなブログカード自動展開機能の実装劇をお届けします!
1. ぶつかった2大技術的障壁:CORSとSSRF
① ブラウザから直接取得できない「CORSの壁」
クライアント(JavaScript)から外部のニュースサイト https://news.example.com のHTMLを fetch() しようとしても、ブラウザの同一生成元ポリシー(Same-Origin Policy)およびCORS制限によってブロックされます。
そのため、必ず 「自前のサーバーを経由して外部HTMLを取得するプロキシAPI」 を用意しなければなりません。
② サーバープロキシ最大の脅威「SSRF(内部侵入攻撃)」
しかし、安易に「指定されたURLのHTMLを取ってきて返すAPI」を作ると、悪意あるユーザーによってサーバー自身やクラウドの内部メタデータが攻撃されてしまいます。
攻撃者のリクエスト:
GET /api/ogp?url=http://127.0.0.1:3306/ (ローカルMySQLへのポートスキャン)
GET /api/ogp?url=http://169.254.169.254/ (クラウドインスタンスの認証情報窃取)
GET /api/ogp?url=http://192.168.1.1/admin (社内ルーター管理画面の探索)
この SSRF攻撃 を許せば、サーバーの最高権限やデータベースが瞬時に丸裸になってしまいます。
2. アーキテクチャ設計:多層防御プロキシとキャッシュ層
これらを防ぎ、かつ軽快に動かすため、以下のような多層防御アーキテクチャを構築しました。
多層防御の鉄則
-
URLスキーム制限 :
http://またはhttps://のみ許可(file://,gopher://,dict://などは完全遮断)。 -
DNS名前解決とプライベートIP判定 : リクエスト前にホスト名を名前解決し、ループバック(
127.0.0.1)、リンクローカル(169.254.*)、プライベートアドレス(10.*,172.16-31.*,192.168.*)を徹底排除。 - データ量とタイムアウト制限 : レスポンスサイズは最大1MBで切断、タイムアウトは3秒で打ち切り。
- 24時間キャッシュ : 同一URLへの過度な外部アクセスを防ぎ、表示を爆速化。
3. 実装の全貌
① SSRF防御を施したOGPプロキシAPI(API / サーバー)
Node.jsおよびPHPの双方で、厳格なIP検証とHTMLメタタグ抽出を実装しました。
// server.js (OGPプロキシ)
const dns = require('dns').promises;
const ipRangeCheck = require('ip-range-check');
// プライベートIP / 予約IPのブラックリスト
const PRIVATE_IP_RANGES = [
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'169.254.0.0/16',
'::1/128',
'fc00::/7',
'fe80::/10'
];
app.get('/api/ogp', async (req, res) => {
const targetUrl = req.query.url;
if (!targetUrl) return res.status(400).json({ error: 'URLが必要です' });
try {
const parsed = new URL(targetUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return res.status(400).json({ error: '無効なプロトコルです' });
}
// 1. DNS名前解決を行ってIPアドレスを調査
const addresses = await dns.resolve4(parsed.hostname).catch(() => []);
if (addresses.length === 0) {
return res.status(404).json({ error: 'ホストの名前解決に失敗しました' });
}
// 2. SSRF検証: 解決されたIPがプライベート帯域なら即遮断!
const targetIp = addresses[0];
if (ipRangeCheck(targetIp, PRIVATE_IP_RANGES)) {
console.warn(`[SSRF防御] 不正な内部アクセスを検知・遮断: ${targetUrl} (${targetIp})`);
return res.status(403).json({ error: '内部ネットワークへのアクセスは禁止されています' });
}
// 3. 安全な外部アクセス (3秒タイムアウト & 1MB制限)
const response = await fetch(targetUrl, {
signal: AbortSignal.timeout(3000),
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; 2chBizBot/1.0; +https://2ch.biz)' }
});
const html = await response.text();
// 4. OGPタグの抽出 (Title, Image, Description, SiteName)
const ogpData = extractOgpMeta(html, targetUrl);
res.json(ogpData);
} catch (err) {
res.status(500).json({ error: 'OGP取得エラー', details: err.message });
}
});
🛡️ 上級セキュリティ解説:DNS Rebinding とリダイレクト追従対策
上記のコード例は基本的な名前解決とIPレンジ判定を示していますが、実環境でより完璧なSSRF防御を目指す場合は以下の3点にも配慮が必要です。
- HTTPリダイレクト(30x)追従の制御 :
fetchにredirect: 'manual'を指定し、リダイレクト先URLのIPアドレスも同様にプライベート帯域でないかを再検証する。- DNS Rebinding(TOCTOU)対策 : 判定時の名前解決と
fetch時の名前解決で異なるIPが返されるリスクを防ぐため、検証済みのIPアドレスに対して直接HTTPリクエストを送信する(またはカスタムAgentでDNS Pinningを行う)。- IPv6対応 :
dns.resolve4だけでなくdns.resolve6も併用し、IPv6のループバック(::1)やユニークローカル(fc00::/7)を確実にブロックする。
② フロントエンドでのブログカード自動描画
投稿本文内のURLを正規表現で検出し、投稿の下部に洗練されたブログカードを非同期で追加します。
// app.js
async function renderOgpCardsForPost(postElement, content) {
const urlRegex = /(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g;
const urls = content.match(urlRegex);
if (!urls) return;
// 1投稿あたり最大5件までプレビュー展開
const uniqueUrls = [...new Set(urls)].slice(0, 5);
const container = document.createElement('div');
container.className = 'ogp-card-container';
postElement.appendChild(container);
for (const url of uniqueUrls) {
try {
const data = await fetchOgpWithCache(url);
if (!data || !data.title) continue;
const card = document.createElement('a');
card.className = 'ogp-card';
card.href = url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
card.innerHTML = `
${data.image ? `<div class="ogp-image-wrap"><img src="${escapeHtml(data.image)}" alt="" loading="lazy" /></div>` : ''}
<div class="ogp-content">
<div class="ogp-site-name">${escapeHtml(data.siteName || new URL(url).hostname)}</div>
<div class="ogp-title">${escapeHtml(data.title)}</div>
${data.description ? `<div class="ogp-description">${escapeHtml(data.description)}</div>` : ''}
</div>
`;
container.appendChild(card);
} catch (e) {
console.debug('OGP取得スキップ:', url);
}
}
}
4. 自動テストによるSSRF防御とカード描画の検証
テストスクリプトを実行し、攻撃URLの遮断と正常URLのメタデータ抽出を徹底検証しました。
$ node test_ogp_feature.js
[TEST 1] SSRF攻撃検知: http://127.0.0.1:3306 ... 403 Forbidden (防御成功!)
[TEST 2] SSRF攻撃検知: http://169.254.169.254/latest/meta-data ... 403 Forbidden (防御成功!)
[TEST 3] SSRF攻撃検知: http://192.168.0.1/ ... 403 Forbidden (防御成功!)
[TEST 4] 正常系: ニュース記事URLのOGP取得 ... 200 OK (Title, Image抽出完了)
[TEST 5] キャッシュヒット検証: 2回目のリクエスト ... 0ms 返却確認
✅ すべてのOGPセキュリティ・機能テストに合格しました!
5. まとめと次回予告
URLを貼るだけで、スレッドの話題が一目でわかるリッチなブログカード。
「見た目の派手さ」の裏側には、サーバーを踏み台にしたサイバー攻撃(SSRF)を完璧に防ぐ強固な多層防壁が存在しています。
こうして華々しくデビューしたブログカード機能でしたが、掲示板が盛り上がるにつれて、長年テキストのみで2chを楽しんできた古参ユーザーからこんな意見が寄せられました。
「画像付きのカードは大きすぎて画面を占領するから邪魔だ!」
「昔ながらの 『> 引用文』 みたいなシンプルなテキスト表示にできないのか?」
「でも、スマホで見るときは画像があった方が見やすいし……人によって好みが違いすぎる!」
掲示板のユーザー層は実に多様です。全員を満足させるUIとは何でしょうか?
次回は、 【第056回】ユーザーの好みに合わせる!ブログカード/引用レス風/シンプルテキストの3形態切り替え をお届けします。お楽しみに!
本番サービス : https://2ch.biz/