0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【第055回】URLを貼るだけでカード化!OGPメタデータ取得プロキシとブログカード自動プレビュー

0
Posted at

こんにちは!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. アーキテクチャ設計:多層防御プロキシとキャッシュ層

これらを防ぎ、かつ軽快に動かすため、以下のような多層防御アーキテクチャを構築しました。

多層防御の鉄則

  1. URLスキーム制限 : http:// または https:// のみ許可(file://, gopher://, dict:// などは完全遮断)。
  2. DNS名前解決とプライベートIP判定 : リクエスト前にホスト名を名前解決し、ループバック(127.0.0.1)、リンクローカル(169.254.*)、プライベートアドレス(10.*, 172.16-31.*, 192.168.*)を徹底排除。
  3. データ量とタイムアウト制限 : レスポンスサイズは最大1MBで切断、タイムアウトは3秒で打ち切り。
  4. 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点にも配慮が必要です。

  1. HTTPリダイレクト(30x)追従の制御 : fetch に redirect: 'manual' を指定し、リダイレクト先URLのIPアドレスも同様にプライベート帯域でないかを再検証する。
  2. DNS Rebinding(TOCTOU)対策 : 判定時の名前解決と fetch 時の名前解決で異なるIPが返されるリスクを防ぐため、検証済みのIPアドレスに対して直接HTTPリクエストを送信する(またはカスタムAgentでDNS Pinningを行う)。
  3. 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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?