タグ: セキュリティ

  • GTMの設定は誰でも読める

    GTMの設定は誰でも読める

    GTMのコンテナIDをURLに打ち込むと、その設定が誰でも読めます。

    開いてみる
    https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX

    返ってくるのはJavaScriptですが、その中にどんなタグが、どんな条件で発火するかが全部入っています。ログインは要りません。これは不具合ではなく、そうでなければ動かない仕組みです。

    この記事で扱うこと

    なぜコンテナの中身が公開されているのか、そこから実際に何が読めるのか、そして「だから何をしてはいけないか」。計測IDが公開情報であることと、その先で何が見えるかは、分けて考える必要があります。

    公開されていないと動かない

    理由は考えてみれば当たり前です。

    GTMは、閲覧者のブラウザの中で動きます。ブラウザが設定を読み込めなければ、タグを発火させようがありません。そしてブラウザが読めるということは、人間も読めるということです。

    置き場所 誰が読めるか
    GTMのコンテナ設定 閲覧者のブラウザ 誰でも
    サーバー側の処理 自分のサーバー 自分だけ

    ブラウザで動くものに秘密は持てません。これはGTMに限った話ではなく、フロントエンドに置いたものはすべて公開されているという一般則の一例です。JavaScriptを難読化しても、通信を暗号化しても、最終的にブラウザが解釈する以上は読めます。

    何が読めるのか

    このサイトのコンテナを例にすると、次のようなことが分かります。

    読めること この例では
    使っている計測ツール GA4(測定ID付き)
    タグの名前 GA4 – article_read など
    発火の条件 カスタムイベント article_read
    送っているパラメータ名 article_slug / article_title
    データレイヤー変数の名前 同上

    つまり「このサイトは何を測っているか」が丸ごと見えます。競合他社のサイトを調べる手段としても使われますし、それ自体は行儀の悪いことではありません。

    逆に言えば、あなたのサイトも同じように見られています。計測の設計を秘密にしておくことはできない、と最初から考えておくのが正しい前提です。

    だから、書いてはいけないもの

    ここが実務上いちばん大事な帰結です。

    カスタムHTMLタグに秘密の値を書かない

    GTMには「カスタムHTML」というタグの種類があり、任意のJavaScriptを書けます。便利なので、外部サービスのAPIキーやトークンを直接書いてしまう事故が起こります。それは公開されたテキストファイルに書くのと同じです。コンテナを公開した瞬間、世界中から読めます。

    同じ理由で、次のようなものも置けません。

    社内向けの管理画面のURL、テスト環境のアドレス、まだ発表していないキャンペーンの名前。タグの名前や変数の名前にも情報が乗ります。「新製品X_LP_2026春」というタグ名を作れば、それも読めます。

    IDが公開なのと、悪用できるのは別

    「IDが見えるなら、誰かに偽のデータを送られるのでは」という心配が出ます。実際その通りで、GA4の測定IDが分かれば、第三者がデータを送ることは技術的にできます。

    ただしこれはIDを記事に書いたから起きるのではありません。サイトを開けば誰でも分かる以上、隠しようがないからです。

    心配 実際のところ
    設定を書き換えられる できない。読めるだけで、変更にはGTMアカウントの権限が要る
    偽のデータを送られる できる。ただしIDを隠しても防げない
    秘密の値を盗まれる 置かなければ盗まれない。ここだけが自分で決められる

    3行目だけが、こちらの判断でどうにでもなります。防げないことに対策を考えるより、防げることを確実にやるほうが効きます。

    偽データへの現実的な対処

    GA4にはホスト名で受信を制限する機能がありません。実務では、GTM側のトリガーに「ホスト名が自分のドメインのときだけ発火する」条件を足します。これはサイトを複製された場合に効きます。直接データを送りつけられるケースは、サーバー側で計測する構成にしない限り防げません。個人サイトでそこまでやる必要があるかは、別途考えるべきことです。

    公開前に自分で読んでみる

    実際に一度開いてみることを勧めます。自分のコンテナが何を晒しているかを、公開する前に知っておけます。

    確認の手順
    1. https://www.googletagmanager.com/gtm.js?id=(自分のコンテナID)を開く
    2. ページ内検索で、次のような語を探す
       - key / token / secret / password
       - 社内のドメイン名
       - 未発表の企画名

    何も出てこなければ問題ありません。見つかったら、公開を止めてそのタグを直します。一度公開したものは、消しても誰かの手元に残っている可能性があります。

    今回の学び

    まず、ブラウザで動くものに秘密は持てないということ。GTMのコンテナが公開されているのは設計の欠陥ではなく、そうでなければ動かないからです。「見えてしまう」ではなく「見えるように作られている」と理解すると、置いていいものと悪いものの線が引けます。

    次に、IDが公開なのと、悪用できるのは別の話だということ。読めることと書き換えられることは違います。混同すると、防げないことに時間を使い、防げることを見落とします。

    そして、名前にも情報が乗ること。値だけを気にして、タグ名や変数名を無防備にしてしまいがちです。読まれる前提で名前を付ける習慣は、持っておいて損がありません。

  • 所有権をどう証明するか

    所有権をどう証明するか

    「このサイトはあなたのものですね」を、Googleはどうやって確かめているのか。

    Search Consoleにサイトを登録すると、所有権の証明を求められます。方法は5つ提示されますが、それぞれ証明している中身が違います。同じ「所有権」という言葉でくくられているせいで、そこが見えにくくなっています。

    この記事で扱うこと

    5つの確認方法が何を根拠にしているか、DNSのTXTレコードだけが別格である理由、ドメインプロパティでDNS確認しか認められていない理由。そして実際に登録するときの注意点。

    5つの方法は、証明の強さが違う

    Search Consoleが提示する確認方法を、「何ができる人だと証明しているか」で並べ替えるとこうなります。

    方法 証明していること
    Googleアナリティクス そのGA4プロパティを編集できる
    Googleタグマネージャー そのGTMコンテナを編集できる
    HTMLタグ そのページのHTMLを書き換えられる
    HTMLファイル そのサーバーにファイルを置ける
    DNSレコード そのドメインそのものを支配している

    上の4つは、いずれも「サイトの中身に手が届く」ことしか示していません。制作を請け負っている人でも、CMSの編集権限を持っている人でも通せます。

    DNSだけが違います。DNSレコードを書き換えられるということは、そのドメインをどのサーバーに向けるかを決められるということです。極端に言えば、サイトの中身を丸ごと別の場所に差し替えられます。所有者でなければ持てない権限です。

    だからドメインプロパティはDNSしか認めない

    Search Consoleのドメインプロパティは、www の有無もサブドメインもすべてまとめて扱います。cms.kisaku.site のような、まだ存在しないサブドメインまで対象に含みます。まだ無いサブドメインにファイルは置けません。だから「ドメイン全体の持ち主である」ことを、上の階層で一度に証明する必要があります。それができるのはDNSだけです。

    TXTレコードとは何か

    DNSは本来、名前をIPアドレスに変えるための仕組みです。レコードには種類があります。

    種類 用途
    A / AAAA 名前をIPアドレスに変換する
    CNAME 名前を別の名前に転送する
    MX メールの宛先サーバーを示す
    NS このドメインを管理するサーバーを示す
    TXT 任意の文字列を置ける(用途は決まっていない)

    TXTだけが異質です。「好きな文字を書いておける場所」として定義されていて、何に使うかは決められていません。

    この自由さが、あとから来た用途に次々と使われました。所有権の証明もそのひとつです。SPF(メールの送信元認証)も、DKIMも、TXTレコードの上に成り立っています。

    実際に登録した内容
    ホスト名   (空欄。管理画面によっては @ と表示される)
    種別       TXT
    値         google-site-verification=(発行された文字列)
    TTL        既定のまま

    ホスト名を空欄にするのは、ドメインそのもの(kisaku.site)に対して置くという意味です。ここに www と入れると www.kisaku.site のレコードになり、ドメインプロパティの確認には使えません。

    なぜこれで証明になるのか

    仕組みは単純です。

    確認の流れ
    1. Googleが、あなた専用のランダムな文字列を発行する
    2. あなたが、その文字列を kisaku.site のTXTに登録する
    3. Googleが、世界中から見えるDNSに問い合わせる
    4. 発行した文字列が返ってくれば、確認完了

    ポイントは3番です。GoogleはあなたのサーバーやCMSを見に行っていません。公開されているDNSに問い合わせているだけです。

    そしてDNSの内容を書き換えられるのは、ドメインを管理している人だけです。第三者があなたのドメインのTXTレコードに勝手な値を入れることはできません。「書き換えられた」という事実そのものが証明になっているわけです。

    パスワードを渡さずに証明する

    この方式のうまさは、Googleに何の権限も渡していないことです。サーバーのログイン情報も、CMSの管理権限も渡していません。「あなたにしかできない操作を実際にやってみせる」だけで済んでいます。所有権の証明としては、これがいちばん筋の通った形です。

    反映を自分で確かめる

    DNSの変更は、登録した瞬間に世界中へ伝わるわけではありません。管理画面で保存したあと、実際に公開されるまでに時間がかかります。

    「確認」を押して失敗したとき、原因が登録内容の間違いなのかまだ反映されていないだけなのかは、画面からは分かりません。ここで待ち続けるか設定を疑うかを間違えると、時間を大きく無駄にします。

    自分で問い合わせれば、すぐに区別できます。

    TXTレコードを直接見る
    dig kisaku.site TXT +short
    
    "v=spf1 include:spf21.gmoserver.jp ~all"
    "google-site-verification=..."

    ブラウザだけでも確認できます。https://dns.google/resolve?name=kisaku.site&type=TXT を開くと、Googleのリゾルバが見ている内容がJSONで返ってきます。Googleが実際に何を見ているかを、Googleに聞くので、Search Consoleの判定と食い違いません。

    最初に確認したとき、まだ見えていなかった

    実際、私は1回目の「確認」で失敗しました。設定を疑う前にDNSへ直接問い合わせたところ、返ってきたのはSPFの1件だけで、確認用の文字列はまだ公開されていませんでした。失敗の原因が「間違い」ではなく「待ち」だと分かったので、設定をいじり回さずに済みました。

    保存できたのに、公開されない

    ここからは、実際に私がはまった話です。

    管理画面でTXTレコードを追加し、「保存しました」と表示されました。ところが何時間経っても、DNSに問い合わせると返ってくるのはSPFの1件だけ。TTLは600秒——10分です。10分待てば消えるはずのキャッシュを、何時間も疑っていました。

    原因は、編集していた管理画面が、そのドメインの答えを持っていなかったことでした。

    ドメインの管理画面は、1つとは限らない

    ドメインを買った会社と、そのドメインの問い合わせに実際に答えるサーバーは、別々にできます。どちらが答えるかを決めているのがNSレコードです。

    誰が答えているのかを聞く
    dig kisaku.site NS +short
    
    dns01.gmoserver.jp.
    dns02.gmoserver.jp.

    返ってきたのはレンタルサーバー側のDNSでした。ドメインを買った会社の管理画面ではありません。

    できること 今回の状態
    ドメイン登録側の管理画面 NSレコードを決める ここを編集していた
    NSが指しているDNS 実際の答えを返す こちらが本番だった

    権限のない側の画面に書いた設定は、エラーにならずに保存され、そして誰にも参照されません。これが厄介なところです。間違いを教えてくれる仕組みが、どこにもありません。

    「保存できた」は「効いている」ではない

    管理画面の保存成功は、その画面のデータベースに書けたことしか意味しません。それが世界から参照されるかどうかは別の話です。DNSに限らず、設定が複数の場所に分かれている仕組みでは同じことが起こります。

    先に、答えている場所を特定する

    この失敗を避ける手順は単純です。レコードを追加する前に、NSを引く。

    作業前に確認する
    # 誰が答えるか
    dig kisaku.site NS +short
    
    # ブラウザだけでも見られる
    https://dns.google/resolve?name=kisaku.site&type=NS

    返ってきたサーバー名で、どこの管理画面を開けばいいかが分かります。*.gmoserver.jp ならレンタルサーバーのコントロールパネル、*.vercel-dns.com ならVercel、*.cloudflare.com ならCloudflareです。

    既存のレコードが、目印になる

    正しい画面かどうかは、開いた瞬間に判断できます。すでに動いているレコードが並んでいる画面が本番です。今回で言えば、AレコードとSPFとサブドメインのCNAMEが揃っている画面。逆に、実際のサイトが動いているのに空っぽの一覧が表示されたら、そこは参照されていない画面です。

    既存のレコードを消さない

    DNSの編集で、いちばん怖いのはこれです。

    TXTレコードには、すでにSPF(メールの送信元認証)が入っていることがよくあります。これを消すと、送ったメールが迷惑メール扱いされるようになります。サイトの表示には何の影響もないので、しばらく気づきません。

    TXTは複数あってよい

    「TXTはひとつしか置けない」と誤解して、既存のSPFを上書きしてしまう事故が起きます。同じホスト名に複数のTXTレコードを共存させられます。追加するのは1行だけで、既存の行はそのまま残します。

    もっと危険なのはAレコードやCNAMEです。ここを触るとサイト全体が見えなくなります。DNSは、間違えたときの影響がサイト全体に及ぶ数少ない設定です。作業前に現在の内容を控えておくと、戻せます。

    今回の学び

    まず、同じ「所有権の証明」でも、証明している中身が違うということ。ファイルやHTMLタグは「中身に手が届く」ことしか示しません。DNSだけが「ドメインの持ち主である」ことを示します。選択肢が並んでいると同じものに見えますが、強さが違います。

    次に、TXTレコードは「用途が決まっていない」から使われていること。所有権の証明もSPFも、DNSの設計時には想定されていない用途です。汎用の置き場所をひとつ用意しておいたことが、後から効いています。

    そして、失敗したときは「間違い」か「待ち」かを先に切り分けること。DNSは反映に時間がかかる仕組みなので、この2つが同じ見た目になります。切り分けずに設定をいじると、正しかったものまで壊します。

    さらに、「保存できた」は「効いている」ではないこと。今回いちばん時間を取られたのは、参照されていない管理画面に正しい値を書き続けていたことでした。書き込む前にNSを引いて、答えている場所を先に特定する。これだけで防げます。

    最後に、影響範囲の広い設定ほど、触る前に控えを取ること。DNSは間違いがサイト全体に及びます。しかもメール側の不具合は、しばらく誰も気づきません。

  • 自分のブログに記事が投稿できない — WAFの誤検知と、渡し方を変える解決

    自分のブログに記事が投稿できない — WAFの誤検知と、渡し方を変える解決

    自分のブログに、自分で書いた記事が投稿できなくなりました。

    原因はサーバーのWAF(Webアプリケーションファイアウォール)でした。記事の本文に含まれるコードが、攻撃と誤検知されて遮断されていたのです。回避を4種類試して全部失敗し、最後に「内容を変えずに、渡し方を変える」ことで解決しました。

    この記事で扱うこと

    WAFによる誤検知、二分探索を使った原因の特定、実体参照によるすり抜けがなぜ効かないのか、そしてWordPressのインポート機能を使った解決。技術記事を書く人ほど踏みやすい問題です。

    403だけが返ってくる

    記事をWordPressのREST APIに送ると、403が返りました。

    返ってきたもの
    status: 403
    body: (空。WordPressのエラー形式ですらない)

    ここに最初の手がかりがあります。WordPressが権限で拒否する場合、rest_cannot_create のようなコードを含むJSONを返します。中身が空の403は、WordPressに届く前に止められていることを意味します。

    直前まで同じ手順で5本の記事を投稿できていました。コードも認証も変えていません。変えたのは記事の中身だけです。

    どこが引っかかっているのかを探す

    本文は約13,000文字あります。どこが原因かを目で探すのは現実的ではありません。

    そこで二分探索を使いました。本文の前半だけを送ってみて、通れば原因は後半にあり、弾かれれば前半にある。これを繰り返して範囲を半分ずつ狭めていきます。13,000文字なら、14回ほどで場所が特定できます。

    やったこと(ブラウザのコンソールで実行)
    const ok = async (text) => {
      const res = await fetch('/wp-json/wp/v2/posts', {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
        body: JSON.stringify({ title: 'probe', content: text, status: 'draft' })
      });
      return res.status === 201;
    };
    
    let lo = 0, hi = content.length;
    while (hi - lo > 16) {
      const mid = Math.floor((lo + hi) / 2);
      if (await ok(content.slice(0, mid))) lo = mid; else hi = mid;
    }
    console.log(content.slice(lo - 50, hi + 16));   // 境界の前後を表示

    結果はこうでした。

    const HEADING_PATTERN = 正規表現でHTMLの見出しタグを拾う行

    記事の中で紹介していた、見出しを抽出する正規表現です。WAFから見ると、HTMLタグを組み立てようとしている文字列=コード注入の試みに見えます。

    検証用のデータを残さない工夫

    上の関数は、投稿に成功した場合その投稿をすぐゴミ箱に入れています。二分探索は十数回の投稿を行うので、これをやらないと「probe」という記事が大量に残ります。調査のために本番環境を汚さないのは、調べる前に決めておくべきことです。

    すり抜けようとして、4回失敗した

    原因が分かったので、表示を変えずに検知だけ避ける方法を順に試しました。

    試したこと ねらい 結果
    バックスラッシュを数値文字参照に エスケープ記号を消す 失敗
    バッククォートも数値文字参照に コマンド置換に見える形を消す 失敗
    コード部分の記号をすべて数値文字参照に 記号の並びを根本から消す 失敗
    JSONではなくフォーム形式で送る 検査対象から外れることを期待 失敗

    3番目で分かったことが重要でした。WAFは実体参照を元に戻してから判定しています。

    これは考えてみれば当然です。攻撃者も同じ手を使うからです。<script> と書けば通ってしまうWAFには、防御の意味がありません。ブラウザが解釈する前の姿ではなく、解釈した後の姿で判定するのが正しい実装です。

    つまり、表示が同じである限り、エンコードを変えても結果は変わりません。目的が「表示は同じまま通す」ことである以上、この方向に出口はないと分かりました。

    ひとつだけ、通っていた経路があった

    行き詰まったところで、それまでの作業を振り返りました。

    記事の本文をブラウザに渡すために、13,000文字のJSONファイルをWordPressのメディアにアップロードしていました。そのアップロードは、一度も失敗していません。

    中身は同じです。同じ正規表現も、同じコードも入っています。それでも通っていました。

    送り方 中身 結果
    JSONの本文として送る 同じ 403
    フォームの項目として送る 同じ 403
    ファイルとして送る 同じ 成功

    このWAFは、フォームの入力値は検査するが、ファイルの中身は検査していないという動きをしていました。

    これは手抜きではなく、設計上の判断だと思われます。アップロードされるファイルは画像や動画やアーカイブで、中身は任意のバイト列です。すべてを攻撃パターンで検査すれば、正常なファイルが大量に誤検知されます。

    正規の道具が、すでに用意されていた

    ファイルなら通る。ではファイルとして記事を投入する方法はあるか。

    あります。WordPressのインポート機能です。サイト間で記事を引っ越すための仕組みで、記事をWXR(WordPress eXtended RSS)という形式のXMLファイルとして読み込みます。

    article6.xml(抜粋)
    <item>
      <title>読む体験をつくる</title>
      <dc:creator><![CDATA[sakurai_dev]]></dc:creator>
      <content:encoded><![CDATA[ ここに記事本文のHTML ]]></content:encoded>
      <wp:post_name><![CDATA[reading-experience]]></wp:post_name>
      <wp:status><![CDATA[publish]]></wp:status>
      <wp:post_type><![CDATA[post]]></wp:post_type>
      <category domain="category" nicename="tech"><![CDATA[技術系]]></category>
      <category domain="post_tag" nicename="react"><![CDATA[React]]></category>
    </item>

    本文は CDATA という区画に入れます。この中身はXMLとして解釈されないので、HTMLタグをそのまま書けます。

    「ツール → インポート → WordPress」からこのファイルをアップロードすると、記事・カテゴリ・タグがまとめて作られます。一度で通りました。

    抜け道ではない

    この方法は、WAFをだましているわけではありません。WordPressが元から用意している正規の移行手段を、そのまま使っただけです。防御は一切弱めていません。<script>alert(1)</script> は今も遮断されたままです。

    WAFを無効にしなかった理由

    途中、WAFを一時的に無効にして試したことがあります。実際、正規表現の行は通るようになりました。しかし記事全体はまだ弾かれ、<script> も遮断されたままでした。防御が複数の層で構成されていたためです。

    ここで「全部無効にする」という選択もありました。しかし、それはやめました。

    理由は単純です。この問題は今後も繰り返し起きます。技術記事を書く以上、本文にコードが入るのは避けられません。次に何が引っかかるかは予測できません。そのたびにWAFを無効にしていたら、いずれ「戻し忘れ」が起きます。

    作業のたびに防御を外す運用は、いつか必ず外したままになります。人間の注意力に頼る仕組みは、長く続けると必ず破れます。防御を触らずに済む経路が見つかったなら、そちらを選ぶべきです。

    今回の学び

    まず、空の403は「届いていない」という意味だということ。アプリケーションが返すエラーには、たいてい理由が書いてあります。理由の無いエラーは、アプリケーションより手前で止められています。どこを調べるべきかが、これで決まりました。

    次に、手作業で探さないこと。13,000文字から原因の1行を見つけるのに、二分探索なら14回で済みます。目で追えば見落としますし、時間もかかります。範囲を半分ずつ狭める方法は、原因が1か所に絞れる問題ならほぼ常に使えます。

    そして、同じ内容でも、渡し方が変われば結果が変わるということ。今回いちばんの発見はこれでした。中身をどう変形しても通らなかったものが、経路を変えた瞬間に通りました。行き詰まったら、変える対象を「中身」から「経路」に移す。この視点は他の場面でも使えます。

    最後に、防御を弱める解決を最後の手段にすること。無効にすれば即座に解決しますが、それは問題を先送りしているだけです。しかも「一時的に」で済んだためしがありません。遠回りに見えても、防御を維持したまま通る道を探す価値はありました。

  • 書いた記事をWordPressに届けるまで — CORS・nonce・CSSの壁

    書いた記事をWordPressに届けるまで — CORS・nonce・CSSの壁

    「作った」と「届いた」は違う

    ここまでの作業記録を記事にまとめ、HTMLファイルとして書き出しました。CSSも当て、ブラウザで開けばきちんと読める状態になっていました。私はそこで「記事の作成は完了した」と考えていました。

    しかし当然ながら、ファイルがローカルにあるだけでは誰も読めません。記事はブログに載って初めて記事です。成果物の形が合っていても、あるべき場所に届いていなければ完了ではない——今回いちばん効いた教訓はこれでした。

    そこで「HTMLファイル4本を、WordPressの投稿として登録する」という作業が発生します。手作業でコピー&ペーストすれば済む話に見えますが、1本あたり1万文字を超えるHTMLです。しかも今回の環境では、その素朴な方法が使えませんでした。この記事は、その受け渡し経路を作るまでの記録です。

    この記事で扱うこと

    ブラウザの同一オリジンポリシー(CORS)、WordPressのREST APIとnonce、アップロード可能なファイル形式の判定、React(Next.js)で外部HTMLを表示するときのCSS設計、そして公開後の検証方法。どれも「WordPressとNext.jsをつなぐ」構成では避けて通れない話です。

    通せる経路を探す

    状況を整理します。記事のHTMLは手元の作業環境にあり、WordPressの管理画面はブラウザで開いています。この2つの間には直通の道がありません。

    考えられる経路を順に試しました。

    案 やり方 結果
    1 GitHubのファイルURLをブラウザから直接読み込む 失敗。リポジトリが非公開なうえ、別ドメインなのでCORSで弾かれる
    2 作業環境から直接WordPressのAPIを叩く 失敗。作業環境からの外向き通信が制限されている
    3 記事本文をそのままスクリプトに書き込んで実行する 理屈上は可能。ただし4本で約5万文字。引用符やバックスラッシュのエスケープを1文字でも間違えると壊れる
    4 メディアライブラリにファイルをアップロードし、そのURLから読む 最初は失敗。原因の切り分けに時間を使った(後述)

    最終的に採用したのは、この4案のどれでもない5番目の方法でした。ただしそこにたどり着くには、まず1番の失敗が何を意味しているかを理解する必要があります。

    なぜブラウザは他所のデータを読ませないのか

    案1で出たエラーは Failed to fetch でした。ファイルが無いのでも、通信ができないのでもありません。ブラウザが意図的に読み取りを止めています。

    ブラウザには同一オリジンポリシーという原則があります。オリジンとは「プロトコル+ドメイン+ポート」の組のことで、https://cms.kisaku.site と https://github.com は別のオリジンです。原則として、あるオリジンのページで動くJavaScriptは、別のオリジンのデータを読めません。

    なぜそんな制限があるのか。もし制限が無ければ、こういうことが起きます。

    悪意のあるサイトで動くJavaScript(もし制限が無ければ成立してしまう)
    // 訪問者がネットバンクにログイン済みなら、
    // Cookieが一緒に送られて「本人のリクエスト」として通ってしまう
    const res = await fetch('https://bank.example.com/api/balance', {
      credentials: 'include'
    });
    const data = await res.json();   // 残高が読めてしまう
    send_to_attacker(data);

    つまり同一オリジンポリシーは、「ログイン状態を勝手に借用して他所のデータを盗む」ことを防ぐための仕組みです。CORS(Cross-Origin Resource Sharing)は、その原則に対して「このオリジンからなら読ませてよい」とデータの提供側が明示的に許可を出すための仕組みです。許可を出すのは受け取る側ではなく渡す側だという点が重要で、だから今回のように相手がGitHubであれば、こちらの都合で許可を足すことはできません。

    よくある誤解

    「CORSエラーが出た=サーバーが落ちている/URLが間違っている」と考えてしまいがちですが、違います。リクエスト自体はサーバーに届いていて、レスポンスも返ってきています。ブラウザがそれをJavaScriptに渡さないだけです。curl で同じURLを叩くと普通に取得できるのは、curlにはこの制限が無いからです。

    ファイル選択欄を通り道にする

    ここで発想を変えます。ブラウザが「別のオリジンから取ってくる」のを止めるなら、取ってこなければいい。利用者が自分の意思でファイルを選んで渡す経路なら、CORSは関係ありません。

    ファイル選択欄(<input type="file">)がまさにそれです。ここには、セキュリティ上の重要な非対称性があります。

    操作 可否 理由
    JavaScriptからファイルパスを設定する できない できてしまうと、サイト側が勝手に利用者のファイルを盗み出せる
    選ばれたファイルの中身をJavaScriptから読む できる 利用者が明示的に選んだファイルなので、同意があるとみなせる

    この「選ばれた後なら読める」を使います。ポイントは、ファイルをアップロードはしないことです。選択欄に入れるところまでで止め、中身だけをJavaScriptで読み出します。

    WordPress管理画面のコンソールで実行
    // 「メディアを追加」ページのファイル選択欄
    const input = document.getElementById('async-upload');
    const file  = input.files[0];
    
    // アップロードはしない。中身だけ読む。
    // File.text() はテキストとして読み出すメソッド(Promiseを返す)
    const text = await file.text();
    const posts = JSON.parse(text);
    
    console.log(posts.length);          // 4
    console.log(posts[0].content.length); // 11344

    記事4本をJSONの配列にまとめた1つのファイルを用意し、それを選択欄に入れて読み込みました。これで、5万文字のHTMLがブラウザのメモリ上に乗ります。CORSも通信も介在しません。

    この方法の良いところ

    メディアライブラリに中継用ファイルが残りません。「サーバーに置いてから読む」方式だと、用が済んだあとのファイルが公開状態で残り続けます。今回のように一度きりの受け渡しでは、置かずに済ませるほうが後始末が要りません。

    「権限がありません」の正体

    この方法にたどり着く前に、案4(メディアにアップロードしてから読む)を試していました。そこで出たのがこのエラーです。

    このファイルタイプをアップロードする権限がありません。

    管理者としてログインしているのに「権限がありません」と言われる。素直に読むと「WordPressが .txt を受け付けない設定になっている」と解釈したくなります。しかし調べると、WordPressは標準で text/plain を許可しています。話が合いません。

    そこで、同じことをREST API経由で試しました。小さなファイルを作って投げてみます。

    許可されている形式を実際に確かめる
    const form = new FormData();
    form.append('file', new Blob(['hello'], {type: 'text/plain'}), 'test.txt');
    
    const res = await fetch('/wp-json/wp/v2/media', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {'X-WP-Nonce': nonce},
      body: form
    });
    console.log(res.status);   // 201 → 作成成功

    201が返りました。.txt は問題なくアップロードできる。つまりエラーの原因はファイル形式ではありませんでした。管理画面のフォームを操作する過程で、フォームに埋め込まれていたnonceが本来の組み合わせから外れてしまい、WordPressが「この操作は正当な経路から来ていない」と判断していたのです。

    nonceとは何か

    nonceは “number used once” の略で、その操作が正規の画面から、本人の意思で行われたことを示す使い捨ての合言葉です。Cookieだけで認証していると、こういう攻撃が成立してしまいます。

    CSRF(クロスサイトリクエストフォージェリ)の例
    <!-- 攻撃者のページ。訪問者がWordPressにログイン済みだと…… -->
    <form action="https://example.com/wp/wp-admin/user-new.php" method="post">
      <input name="user_login" value="attacker">
      <input name="role" value="administrator">
    </form>
    <script>document.forms[0].submit();</script>

    Cookieはドメインに対して自動で送られるので、リクエストは「ログイン中の管理者からの正当な要求」に見えてしまいます。nonceがあると、攻撃者はその値を知り得ないため、この形の攻撃は成立しません。

    ややこしいのは、WordPressのnonceは用途ごとに別物だという点です。管理画面のフォームに埋まっているnonceと、REST APIが要求するnonceは別で、片方をもう片方に使うと弾かれます。

    用途 取得方法 渡し方
    管理画面のフォーム送信 フォーム内の hidden 項目(_wpnonce) フォームの一部として送信
    REST API wpApiSettings.nonce、または admin-ajax.php?action=rest-nonce X-WP-Nonce ヘッダ

    最初、私はページのHTMLから正規表現で10桁の16進数を拾ってnonceにしていました。当然これは別用途のnonceを掴むことがあり、rest_cookie_invalid_nonce という403が返ります。確実な取り方は次のとおりです。

    REST API用のnonceを確実に取る
    // WordPressが専用に用意しているエンドポイント。
    // 管理画面にログインしていれば、有効なnonceを文字列で返す。
    const nonce = (await fetch('/wp/wp-admin/admin-ajax.php?action=rest-nonce', {
      credentials: 'same-origin'
    }).then(r => r.text())).trim();
    切り分けの考え方

    エラーメッセージは「何が起きたか」を教えてくれますが、「なぜ起きたか」は教えてくれません。「権限がありません」を額面どおり受け取っていたら、許可形式を増やす設定変更に進んでいたはずです。実際の原因は認証の受け渡しでした。別の経路で同じことを試して結果が変わるなら、原因は自分が疑っていた場所には無い——これは今回いちばん役に立った切り分け方でした。

    REST APIで下書きを作る

    本文がブラウザ上に乗り、有効なnonceも手に入りました。あとは投稿を作るだけです。

    投稿を下書きとして作成する
    const post = posts[0];
    
    const res = await fetch('/wp-json/wp/v2/posts', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
        'X-WP-Nonce': nonce
      },
      body: JSON.stringify({
        title:      post.title,
        content:    post.content,
        status:     'draft',   // いきなり公開しない
        categories: [11]       // 「技術系」カテゴリのID
      })
    });
    
    const created = await res.json();
    console.log(res.status, created.id);   // 201 66

    status を draft にしているのは意図的です。いきなり publish にすると、表示が崩れていた場合に崩れた状態が世に出ます。下書きなら、確認してから公開できます。取り返しのつく順番で進めるというだけの話ですが、効果は大きいです。

    保存されたものを疑う

    201が返ったから成功、とはしませんでした。WordPressには wp_kses_post() という仕組みがあり、投稿本文から危険なタグや属性を削り落とすことがあります。今回の記事は class 属性に強く依存しているので、これが削られると装飾が全部消えます。

    削られるかどうかは、投稿するユーザーが unfiltered_html という権限を持つかで決まります。単一サイトの管理者は持っていますが、それは「持っているはず」であって「確認した」ではありません。そこで、保存されたものを読み戻して元と突き合わせました。

    保存後の本文が元と一致するか確かめる
    // context=edit を付けると、加工前の生データ(content.raw)が返る
    const res = await fetch('/wp-json/wp/v2/posts/66?context=edit', {
      credentials: 'same-origin',
      headers: {'X-WP-Nonce': nonce}
    });
    const saved = await res.json();
    
    console.log(saved.content.raw === post.content);       // true
    console.log((saved.content.raw.match(/<pre/g) || []).length);  // 12
    console.log(saved.status, saved.categories);           // draft [11]

    context=edit を付けるのがポイントです。これを付けないと表示用に整形された content.rendered しか返らず、元と比べても差が出てしまいます。生データ同士で比較して初めて「1文字も失われていない」と言えます。

    スラッグを先に整える

    公開する前に、URLの末尾になるスラッグを英語に変えました。日本語タイトルのまま公開すると、スラッグも日本語になり、URLがパーセントエンコードされて非常に長くなります。

    スラッグを後から変えるとリンクが切れる
    // 日本語スラッグの実際の姿
    /blog/%E3%81%AA%E3%81%9C%E3%81%93%E3%81%AE%E6%A7%8B%E6%88%90...
    
    // 変更後
    /blog/headless-wordpress-nextjs-architecture

    これを公開前にやるのには理由があります。公開後にスラッグを変えると、それまでのURLが404になります。SNSで共有されていれば、そのリンクは死にます。URLは公開した瞬間に「他人が持つ資産」になるので、後から変えられる前提で設計してはいけません。

    CSSが当たらない

    記事を1本公開して、フロント(Next.js側)で見てみると、文章は出るものの装飾が一切効いていませんでした。コードブロックは白背景のただの文字、補足ボックスも普通の段落です。

    原因は2つ重なっていました。

    1. className は流し込んだHTMLの中には届かない

    WordPressの本文は、React側では次のように表示しています。

    frontend/src/app/blog/[slug]/page.tsx
    <div
      className="wp-content mt-8 rounded-2xl bg-white p-6"
      dangerouslySetInnerHTML={{ __html: post.content }}
    />

    dangerouslySetInnerHTML は「このHTMLを解釈せずそのまま入れる」という指定です。中身はReactのコンポーネントではないので、Reactが管理する仕組み(className を渡す、コンポーネントを差し込む)は一切届きません。外側の div にどれだけクラスを足しても、内側の h2 や pre には影響しません。

    したがって、CSS側で「この箱の中の要素」として受けるしかありません。

    2. Tailwindが標準のスタイルを消している

    Tailwind CSSにはpreflightという機能があり、読み込むと h2 の文字サイズや p の余白といったブラウザ標準のスタイルを全部リセットします。ブラウザ差をなくすための仕組みですが、外部から流し込んだHTMLには致命的で、見出しと本文が同じ見た目になります。

    この2つをまとめて解決する形が、こうなります。

    frontend/src/app/globals.css
    .wp-content {
      /* 色は変数にまとめる。調整を1か所で済ませるため */
      --code-bg: #1c2128;
      --line:    #e3e6ea;
    
      line-height: 2;    /* 日本語は行間を広く取ると格段に読みやすい */
      font-size: 17px;
    }
    
    /* 装飾ではなく「上の余白」で階層を出す。
       線や色を足すより、空白のほうが読み手には強く効く */
    .wp-content h2 {
      font-size: 1.5rem;
      font-weight: 700;
      margin: 4.5rem 0 1.5rem;
      scroll-margin-top: 1.25rem;   /* 目次から飛んだとき上端に貼り付かない */
    }
    
    /* コードは濃色にして「ここは読み物ではない」と視覚的に伝える */
    .wp-content pre {
      background: var(--code-bg);
      color: #dfe3e8;
      border-radius: 8px;
      padding: 1.1rem 1.25rem;
      overflow-x: auto;   /* 折り返さない。折り返すとコードの構造が壊れる */
    }
    
    /* スマホで表がはみ出すのを防ぐ。
       表そのものではなくラッパーをスクロールさせるのがポイント */
    .wp-content .table-wrap { overflow-x: auto; }
    なぜ prose(Tailwind Typography)を使わないのか

    Tailwindには @tailwindcss/typography という、まさにこの用途のプラグインがあります。導入すれば見出しや段落は一発で整います。今回それを使わなかったのは、この記事群が .note や .code-block といった独自のclassを使っており、そこは結局自分で書く必要があるためです。半分プラグイン・半分自前だと、どちらに何が書いてあるか分からなくなります。既製品を入れるかどうかは「どこまで賄えるか」で決めるのが良さそうです。

    公開後の確認項目

    公開したあと、次の項目を確認しました。「見た目が合っていそう」で終わらせないための一覧です。

    確認すること 方法 なぜ見るのか
    一覧ページに出ているか ブログ一覧のリンクにスラッグが含まれるか 個別ページが正しくても、一覧に出なければ誰にも見つからない
    個別ページが200を返すか fetch(url) のステータス 404や500でないこと。スラッグの設定ミスはここに出る
    本文が入っているか HTML内に wp-content の箱があるか 取得に失敗しても、ページ自体は200で返ることがある
    HTMLがエスケープされていないか &lt;h2&gt; のような文字列が本文に出ていないか どこかでエスケープが挟まると、記事全体がタグの羅列になる
    CSSが実際に効いているか getComputedStyle() で計算後の値を見る 目視だと「効いているように見える」で通してしまう
    CSSが本当に適用されているかを値で確かめる
    const box = document.querySelector('.wp-content');
    const pre = box.querySelector('pre');
    
    console.log(getComputedStyle(pre).backgroundColor);
    // "rgb(28, 33, 40)" ← 指定した --code-bg と一致
    
    console.log(getComputedStyle(box).lineHeight);
    // "34px" ← 17px × 2.0。行間の指定も効いている

    getComputedStyle() は、CSSの継承や優先順位をすべて解決したあとの最終的な値を返します。「スタイルシートには書いたが、より強い指定に負けていた」というよくある失敗が、これを見れば一発で分かります。目で見て確認すると、うっすら効いている状態を「効いている」と判断してしまいがちです。

    おまけ:rebaseが止まるとき

    CSSをコミットしようとしたとき、以前と同じところで詰まりました。

    よく出るエラー
    error: cannot pull with rebase: You have unstaged changes.

    rebaseは「自分のコミットを、取り込んだ最新の状態の上に載せ直す」操作です。作業中の未コミットの変更が残っていると、載せ直す途中でそれが失われる危険があるため、Gitは先に止まります。

    毎回 git stash と git stash pop を手で打っていたのですが、これを自動でやるオプションがあります。

    退避と復帰を自動でやる
    git pull --rebase --autostash
    
    # 内部でやっていること:
    #   1. 未コミットの変更を一時退避(stash)
    #   2. rebase を実行
    #   3. 退避したものを戻す(stash pop)

    今回のように「関係ない別ファイルの変更が手元に残っている」場面では、これで止まらずに済みます。

    今回の学び

    技術的な内容より先に、いちばん大きかったのは冒頭の話です。ファイルを作ったことと、それが読める場所にあることは別。今回は「記事を書く」という依頼に対して、HTMLファイルを渡した時点で完了だと考えてしまっていました。依頼した側から見れば、ブログに載っていなければ何も起きていないのと同じです。

    技術面では、次の3つが残りました。

    ひとつ目は、制約は迂回するのではなく、その意味を理解してから別の道を探すということ。CORSに阻まれたとき、抜け道を探すのではなく「なぜこの制限があるのか」を理解したことで、ファイル選択欄という筋の通った経路にたどり着けました。制限の理由が分かれば、その理由に反しない方法も見えてきます。

    ふたつ目は、エラーメッセージは症状であって原因ではないということ。「権限がありません」を額面どおり受け取っていたら、許可設定をいじる方向に進んでいました。別の経路で同じ操作を試し、結果が変わったことで、原因が別の場所にあると分かりました。

    みっつ目は、成功したという返事を信じないということ。201が返っても、中身が削られていないかは別問題です。保存されたものを読み戻して元と比較する。CSSは目で見ずに計算値で確認する。ひと手間ですが、この確認があるから「できました」と言い切れます。

  • 公開したあとの話 — 脆弱性検知と監視の仕組みを作る

    公開したあとの話 — 脆弱性検知と監視の仕組みを作る

    ポートフォリオサイトを本番公開したあと、運用のための仕組みを整えました。脆弱性の検知、稼働の監視、依存パッケージの更新です。

    公開までの記事は「動かすまで」の話でしたが、今回は「動き続けさせる」ための話になります。地味な作業ですが、初回の実行でいきなり実害のある問題が見つかったので、やっておいてよかったと思っています。

    公開したら終わり、ではなかった

    デプロイが終わった時点で、次のような状態でした。

    ・使っているnpmパッケージ 679個 の脆弱性 → 誰も見ていない
    ・WordPress本体・プラグインの更新       → 気付いたときに手で確認
    ・公開してはいけないファイルの置き忘れ   → 気付く手段がない
    ・サイトが止まったとき                  → 自分が見に行くまで分からない

    個人サイトなので、止まっても誰も困りません。それでも仕組みを入れたのは、実際に「置き忘れ」をやらかしていたからです。

    移行作業のときに、データベース全体が入った約62MBのバックアップファイルをサーバーに置きました。作業には必要でしたが、URLさえ分かれば誰でもダウンロードできる状態でした。中にはユーザー情報もパスワードのハッシュも入っています。

    これは移行の途中で自分で気付いて削除しましたが、気付かなければ置きっぱなしでした。同じことがまた起きないようにしたい、というのが動機です。

    やったこと1: 脆弱性の自動検知を入れる

    GitHubには Dependabot という機能が最初から用意されています。使っているパッケージの一覧を読み取り、既知の脆弱性データベースと突き合わせて教えてくれるものです。

    設定は3段階に分かれていて、順に有効化する必要があります。

    機能 役割
    Dependency graph 依存関係を解析する土台。これが無いと以下が動かない
    Dependabot alerts 脆弱性を検知して通知する
    Dependabot security updates 修正版へ上げるPRを自動で作る
    ポイント

    alerts だけだと「アラートは来るが誰も直さない」状態になりやすいです。security updates まで有効にして、修正案がPRの形で届くようにするのが実用的でした。人間がやるのは中身を見てマージするだけになります。

    あわせて .github/dependabot.yml という設定ファイルも置きました。これがあると、脆弱性の有無に関係なく定期的な更新PRも作られます。

    .github/dependabot.yml
    version: 2
    updates:
      - package-ecosystem: npm
        # Next.jsアプリはリポジトリ直下ではなく frontend/ にある
        directory: /frontend
        schedule:
          interval: weekly
        open-pull-requests-limit: 5
        groups:
          minor-and-patch:
            update-types: [minor, patch]
    つまずきやすい点

    directory: /frontend の指定が重要です。今回のリポジトリはルート直下にWordPressのファイルもあり、Next.jsは frontend/ の中にあります。ここを間違えると package.json が見つからず、Dependabotはエラーも出さずに何もしません。「設定したのに動かない」で悩む典型的なパターンです。

    やったこと2: 検知できない領域を自分で作る

    Dependabotは強力ですが、npmパッケージしか見ません。今回のサイトで実際に起きた問題を並べると、拾えるのは1つだけでした。

    実際に起きたこと Dependabotで拾えるか
    next に脆弱性 拾える
    バックアップファイルが誰でもダウンロード可能 拾えない
    SSL証明書がホスト名と不一致 拾えない
    CMSが落ちてサイトのデータが全部空 拾えない

    パッケージの脆弱性より、設定ミスや置き忘れのほうが実際には多かったわけです。そこで、残りをカバーする監視をGitHub Actionsで作りました。

    GitHub Actionsは、GitHubのサーバー上でコマンドを定期実行できる仕組みです。毎朝9時に動かし、問題があればSlackへ通知します。

    .github/workflows/security-audit.yml(抜粋)
    on:
      schedule:
        # 毎日 09:00 JST(cron は UTC なので 00:00 を指定)
        - cron: "0 0 * * *"
      workflow_dispatch:   # 手動実行もできるようにする

    チェックしているのは4領域です。

    1. npm依存パッケージ … npm audit の結果から high 以上を報告
    2. WordPress本体     … 導入バージョンと最新版を比較
    3. 公開ファイル       … 見えてはいけないURLを実際に叩いて確認
    4. 稼働とSSL証明書    … 表示・API応答・証明書の残り日数

    実際の失敗から作った監視項目

    この中に、教科書には出てこない項目をひとつ入れました。

    scripts/security-audit.mjs(抜粋)
    if (/まだ実績が登録されていません|まだ記事がありません/.test(html)) {
      report("high", "health", "サイトは表示されるがCMSのデータが取得できていない");
    }

    デプロイのときに、ビルドは成功しページも表示されるのに、データが1件も出ないという状態に数時間はまりました。HTTPステータスは200なので、普通の死活監視では検知できません。

    監視項目は、教科書からではなく自分が踏んだ失敗から作るほうが実用的になります。

    通知は異常時だけにする

    Slackへの通知は、問題があったときだけ送るようにしました。正常時は何も送りません。

    毎日「異常なし」が届くと、そのうち誰も読まなくなります。 通知は「届いたら必ず見る」状態を保つことが重要で、そのためには送る回数を絞る必要があります。

    初回実行でいきなり本物が出た

    作った監視を初めて動かしたところ、25件を検知しました。そして、その中に実害のある指摘が含まれていました。

    CRITICAL (exposure) /wp/readme.html が公開状態(200 / 7406バイト)

    readme.html はWordPressに最初から入っているファイルで、WordPressのバージョン番号が書かれています。

    これがなぜ問題かというと、攻撃する側にとって攻撃対象を絞り込むための情報だからです。「このサイトはWordPress 7.0.2だから、このバージョンに効く攻撃を試そう」と判断できてしまいます。

    削除すべきファイルなのですが、WordPressを更新するたびに復活します。だからこそ、一度消して終わりではなく定期チェックの対象にする価値があります。

    作った意味があった

    これはDependabotでは絶対に拾えません。パッケージの脆弱性ではなく、置き忘れだからです。移行のときの .wpress と同じ種類の問題が、まだ他にもあったということでした。

    誤報も出た(そして直した)

    同時に、誤検知も1件ありました。

    CRITICAL (exposure) /wp/wp-config.php が公開状態(200 / 0バイト)

    wp-config.php にはデータベースのパスワードが平文で書かれています。もし中身が読めるなら重大な問題です。

    ただ、よく見ると0バイトです。中身は漏れていません。PHPファイルはサーバー側で実行されてから結果が返るので、このファイルの場合は実行結果が空になります。アクセスできること自体は正常な挙動でした。

    私の判定ロジックが「ステータス200 = 公開されている」という単純な条件だったために起きた誤報です。本文まで読んでから判定するように直しました。

    scripts/security-audit.mjs(修正後)
    const res = await get(`${CMS}${path}`);
    if (res.status !== 200) continue;
    
    // 200が返っても中身が空なら「漏れていない」。
    // 例: wp-config.php はPHPとして実行され、出力は空になる。
    const body = await res.text();
    const bytes = new TextEncoder().encode(body).length;
    
    if (bytes === 0) {
      note(`${path} は200を返すが中身は空(問題なし)`);
      continue;
    }
    
    // ファイルの性質によって深刻度を変える
    const severity = /wp-config\.php|\.wpress$/.test(path) ? "critical" : "high";
    report(severity, "exposure", `${path} の中身が読める状態(${bytes}バイト)`);

    修正後、検知件数は 25件 → 23件 になりました。誤報1件と、削除した readme.html の1件が消えた形です。

    監視を作るときの原則

    監視は動かしてから調整するものです。最初から完璧を狙う必要はありません。ただし誤報を放置してはいけないのは重要で、誤報が続くと通知そのものが信用されなくなり、本物の警告まで無視されるようになります。

    更新PRの見分け方 ── セマンティックバージョニング

    Dependabotを有効にした直後、脆弱性51件を検知し、修正PRが8件作られました。

    これを全部そのままマージしてよいかというと、まったく違います。判断の基準になるのがセマンティックバージョニングという約束事です。

    16 . 3 . 0
    │    │   └── パッチ: バグ修正。動作は変わらない
    │    └────── マイナー: 機能追加。既存の動作は壊さない
    └─────────── メジャー: 破壊的変更あり。動かなくなる可能性がある

    この約束に従うと、左端の数字が変わらなければ、原則として壊れません。逆に左端が変わるものは、動作確認なしでマージしてはいけません。

    今回の8件を分類するとこうなりました。

    # 内容 種別 判断
    1 brace-expansion パッチ すぐマージ可
    2 js-yaml 4.3.0 → 4.3.1 パッチ すぐマージ可
    3 postcss 8.4.39 → 8.5.23 マイナー すぐマージ可
    4 minor-and-patch グループ マイナー すぐマージ可
    5 eslint-config-next 14 → 16 メジャー×2 #7とセット
    7 next 14.2.5 → 16.3.0 メジャー×2 #5とセット
    6 tailwindcss 3.4.4 → 4.3.3 メジャー 要注意
    8 graphql-request 6.1.0 → 7.4.0 メジャー 単独で検証

    セットで扱う必要があるもの

    eslint-config-next は Next.js とバージョンを揃える前提のパッケージです。片方だけ上げると噛み合いません。 #5 と #7 は必ず一緒にマージする必要があります。

    特に注意が要るもの

    Tailwind CSS の v3 → v4 は、設定の書き方が根本から変わります。これまで tailwind.config.ts に書いていた内容を、CSS側へ移す作業が必要です。

    怖いのは、ビルドが通っても見た目が崩れる可能性がある点です。「マージしたらエラーになる」なら気付けますが、「マージしたら静かにデザインが変わる」は気付きにくい。これは更新ではなく移行作業として、時間を取って扱うべきものです。

    なぜ一度に全部マージしないのか

    8件をまとめてマージすれば早く終わります。それをしなかった理由が2つあります。

    理由1: 問題が起きたとき原因が分からなくなる

    8件を同時に入れて表示が崩れたら、原因はどれでしょうか。切り分けるには結局1つずつ戻すことになり、余計に時間がかかります。

    変更は1つずつ、確認できる状態で入れる。 公開作業でSSL証明書の問題に数時間かけたのは、まさに「何が原因か切り分けられない状態」を作ってしまったからでした。

    理由2: マージするたびに他のPRが古くなる

    PRをマージすると package-lock.json というファイルが更新されます。すると残りのPRは古い状態のままになり、そのままではマージできません。

    Dependabotが自動で作り直してくれますが、少し時間がかかります。まとめられるものはまとめて処理するのが効率的です。

    今回の進め方

    安全な #1〜#4 を先に処理し、Dependabotが残りを作り直すのを待ってから、メジャーアップデートを1グループずつ扱う——という順序にしました。急がないものを急がないのも判断のうちです。

    確認項目チェックリスト

    今回の作業で使った確認項目をまとめておきます。次に同じことをするとき、これをなぞれば済むようにするためのものです。

    A. 依存パッケージを更新する前

    □ バージョン番号の左端が変わっていないか(メジャーか否か)
    □ セットで上げる必要のあるパッケージはないか
       (next と eslint-config-next のような組み合わせ)
    □ CI・プレビューのチェックが通っているか
    □ コンフリクトが出ていないか
    □ メジャーの場合、公式の移行ガイドを読んだか
    便利だった点

    VercelはPRごとにプレビュー環境を自動で作ります。本番と同じ環境でビルドが通るかを、マージ前に確認できました。「ビルドが通ることは確認済み」という状態でマージできるのは大きいです。

    B. マージした直後

    □ Vercelのデプロイが成功しているか
    □ トップページが表示されるか
    □ CMSのデータが実際に出ているか(空表示になっていないか)
    □ 主要ページが全部200を返すか
    □ 画像が表示されているか

    3つ目が特に重要です。ステータス200でも中身が空、という状態がありうることを、公開作業で学びました。ページが開けることと、中身が正しいことは別です。

    C. 定期的に確認すること(自動化済み)

    □ npm依存に high 以上の脆弱性がないか
    □ WordPress本体が最新か
    □ 公開してはいけないファイルが読めていないか
       ・移行用のバックアップ(.wpress)
       ・php.ini
       ・wp-config.php(中身が読める場合のみ)
       ・debug.log
       ・readme.html   ← WordPress更新のたび復活する
    □ ディレクトリ一覧が表示されていないか
    □ サイトが200を返すか
    □ ルートドメインが正しく転送されるか
    □ GraphQLがJSONを返すか
    □ SSL証明書の残り日数が14日を切っていないか

    証明書の閾値を14日にしたのには理由があります。Let’s Encryptは90日有効で、30日前から自動更新されます。つまり14日を切っている=自動更新が動いていないと判断できるわけです。

    D. 手作業でやること(自動化していない)

    □ WordPressプラグインの更新確認
    □ 管理者パスワードが十分に強いか
    □ 不要なプラグイン・テーマを削除したか
    □ ドメインの有効期限(切れるとサイト全体が止まる)
    □ 2要素認証の設定(GitHub / Vercel / サーバー)

    ドメインの有効期限は見落としやすい割に影響が大きい項目です。今回、作業中にたまたま管理画面で気付きました。

    今回の学び

    監視は既製品と自作を組み合わせる。 Dependabotだけでは今回の問題の4分の1しかカバーできませんでした。かといって全部自作するのも無駄です。既製品が得意な領域は任せ、残りを自分で埋めるのが現実的でした。

    監視項目は自分の失敗から作る。 「データが空なのに200を返す」という項目は、実際にはまったからこそ思いつきました。一般的な死活監視の教科書には出てきません。踏んだ失敗を監視項目に変換していくと、そのサイト固有の実用的な監視になっていきます。

    誤報は放置しない。 初回で誤報が1件出ました。誤報が続くと通知が信用されなくなり、本物の警告まで無視されるようになります。「オオカミ少年にしない」ことが、監視を維持する上でいちばん大事だと思っています。

    そして、急がないものを急がない。 8件のPRを前にすると全部片付けたくなりますが、メジャーアップデートは動作確認とセットです。急いで入れて壊すより、安全なものから順に入れて、残りは時間を取って扱う。作業を止める判断も作業のうちだと感じました。

    次回は、残っているメジャーアップデート(Next.js 16 と Tailwind CSS v4)に取り組む予定です。特にTailwindのv4は設定の書き方が変わるので、それなりの記事になりそうです。