非公開フラグでは記事を隠せていなかった:Nuxt Contentの公開SQLダンプに本文が残る
非公開フラグでは記事を隠せていなかった:Nuxt Contentの公開SQLダンプに本文が残る
毎朝の日記生成チェーンを回している途中で、保有銘柄のデイリーニュース記事を非公開に切り替えることにした。ポジションの中身は私的な情報なので、公開しておく理由がない。
frontmatter に unpublished: true を足すだけで済むはずだった。設定ファイルにも「新規記事は unpublished を使う」と書いてある。既存の非公開記事193本がその方式で運用されている。
ところが本番に置かれている配信物を直接取得してみると、非公開にしたはずの記事の本文がそのまま返ってきた。
結論
unpublished: trueは記事ページと sitemap、_rawからは記事を外すが、Nuxt Content が本番に置く公開SQLダンプ(/__nuxt_content/pages/sql_dump.txt、認証不要、12.5MB)には本文が残る- フラグが効くのは「出力を作らない」処理だけで、コレクションへの取り込み自体は止めない。取り込まれた時点でダンプに載る
- 個別の記事を確実に隠すには、本番除外の対象になっているローカル専用ツリー(
content/_local-unpublished/)へ物理的に移すしかなかった - 恒久対策として、本番ビルド時に非公開記事のパスを列挙し、コレクションの
source.excludeへ流し込む形にした
フラグ方式を選んだ理由
非公開にすると決めたとき、選択肢は2つあった。frontmatter にフラグを足すか、公開ビルドから外れるディレクトリへ移すか。
フラグを選んだ。設定ファイルに「非公開フラグ(新・opt-in)。新規記事は unpublished を使う」と明記してあり、既存193本もその方式で動いている。加えて、デイリーニュースを作るスキルは保存先を content/{YYYY-MM}/{実行日}/ と決めているので、ディレクトリを移すとその規約と衝突する。
前提が正しければ、判断としては筋が通っていた。
本文は読める状態のままだった
念のため、本番の /__nuxt_content/pages/sql_dump.txt を取得した。12.5MB のダンプの中に、非公開にした記事の本文がそのまま入っていた。認証はかかっていない。URL を知っていれば誰でも取れる。
理由は設定ファイルを読めばすぐわかった。本番の除外設定は _local-unpublished/** というディレクトリだけを対象にしていて、フラグは対象に入っていない。フラグが効くのはプリレンダーと sitemap、_raw の生成、つまり「出力を作らない」側の処理だけである。コレクションへの取り込みは止まらないので、取り込まれた記事はすべてダンプに載る。
記事一覧から消えたのを見て隠せたと判断していた。消えていたのは導線だけだった。
ローカル専用ツリーへ隔離する
方式を切り替えて、デイリーニュース記事4本(2026-08-05 / 07 / 10 / 11)を content/_local-unpublished/ へ移させた。このディレクトリは gitignore の対象なので、移した先はリポジトリにも載らない。既存の24本も同じ扱いで、すべて未追跡だった。
移動だけでは終わらなかった。
- 公開している日記から非公開記事へのリンクが2箇所残っていた。放置するとリンク切れになるうえ、ビルド後の検証も落ちる。リンクを外し、記録は本文に残した
- デイリーニュースを作るスキルと、日記生成コマンドの保存先を新方式へ書き換えてもらい、フラグ単独では隠れない根拠も併記させた
- 残った穴(フラグ運用の193本と、検証スクリプトがダンプを見ていないこと)を issue に記録した
コミットは学習ゲートで止まった。クイズを出させたものの、その場では答えずスキップして通した。
フラグだけの記事をどうするか
移動で片付いたのは4本だけで、フラグだけを頼りにしている記事はまだ読める。ここで作業を切り、恒久対策は個別タスクの計画書に回した。
まずダンプの構造を調べさせた。gzip と base64 でエンコードされた SQL 文の JSON 配列で、INSERT が1,959件。配信側の JS が実際にこのファイルを取得して、クライアント側のクエリに使っている。壊すと表示に響く場所なので、雑に削るわけにはいかない。
計画書を書いたところで Codex にレビューさせたら、重大な指摘が8件返ってきた。中でも効いたのが、「1レコード=1要素」という前提の誤りを突いた指摘である。実測し直すと、長文の記事は INSERT のあとに UPDATE ... CONCAT(body, ...) を重ねる形で分割されていた。この UPDATE が98件ある。要素単位で除去する設計では、本文の後半が残る。
自分の実測の読み違いだったので、計画書を書き直させた。
判断が要る3点は決裁フォームに出し、次のように決めた。
- 出力側で消すのではなく、取り込む前に除外する
- frontmatter のフラグ運用はそのまま残す(193本を全部移動はしない)
- 本番ビルドだけ先に直す(dev では従来どおり読める)
取り込み前に除外する
本番ビルドのときだけ content/ の frontmatter を先読みして、非公開記事のパスを列挙し、コレクションの source.exclude へ流し込む形にした。
// content.config.ts(要点)
const productionExclude = () => {
const { paths, skipped } = collectUnpublishedPaths(contentRoot);
// グロブ特殊文字を含むファイル名は exclude で確実に落とせない。黙って通すと漏れるので知らせる
if (skipped.length > 0) console.warn(`除外できません: ${skipped.join(", ")}`);
return ["_local-unpublished/**", ...paths];
};
列挙されたのは169件。フラグ運用の193本から、ローカル専用ツリーへ移した24本を引いた数と一致する。取り込む前に落とすとダンプのチェックサムも変わるので、旧いダンプを持っているブラウザがキャッシュを使い回すこともない。
検証スクリプトには、dist のダンプを復号して、非公開記事のパスとタイトル、本文の断片が残っていないかを検査する処理を足させた。Codex の指摘を踏まえ、照合はレコード単位ではなく復号後の全文で行う。
対策前の dist に対してこの検査を回すと、197件の漏えいを検出して落ちた。ゲートとして働くことは確認できた。除外設定そのものが効くことも実測した。ローカル専用ツリーへ移した記事はダンプになく、フラグだけの記事はある。同じ exclude に169件を流すので、同じように落ちる。
パス列挙の境界はテスト8件で固定した。対象拡張子は .md / .mdx / .mdc の3つである。
学んだこと
- 「隠す」機能には、導線を消すものと、出力そのものを作らないものがある。設定ファイルに「非公開フラグ」と書いてあっても、どちらなのかは実装を読むまで決まらない
- 隠したつもりの記事は、一覧から消えただけでは確かめたことにならない。本番に置かれている配信物を直接叩き、本文が出てこないところまで見る
- SSG では「ページを作らない」だけで足りないことがある。クライアント側クエリ用のデータが別経路でまとめて配信されている構成では、そちらが本体になる
- 実測結果は、レビューに出して読み違いを潰してから設計に使う。要素単位で消す前提のまま実装していたら、本文の後半が残ったまま「対策済み」と判断していた
残していること
- 本番の1本で先に確かめる試験はまだ回していない
- 本番への反映は次のデプロイで行う
- master には並行して動いていた別セッションのコミットも混ざっていて、未 push が13本ある。push するとまとめて上がるので、そこは分けて判断する
一区切りついたので、残りは引き継ぎプロンプトにまとめて別セッションへ渡した。