proxy.ts + cookie + IP制限)と相関IDの同居| 項目 | 内容 |
|---|---|
| 実行環境 | ★Cursor 内の Claude が実装します★(.claude/commands/d-watch.md の役割分担表を参照) |
| 実行状態 | 未実行 |
| 取得する決定番号 | ★D-080★(★D-079 まで発番済み★。2026-09-08 時点。着手直前に grep -n "^### D-0" docs/decisions.md で最終番号を再確認すること — 指示書H は D-073 で完了済み。他の指示書と同時に走らせないこと) |
| 前提 | docs/decisions.md の D-072 の項6(方式と地雷の一覧)、★D-077 の項5(X-Forwarded-For は nginx が追記)★、★D-079 の項1(LB と nginx は同一=プロキシは1段)★ を先に全部読むこと |
| ★着手前確認★ | ★なし(全件解決済み)★。★この指示書は着手前確認なしで、段階0 から段階6 まで通して実行できます★(2026-09-08 / 根拠 ★D-079 項1★) |
| 着手前の基準 | npx tsc --noEmit エラー0 / npm test 全パス / npm run lint 0 |
| 段階数 | ★8段階★ = 段階0〜6 の7段階 + ★段階5-B★(2026-09-10 のユーザー判断を受けて後から挿入。★既存の段階番号は繰り下げていない★ — 他文書からの参照がズレるため。★指示書K の段階7-B と同じやり方★)。実行順は 段階5 → 段階5-B → 段階6 です |
| 改訂 | ★2026-09-10★ — ★ALLOW_DIRECT_ACCESS を TypeScript 定数から環境変数に移した★(ユーザー判断)。あわせて ★段階5-B(環境変数のセットアップ手順と検証スクリプト)を追加★。★段階0〜6 の番号・実装内容そのものは変更していない★ |
middleware.ts ではありません★この next は 16.3.3 で、middleware.ts は非推奨です。 正解は ★proxy.ts★ です。
node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/middleware.md の原文:
The
middleware.jsfile convention has been deprecated in Next.js 16 and renamed toproxy.js. All functionality remains the same — only the file and export names have changed.
middleware.ts を置くと dev サーバが警告を出します。 訓練データの記憶で middleware.ts を 作らないでください。★着手前に node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md を実際に開いて読むこと★(CLAUDE.md 末尾の「This is NOT the Next.js you know」)。
| 語 | 意味 | この指示書での扱い |
|---|---|---|
| TLS を終端するリバースプロキシ | nginx / ロードバランサ | ★LB が担当。アプリでは作りません★ |
★Next.js の proxy.ts★ |
フレームワークのファイル規約 | ★これを作ります★ |
★現状、URL を知っていれば全画面が誰でも開きます。★ 認証は完全に未実装です (app/login/ も proxy.ts も存在せず、13本のAPIルートに認証の分岐が1つもありません)。
方式は D-072 の項6 で決まっています。
| 項目 | 決めたこと |
|---|---|
| 保護 | proxy.ts で全ルート |
| 認証 | cookie 方式。共有トークンを app/login/ で入力 → 検証して cookie を発行 |
| IP制限 | X-Forwarded-For の ★末尾から TRUSTED_PROXY_HOPS 番目だけ★ を信じる(★TRUSTED_PROXY_HOPS = 1 = 最後の1ホップで確定★。D-079 項1 でプロキシは1段と判明) |
| トークン | .env.local の ★AUTH_TOKEN(既に存在します)★ |
| トークンの期限 | ★実装しない★(CLAUDE.md §7 で禁止) |
★proxy.ts には相関IDの発行も同居させます。★ docs/logging-design.md:408 の申し送りどおり ★相関IDの発行を先、認証を後★ に置きます (認証失敗もログで追跡できるようにするため)。これで logging-design.md のフェーズ2 が解除されます。
1ファイル制約の解き方はガイドが示しています — 「機能ごとに別モジュールへ分け、 proxy.ts から import する」のが公式推奨です。proxy.ts 本体は薄い順序制御だけにしてください。
★この節に残っていた未確認事項は 2026-09-08 にすべて解決しました。着手をブロックする確認はもうありません。★ 以下は「なぜこの実装にするのか」の根拠として残してあります。
X-Forwarded-For を「追記」する(2026-09-08 / 根拠 ★D-077 項5★)ユーザー回答の原文:
★ロードバランサが X-Forwarded-For を追記するか上書きするか★はnginxで追記して各サービスに割り振ります
X-Forwarded-For: 1.2.3.4 を送ってきた場合、 nginx は自分が見た接続元IPを末尾に足して 1.2.3.4, <実際の接続元> にしますX-Forwarded-For は ★<自称>, <実際の接続元> のように複数要素になるのが正常系★です。 「上書きされるので要素は1つだけ」という想定は ★捨ててください★ (★ただし「要素が1つ=直接アクセス」とも断定できません。★ クライアントが XFF を送らなければ nginx が1個足しただけの「要素1個」になります。段階1 の期待値表の直後の注を参照)★先頭を取る実装にすると、クライアントが X-Forwarded-For: <許可IP> を自分で付けるだけで IP制限を素通りできます。★ 絶対に先頭を使わないこと。
前版ではここが未確認で、★「この確認が取れるまで段階1・段階2 に着手しないこと」★ と書いていました。 ★その制約は解除されました。★ ユーザー回答の原文:
★1★は同一です
(★1★ = 前版が挙げた確認事項「LB と nginx は同一か、LB → nginx の2段か」を指します)
TRUSTED_PROXY_HOPS = 1 で確定★(X-Forwarded-For の ★末尾★ を取る)★現時点では1段で確定しています。以下は TRUSTED_PROXY_HOPS という設定値を持たせる理由の説明であり、 将来 LB 構成が変わったときに読み返すためのものです。★(削除しないこと)
「末尾を取る」は ★追記するプロキシがちょうど1段であること★ を前提にした話です。
| 構成 | 届く X-Forwarded-For |
末尾を取ると |
|---|---|---|
| ★1段(nginx = LB)★ ← ★今回はこれ(D-079 項1)★ | <自称>, <nginx が見た接続元> |
★正しい★(本当のクライアントIP) |
| ★2段(LB → nginx が両方追記)★ | <自称>, <本当のクライアント>, <LB の内部IP> |
★LB の内部IPになる★ |
2段だった場合、全リクエストが同一IP(LB の内部IP)に見えます。 その結果 allowlist は「(LB の内部IPを許可して)全部通す」か「(許可しないので)全部弾く」の どちらかにしかならず、★IP制限が完全に無意味化します。★
正しい一般則は ★「末尾から N 番目を取る」★ です (N = 自分の前段にある信頼済みプロキシの段数。1段なら N=1 で従来どおり末尾)。 だから段数を ★設定値 TRUSTED_PROXY_HOPS★ にします。
ユーザーからの質問「もし実装後に同一でないと発覚した場合は問題ありますか?」への回答です。
★結論: fail-closed(安全側)に倒れます。危険な方向へは倒れません。★
| 観点 | 実際に起きること |
|---|---|
| 何が起きるか | 2段なら X-Forwarded-For の末尾は ★LB の内部IP★ になり、全リクエストが同一IPに見える |
| allowlist との関係 | ALLOWED_IPS は ★社内IPの列挙★ なので、LB の内部IPは入っていない |
| 結果 | ★全員が 403 になり、即座に気づきます★(誰もログインできないので運用開始と同時に発覚する) |
| ★逆パターン(気づかず素通り)★ | ★起きません★。「弾かれる」方向にしか倒れないため、静かに穴が空くことはない |
| ★唯一の例外★ | ALLOWED_IPS に ★LB の内部IPが偶然入っていた★ 場合のみ、逆に全員素通りになります。社内IPの列挙では通常起きません(10.x / 172.16-31.x / 192.168.x のようなプライベートIPを allowlist に書かないこと) |
| ★修正コスト★ | ★ほぼゼロ。lib/config/allowed-ips.ts の TRUSTED_PROXY_HOPS を 1 → 2 に変えるだけ★。★コード変更は不要★(clientIp() は最初から「末尾から N 番目」で一般化してあるため) |
★これが TRUSTED_PROXY_HOPS をハードコードせず設定値にした理由です。★ 1段で確定していても設定値のまま残してください(定数に畳み込まないこと)。
clientIp() は「末尾から N 番目」で一般化する★段数は1段で確定(D-079 項1)ですが、clientIp() は「末尾から N 番目」で一般化してください。★ (1段/2段のどちらでも設定値1つで直せる形にしておくのが目的。上のリスク評価を参照)
lib/config/allowed-ips.ts に ★TRUSTED_PROXY_HOPS(1 で確定。D-079 項1)★ を置くclientIp(headerValue, hops) は ★末尾から hops 番目の要素★ を返す (hops = 1 なら末尾=従来どおりの挙動。hops = 2 なら末尾から2番目)hops に満たない場合は ★null(取れない扱い)★ にする。 足りないぶんを先頭で埋めないこと(自称IPを掴んでしまいます)TRUSTED_PROXY_HOPS を ★ハードコードしないこと★(CLAUDE.md §7)ヘッダが1つも無い場合(nginx を通っていない = 直接アクセス)は ★拒否★ が既定です。 ローカル開発で困るので、開発時だけ通す条件は NODE_ENV ではなく設定値で明示的に切ること (★ALLOW_DIRECT_ACCESS。★環境変数★です。段階1-1 で定義します★)。
| 段階 | 内容 | コミット |
|---|---|---|
| 段階0 | d-watch-api.yaml の securitySchemes を cookie 方式に直す |
段階0で1コミット |
| 段階1 | lib/config/allowed-ips.ts と lib/auth/(純粋関数)+ テスト |
段階1で1コミット |
| 段階2 | proxy.ts と分割モジュール(相関ID → 認証) |
段階2で1コミット |
| 段階3 | app/login/ と app/api/auth/login logout |
段階3で1コミット |
| 段階4 | lib/logger/redact.ts に cookie 名を追加(★既存テスト2件が落ちるので同時に直す★) |
段階4で1コミット |
| 段階5 | 認証イベントのログ追加 + ★X-Forwarded-For の段数を実測して確定★ |
段階5で1コミット |
| ★段階5-B★ | ★環境変数のセットアップ手順と検証スクリプト★(.env.production.example / npm run check:env / docs/deploy.md) |
段階5-Bで1コミット |
| 段階6 | 文書の更新と記録 | 段階6で1コミット |
★段階5-B の番号について★: この指示書は段階0〜6 で番号が確定しており、 ★繰り下げると他文書からの参照がズレる★ので、★段階5 と段階6 の間に「段階5-B」として挿入しました★ (★指示書K が段階7-B を挿入したのと同じやり方★)。実行順は 段階5 → 段階5-B → 段階6 です。
★段階ごとに npx tsc --noEmit と npm test を実行し、通ってからコミットすること★(D-050)。
d-watch-api.yaml の securitySchemes を直す現在の securitySchemes は bearerAuth / Laravel Sanctum の残骸です(docs/architecture.md:49)。 architecture.md:50-52 が「認証方式は cookie 方式と決まっているが、HTTPS の終端場所が回答待ちで proxy.ts も未着手のため、ここを今確定させると決まっていないことを決めたことになる」として 保留していた箇所です。★D-072(HTTPS の終端は LB)/ D-077 項5(nginx が X-Forwarded-For を追記) / D-079 項1(LB と nginx は同一=1段)ですべて解決したので、ここを直します。★
CLAUDE.md §6-1「API契約を変えたくなったら、先に d-watch-api.yaml を直す」に従い最初に行います。
securitySchemes を ★cookie 方式(type: apiKey / in: cookie / name: dwatch_session)★ に書き換えるbearerAuth と Sanctum への言及を残さないこと★security を付ける。/api/auth/login は認証不要なので付けないCLAUDE.md §7 で禁止){ error: { code, message } }(CLAUDE.md §9.3)で定義するlib/config/allowed-ips.ts(★新規。index.ts に足さないこと★)★lib/config/index.ts に追加してはいけません。★ index.ts は lib/judge/ から広く import されており、proxy のバンドルに judge 系を 引き込む恐れがあります。ガイドも「proxy は shared modules に依存すべきでない」と警告しています。 lib/config/advice-rules.ts が独立ファイルになっている前例に倣ってください。
/** * IP制限の許可リスト(D-072 / D-077 項5 / D-079 項1)。 * ★ハードコード禁止(CLAUDE.md §7)のため、判定側から直接IPを書かずここを参照する★ * 値は運用で変わるので、変えるときはこのファイルだけを直す。 * * ★書き方の注意★ * - 判定は「文字列の完全一致」だけ。CIDR / レンジ / ワイルドカードには対応していない(P1 の割り切り) * - ★nginx が実際に付ける表記をそのまま書くこと。ログで実際の値を確認してから登録する★ * (`::1` / `::ffff:203.0.113.10` / `[2001:db8::1]:54321` のような表記ゆれは * 正規化しないので、書き方が違うだけで拒否される) */ export const ALLOWED_IPS = [ // 例: "203.0.113.10", ] as const; /** 許可リストが空のとき、IP制限を「かけない」か「全拒否」か(★誤って全公開しないため明示する★) */ export const ALLOW_ALL_WHEN_EMPTY = true; /** * ★X-Forwarded-For が無い/末尾から N 番目が取れない = nginx を経由していない直接アクセス★ * を通すかどうか。★環境変数から読む(2026-09-10 のユーザー判断)★。 * ローカル開発でだけ `.env.local` に `ALLOW_DIRECT_ACCESS=true` を書く。 * ★本番では設定しない(未設定 = false = 拒否)★ * ★ALLOW_ALL_WHEN_EMPTY で代用しないこと(後述)★ * * ★`=== "true"` であること。`!== "false"` にしないこと(理由は指示書I の 1-1-c)★ */ export const ALLOW_DIRECT_ACCESS = process.env.ALLOW_DIRECT_ACCESS === "true"; /** * 自分の前段にある「信頼済みプロキシ」の段数(D-077 項5 / ★D-079 項1★)。 * clientIp() は X-Forwarded-For の ★末尾から この数だけ遡った要素★ を採用する。 * * ★1 で確定★ — LB と nginx は同一(プロキシは1段)。2026-09-08 のユーザー回答(D-079 項1)。 * ★定数に畳み込まず設定値のまま残すこと★: 将来 LB → nginx の2段構成になった場合、 * ここを 2 にするだけで直る(コード変更不要)。指示書I の「リスク評価」節を参照。 */ export const TRUSTED_PROXY_HOPS = 1;
★ALLOW_ALL_WHEN_EMPTY を必ず持たせること。★ 空配列を「制限なし」と暗黙に扱うと、 設定ミスで全公開になったことに気づけません。 既定は true(開発を止めないため)にし、 本番では false にすることを段階6の記録に書いてください。
★この2つを1つの設定値でまとめて扱うと、初期状態のまま本番に穴が残ります。★
ALLOW_DIRECT_ACCESS(★環境変数★。未設定 = false。1-1-c)★ALLOW_ALL_WHEN_EMPTY(★TypeScript 定数★。既定 true)なぜ分けるのか(★この指示書に元々あった矛盾★): ALLOW_ALL_WHEN_EMPTY の既定が true で、かつ ip === null を「allowlist が空なら通す」と 扱ってしまうと、★初期状態(allowlist 空)では nginx を経由しない直接アクセスがそのまま通ります。★ これは「ヘッダが1つも無い場合は拒否」という上の指示と真っ向から矛盾します。 ALLOW_DIRECT_ACCESS を独立させ、★ip === null は ALLOW_DIRECT_ACCESS が true のときだけ通す★ と定義してください(ALLOW_ALL_WHEN_EMPTY は ip が取れているときの話に限定する)。
ALLOWED_IPS の判定は ★文字列の完全一致のみ★ です。192.0.2.0/24 のような CIDR / レンジ / ワイルドカードは解釈されません(そう書いても、その文字列と完全一致する IPは存在しないので常に拒否になります)。
lib/config/allowed-ips.ts と isAllowedIp() を作り直すことになりますALLOW_DIRECT_ACCESS だけを環境変数にする理由★(2026-09-10 のユーザー判断)ユーザー判断の原文:
環境変数でお願いします、その代わりサーバーに載せ替えた際にすぐに環境変数を 設定できるようにバッチか手順など作成してください
(後半の「バッチか手順など」への対応が ★段階5-B★ です)
経緯 — なぜ TypeScript 定数のままではまずいのか:
lib/config/allowed-ips.ts に ★TypeScript 定数として ALLOW_DIRECT_ACCESS = false★ を置く設計でしたtrue にしないとローカル(localhost:3000 への直アクセス)が全部 403 になります★。 つまり 開発者は必ず一度この行を true に書き換えることになります。 ★TypeScript ファイルなので、true のままコミットする事故が起こりえます★CLAUDE.md §4.6 の contract と同じ「静かに壊れる」型の事故です).env* は .gitignore 対象(.gitignore:34。:35 の !.env.example だけが例外) なので、.env.local に書いた true は構造的にコミットできません★=== "true" にすること。!== "false" にしないこと★★// ✅ 正しい export const ALLOW_DIRECT_ACCESS = process.env.ALLOW_DIRECT_ACCESS === "true"; // ❌ 間違い(未設定が true になる = 本番で穴が空く) export const ALLOW_DIRECT_ACCESS = process.env.ALLOW_DIRECT_ACCESS !== "false";
=== "true" なら、未設定・空文字・タイポ・1・TRUE — すべて false(拒否)になります★★既存の USE_FIXTURES は逆向きなので、真似しないこと★:
| 設定値 | 実装 | 未設定のとき | 設計意図 |
|---|---|---|---|
USE_FIXTURES(lib/db/use-fixtures.ts:17) |
process.env.USE_FIXTURES !== "false" |
★true★(fixtures で動く) |
★開発しやすさ優先★。clone しただけで動くことが目的 |
★ALLOW_DIRECT_ACCESS(本指示書)★ |
★process.env.ALLOW_DIRECT_ACCESS === "true"★ |
★false★(拒否) |
★セキュリティ優先★。設定漏れが穴にならないことが目的 |
lib/db/use-fixtures.ts:17 の実物:
export const USE_FIXTURES = process.env.USE_FIXTURES !== "false";
★「既存に合わせて !== "false" にする」は誤りです。★ 向きが逆なのは意図的で、 ★セキュリティに関わる設定は未設定を「閉じる」側に倒す★というのがここでの規則です。 ★この対比を段階6 の記録に残してください★(次に誰かが「統一しよう」として戻すのを防ぐため)。
★ALLOWED_IPS / TRUSTED_PROXY_HOPS / ALLOW_ALL_WHEN_EMPTY は環境変数に移しません。★
| 設定値 | 置き場所 | 理由 |
|---|---|---|
ALLOWED_IPS |
★TypeScript のまま★ | ★本番で意味のある値★。社内IPの列挙はレビュー対象にすべき情報なので、コードで管理し履歴を残す |
TRUSTED_PROXY_HOPS |
★TypeScript のまま★ | ★本番で意味のある値★(1 で確定。D-079 項1)。構成が変わったときにコードレビューを通したい |
ALLOW_ALL_WHEN_EMPTY |
★TypeScript のまま★ | ★本番で意味のある値★(本番では false にする。段階6 の記録参照) |
★ALLOW_DIRECT_ACCESS★ |
★環境変数★ | ★本番では常に false。true にするのは開発中だけ★ という、他の3つと性質が違う唯一の設定値だから |
★判定基準はこれです: 「本番で値を入れるもの」=コード。「開発中だけ立てるもの」=環境変数。★ ALLOW_DIRECT_ACCESS だけが後者に当たります。
lib/auth/(★純粋関数。DBもファイルも触らない★)docs/architecture.md:127 が lib/auth/ を置き場所として予告しています(「予定(認証方式の決定待ち)」)。
| ファイル | 中身 |
|---|---|
lib/auth/token.ts |
トークンの検証。AUTH_TOKEN の読み取りと ★定数時間比較★ |
lib/auth/client-ip.ts |
X-Forwarded-For の ★末尾から TRUSTED_PROXY_HOPS 番目★ を取る + 許可判定(★段数・直接アクセス可否は lib/config/allowed-ips.ts から受け取る。ハードコード禁止★) |
lib/auth/__tests__/ |
上2つのテスト |
★トークン比較は必ず定数時間で行うこと。★ a === b は先頭から比較して違えば即 false を返すため、 応答時間の差から1文字ずつ推測される(タイミング攻撃)。node:crypto の timingSafeEqual を使い、 長さが違う場合も同じ時間で false を返すようにしてください。
lib/logger/index.ts:22 の resolveLevel() が process.env を読む薄い関数の前例です。同じ形にしてください。
clientIp(headerValue, hops) — X-Forwarded-For から接続元を取る★hops は TRUSTED_PROXY_HOPS(★1 で確定★ = 末尾。D-079 項1)。以下は特記の無い限り hops = 1。★
入力(x-forwarded-for の値) |
hops | 期待 | 何を固定するか |
|---|---|---|---|
"1.2.3.4, 203.0.113.10" |
1 | ★"203.0.113.10"★ |
★これが正常系のメインケース★(nginx が追記 = D-077 項5。末尾を取る。先頭の 1.2.3.4 はクライアントの自称なので信じない) |
"203.0.113.10" |
1 | "203.0.113.10" |
★追記が確定したので「LB が上書きした場合」という説明は破棄★。単一要素は ★「クライアントが XFF を送らず nginx が1個足した正常系」でもあり得ます★(★直下の注のとおり、要素数だけでは直接アクセスと断定できません★)。ここでは値の取得だけを固定する |
"1.2.3.4, 5.6.7.8, 203.0.113.10" |
1 | "203.0.113.10" |
多段でも末尾 |
★"1.2.3.4, 203.0.113.10, 10.0.0.5"★ |
★2★ | ★"203.0.113.10"★ |
★将来 LB → nginx の2段構成になった場合★(10.0.0.5 は LB の内部IP。末尾から2番目が本当のクライアント)。★現構成は1段(D-079 項1)だが、このテストは残すこと★ — hops の一般化が壊れていないことを固定する |
"1.2.3.4, 203.0.113.10" |
★3★ | ★null★ |
★要素数が hops に満たない → 取れない扱い。先頭で埋めないこと★ |
" 203.0.113.10 " |
1 | "203.0.113.10" |
前後の空白を落とす |
"1.2.3.4," |
1 | ★null★ |
末尾が空 → 取れない扱い |
"" |
1 | null |
空文字 |
null(ヘッダ無し) |
1 | null |
★nginx を通っていない(直接アクセス)★ |
★"1.2.3.4, 5.6.7.8, 203.0.113.10"★(★ X-Forwarded-For ヘッダ行が複数来た場合★) |
1 | "203.0.113.10" |
★Headers.get() は同名ヘッダを ", " で連結して1つの文字列で返す★。したがって分割して末尾を取れば正しく動く。getSetCookie のような個別取得は ★不要★ |
"1.2.3.4, ::1" |
1 | "::1" |
★IPv6。正規化しない(後述)★ |
"1.2.3.4, ::ffff:203.0.113.10" |
1 | "::ffff:203.0.113.10" |
★IPv4射影アドレス。203.0.113.10 に展開しない★ |
"1.2.3.4, [2001:db8::1]:54321" |
1 | "[2001:db8::1]:54321" |
★ポート付き。ポートを剥がさない★ |
★注 — 「要素が1個=直接アクセス」と断定はできません。★ nginx の追記が
$proxy_add_x_forwarded_for相当なら、★クライアントがX-Forwarded-Forを送ってこない 普通のリクエストは、nginx が1個足した結果「要素1個」になります★。つまり要素数だけでは 「nginx 経由の正常系」と「nginx を経由しない直接アクセス」を区別できません。
clientIp()は ★要素数で分岐せず、末尾から N 番目を取るだけ★ にしてください(上表のとおり)- 直接アクセスを本当に塞ぐのは ★ネットワーク側(アプリのポートに nginx 以外が到達できないこと)★ です。
ALLOW_DIRECT_ACCESSはあくまで「XFF が取れないとき」の保険であって、偽装 XFF は防げません- ★「何段か」は D-079 項1 で解決済み(1段)です。★ 残っているのは ★アプリのポートが nginx からのみ到達可能かどうか★ の1点で、これは着手をブロックしません (ネットワーク側の設定であり、アプリの実装は変わらない)。★段階6 の記録に「未確認のまま」と 明記して申し送ってください★
★表記の正規化(IPv6の短縮形の展開・IPv4射影の展開・ポート除去)は P1 では行いません。★ isAllowedIp() は ★完全一致の文字列比較★(次表)なので、★表記ゆれはそのまま「拒否」になります。★
ALLOWED_IPS には nginx が実際に付ける表記をそのまま書く★lib/config/allowed-ips.ts のコメントに ★「nginx が実際に付ける表記をそのまま書くこと。ログで確認してから登録する」★ と必ず書くこと(段階1-1 のコード例参照)clientIp() がやってよいのは ★, での分割と前後の空白除去まで★isAllowedIp(ip, allowedIps, allowAllWhenEmpty, allowDirectAccess)★3軸(ip が null か / allowlist が空か / 直接アクセスを許すか)で網羅します。★ 判定の順序は ★「ip が null かどうか」が先、「allowlist が空かどうか」は後★ です (ip === null は allowAllWhenEmpty の影響を一切受けません)。
★注 —
allowDirectAccessは環境変数由来です(1-1-c)。★ 呼び出し側がlib/config/allowed-ips.tsのALLOW_DIRECT_ACCESS(=process.env.ALLOW_DIRECT_ACCESS === "true")を渡します。 ★isAllowedIp()自身はprocess.envを読まないこと★ — 引数で受け取る純粋関数のままにしてください (そうしないと下の表の12ケースをテストできません)。 ★したがって下表のallowDirectAccess = trueの行(11・12)は、本番では発生しない組み合わせです。★ 環境変数が未設定・空文字・1・TRUEのいずれでもfalseになるため、 ★本番で通るのは行1〜10 の経路だけ★ になります。
| # | ip | allowedIps | allowAllWhenEmpty | allowDirectAccess | 期待 | 何を固定するか |
|---|---|---|---|---|---|---|
| 1 | "203.0.113.10" |
["203.0.113.10"] |
false |
false |
true | 一致 |
| 2 | "203.0.113.11" |
["203.0.113.10"] |
false |
false |
false | 不一致 |
| 3 | "203.0.113.10" |
["203.0.113.10"] |
true |
false |
true | 一致(allowAllWhenEmpty は空でないので無関係) |
| 4 | "203.0.113.11" |
["203.0.113.10"] |
true |
false |
★false★ | ★allowlist が空でなければ allowAllWhenEmpty は効かない★ |
| 5 | "203.0.113.10" |
[] |
true |
false |
true(制限なし) | 開発時の既定 |
| 6 | "203.0.113.10" |
[] |
false |
false |
★false(全拒否)★ | 本番想定の設定ミス検知 |
| 7 | ★null★ |
["203.0.113.10"] |
false |
false |
★false★ | 直接アクセスは拒否 |
| 8 | ★null★ |
["203.0.113.10"] |
true |
false |
★false★ | 同上 |
| 9 | ★null★ |
[] |
★true★ |
★false★ |
★false★ | ★元の指示書が true としていた矛盾行。既定状態(allowlist 空)でも直接アクセスは通さない★ |
| 10 | ★null★ |
[] |
false |
false |
★false★ | 同上 |
| 11 | ★null★ |
[] |
true |
★true★ |
★true★ | ★ローカル開発。.env.local に ALLOW_DIRECT_ACCESS=true を明示的に書いたときだけ通る★ |
| 12 | ★null★ |
["203.0.113.10"] |
false |
★true★ |
★true★ | ★直接アクセスを許した以上、照合すべきIPが無いので通す★(★この環境変数を本番で設定しないこと。段階5-B の検証スクリプトが機械的に検出します★) |
★行9 が今回の修正点です。★ 旧版は isAllowedIp(null, [], true) → true(制限なしなので通す)と していましたが、それだと ★初期状態のまま本番へ出た瞬間、nginx を経由しない直接アクセスが素通り★ し、 「ヘッダが1つも無い場合は拒否」という指示と矛盾します。★null の可否は ALLOW_DIRECT_ACCESS だけが決める★ と覚えてください。
verifyToken(input, expected)| input | expected | 期待 | 何を固定するか |
|---|---|---|---|
"abc" |
"abc" |
true | 一致 |
"abc" |
"abd" |
false | 不一致 |
"abc" |
"abcd" |
★false★ | ★長さ違いで例外を投げないこと★(timingSafeEqual は長さが違うと throw する) |
"" |
"abc" |
false | 空入力 |
"abc" |
"" |
★false★ | ★AUTH_TOKEN が未設定なら誰も通さない★(空を「何でも通す」にしないこと) |
"abc" |
undefined |
★false★ | 同上 |
★AUTH_TOKEN が未設定のときに「認証を無効化して全部通す」実装をしないこと。★ 設定漏れがそのまま全公開になります。未設定なら誰も入れないが正しい挙動です。
proxy.ts と分割モジュールproxy.ts ← 薄い順序制御だけ。ロジックを書かない lib/proxy/request-id.ts ← 相関IDの発行(★先に走る★) lib/proxy/authenticate.ts ← 認証とIP制限(★後に走る★)
★proxy.ts 本体にロジックを書かないこと。★ 1ファイルしか置けないので、 中身を膨らませると次に横断処理を足すときに壊します。 ガイドの推奨どおり分割してください。
x-request-id を生成する。 ★クライアントが送ってきた x-request-id は信頼せず、常に上書き生成する★ (docs/logging-design.md:372-373)。ヘッダ名は x-request-id で確定(logging-design.md:448)/login へリダイレクト、APIは ★401★この順序の理由(docs/logging-design.md:408): 認証失敗もログで追跡できるようにするため。 相関IDを後にすると、弾いたリクエストのログに ID が付きません。
★既存の withRequestId() は変更不要です。★ ヘッダがあればそれを使い、無ければ生成する作りで (docs/decisions.md:273-274)、proxy.ts 投入後もそのまま動きます。触らないでください。
★リクエストヘッダを後段へ渡す方法は、必ずガイドで確認してください。★ NextResponse.next() に渡す形が版によって違います。記憶で書かないこと。
matcher★matcher を書かないと静的ファイルにも全部かかります。★ ガイドの警告:
Without a
matcher, Proxy runs on every request, including static files (_next/static), image optimizations (_next/image), and assets in thepublic/folder. ... otherwise auth logic or redirects can unintentionally block CSS, JS, or images from loading.
_next/static / _next/image / favicon.ico / public/)を除外する/login と /api/auth/login は matcher で除外せず、関数の中で分岐して通すこと★ (公式の認証例がその形。matcher で削ると保護漏れの発見が難しくなります)matcher に変数は使えません★(ビルド時に静的解析されるため)。 許可IPリストを matcher に書こうとしないこと| # | 内容 |
|---|---|
| 1 | ★ランタイムは Node.js 固定。runtime を指定するとエラーになります★。Edge は proxy では非対応。fs は使えます |
| 2 | ★_next/data は matcher で除外しても必ず proxy が走ります★。「ページを保護したのにデータルートを保護し忘れる」事故を防ぐ意図的な挙動です。除外できないものとして扱ってください |
| 3 | ★Server Functions は matcher の除外を受けても実行されます★(使用ページへの POST として扱われる)。ガイドは「各 Server Function の中で認証・認可を検証せよ。proxy だけに頼るな」と明記。P1 では Server Actions を使っていないので proxy 単独で足ります。段階6 の記録に「Server Actions を使い始めたら各関数内で検証が必要」と書いてください |
| 4 | LB 配下では serverActions.allowedOrigins の設定が必要になる可能性があります(next.config.ts は現在未設定)。この指示書では触らず、段階6 の記録に申し送りだけ書いてください |
app/login/ と app/api/auth/| 項目 | 値 | 理由 |
|---|---|---|
| 名前 | ★dwatch_session★ |
lib/logger/redact.ts のコメントとテストデータが既にこの名前を想定している |
httpOnly |
true | クライアントの JS から読めなくする |
secure |
true | ブラウザ〜LB 間は HTTPS なので有効(アプリが HTTP で応答してもブラウザは受け入れます) |
sameSite |
lax |
D-072 |
path |
/ |
|
★expires / maxAge★ |
★付けない★ | CLAUDE.md §7「トークン期限/残日数の実装」は禁止。公式例には載っていますが、ここでは入れません |
★AUTH_TOKEN と同じ値を入れます(署名トークンは作りません)。★
docs/architecture.md:191 が「cookie の失効方式 → 署名トークンか設定値との突き合わせか」を 未決として予告していましたが、設定値との突き合わせを採ります。理由:
★ただし比較は必ず lib/auth/token.ts の定数時間比較を通すこと。★ proxy の中で === を書かないでください。
app/login/ — トークンの入力欄と送信。★入力値をログに出さないこと★app/api/auth/login — 検証して cookie を発行。失敗しても「トークンが違う」以上の情報を返さないapp/api/auth/logout — cookie を削除/login は認証をかけない★(段階2-3 の関数内分岐で通す)リダイレクトや絶対URLを自前で組まないこと。 LB が HTTPS を終端するため、 アプリが組み立てると http:// になります。★相対パスで済ませる★か、 どうしても絶対URLが必要なら X-Forwarded-Proto を見てください。
redact.ts に cookie 名を追加(★既存テストが2件落ちます★)lib/logger/redact.ts のコメントが申し送っています。
cookie 名そのもの(
dwatch_session等)は未決なので入れない。 認証実装時に決まった名前をここへ追加すること。
★dwatch_session を禁止キーに追加してください。★ そして ★同じコミットで lib/logger/__tests__/redact.test.ts を2箇所直すこと★。直さないと必ず落ちます。
| 落ちるテスト | 直し方 |
|---|---|
| 認証情報8キーの配列を順序込みで完全一致させているテスト | 期待値の配列に dwatch_session を追加(並び順も合わせる) |
expect(FORBIDDEN_LOG_FIELDS).toHaveLength(21) |
★22 に更新★ |
★テストを「落ちるから消す」のは禁止です。★ このテストは「マスキング対象を勝手に減らさせない」 ための固定なので、期待値を正しく増やすのが直し方です。
lib/logger/events.ts はイベント型の allowlistになっています。認証のイベントが無いので追加します。
redact.ts の個人情報13キーの方針と衝突しないかを確認し、 ★判断した内容と理由を段階6 の記録に書いてください★X-Forwarded-For の段数を実測で確定する(新規)★★プロキシは1段(D-079 項1)で確定していますが、実測で裏を取ってから段階6 に記録します。★ 設定値の思い込みが1つ残るだけで IP制限は無意味化するので、必ず一度は生値を見てください。
手順
X-Forwarded-For の生値を1回だけログに出し、, 区切りの要素数を数える★lib/logger の既存の仕組みを使うこと★
docs/logging-design.md を先に読む(§4 マスキング規約 / §8 のステップ一覧)lib/logger/events.ts は ★イベント型の allowlist★ なので、型を足さないと渡せません。 一時確認用のイベント型を足して logEvent() から出す(5-1 で足すイベントに相乗りしてもよい)console.log を残さないこと★。eslint.config.mjs は no-console を全体に適用し、 lib/logger/** だけを除外しています(npm run lint が落ちます)lib/logger に debug レベルはありません★ — lib/logger/index.ts:20 の LogLevel は "info" | "warn" | "error" です(resolveLevel() は開発時に pino の しきい値を debug にしますが、logEvent() から debug は出せません)LogLevel の拡張が必要で、 それはこの指示書の範囲外です。★どちらにしたかを段階6 に記録すること★lib/logger/redact.ts のマスキング方針を確認すること★
FORBIDDEN_PII_FIELDS(13件)と FORBIDDEN_CREDENTIAL_FIELDS(8件)に ★ip / client_ip / x_forwarded_for は入っていません★ = ★redact は止めてくれません。 出す/出さないは実装者の判断です★判定表
| 実測した要素数 | 末尾の値 | 解釈 | TRUSTED_PROXY_HOPS |
|---|---|---|---|
| ★1★ | 社内IP(実際の接続元) | ★想定どおり★ — クライアントが XFF を送っておらず、nginx が1個足した状態 | ★1★ |
| ★2以上★ | 社内IP(実際の接続元) | 想定どおり — クライアントが自称 XFF を送ってきており、nginx がその後ろに足した | ★1★ |
| ★2以上★ | ★社内IPでない値(10.x / 172.16-31.x / 192.168.x などプライベートIP)★ |
★2段構成を疑う★ — 末尾が LB の内部IPになっている可能性 | ★2★(変更は設定値だけ。コード変更不要) |
TRUSTED_PROXY_HOPS の値を段階6 の記録に残すこと★ (生のIPアドレスは docs/decisions.md に書かないこと。「社内IPだった」等の記述で足ります)★この段階は 2026-09-10 のユーザー判断を受けて後から挿入したものです。段階番号は繰り下げていません★ (★指示書K の段階7-B と同じやり方★)。実行順は 段階5 → 段階5-B → 段階6 です。
1-1-c で ALLOW_DIRECT_ACCESS を環境変数に移した見返りとして、ユーザーが求めた条件がこれです。
その代わりサーバーに載せ替えた際にすぐに環境変数を設定できるようにバッチか手順など作成してください
★「設定を忘れたら閉じる」だけでは不十分で、「設定すべきものが分かる」状態を用意する★のが目的です。 ★サーバは Linux(さくらのクラウド VM。CLAUDE.md §3)なので、Windows の .bat ではなく シェルスクリプトか手順書+npm スクリプトが適切です★。本指示書は ★npm スクリプト+手順書★ を採ります (.sh を足すと Windows のローカル開発から実行できず、package.json の既存の形からも外れるため)。
| 項目 | 実測 |
|---|---|
| ★デプロイ手順の文書は存在しない★ | docs/ に deploy 系のファイルは無い(architecture.md / decisions.md / fixtures.md / getting-started.md / handoff.md / logging-design.md / setup-checklist-daigo.md / spec.md / handoff/) |
| ★コードが実際に読んでいる環境変数★ | ★DATABASE_URL / LOG_LEVEL / NODE_ENV / USE_FIXTURES の4つだけ★(lib/ app/ scripts/ prisma7.config.ts を grep した実測。lib/generated/prisma/ は生成物なので除く)lib/db/prisma.ts:28 DATABASE_URL / lib/db/use-fixtures.ts:17 USE_FIXTURES / lib/logger/index.ts:23 LOG_LEVEL / lib/logger/index.ts:28 NODE_ENV / scripts/seed-local-db.ts:54 DATABASE_URL |
★.env.example にあるキー★ |
USE_FIXTURES(:2) / DATABASE_URL(:6) / INSPECTION_DB_URL(:9) / AUTH_TOKEN(:12) / LOG_LEVEL(:15) |
★.env.local にあるキー★ |
上から LOG_LEVEL を除いた4つ |
★AUTH_TOKEN と INSPECTION_DB_URL はコードから読まれていない★ |
★AUTH_TOKEN は本指示書の段階1-2 で使い始めます★(INSPECTION_DB_URL は点検記録機能の未実装分。本指示書では触らない) |
★.env.example:12 は AUTH_TOKEN=changeme★ |
★.env.local をこれから作った人は changeme のままになります★(検査1 が拾う対象そのもの) |
package.json の scripts(:5-13) |
dev / build / start / lint / test / test:watch / seed:local |
seed:local の形(package.json:12) |
tsx scripts/seed-local-db.ts(tsx は devDependencies package.json:31) |
| # | 内容 |
|---|---|
| ★1★ | ★.gitignore:34 が .env* を無視し、:35 の !.env.example だけが例外です。したがって .env.production.example を作っても Git に入りません★(git add docs/... .env.production.example が The following paths are ignored で失敗する)。★.gitignore に !.env.production.example を追加すること★。.env.example の直後に並べて書くと意図が読み取れます |
| ★2★ | ★.env.production.example は「例」であって秘密ではありません。実値を書かないこと★(CLAUDE.md §9.3「認証情報を絶対にコミットしない」)。★DB のホスト名・ユーザー名・パスワードを書かない★ |
| ★3★ | ★検証スクリプトを tsx で単体実行すると .env.local は自動で読まれません★(Next.js が読むのはアプリ実行時だけ)。★scripts/seed-local-db.ts:23-25 が dotenv.config({ path: ".env.local" }) → dotenv.config() の順で読んでいる前例に倣うこと★ |
| ★4★ | ★vitest.config.ts の include は `["lib//.test.ts", "app/**/.test.ts"]です**★。★**scripts/配下に置いたテストは実行されません**★ → ★**純粋関数はlib/` 配下に置くこと**★(下記) |
.env.production.example(新規)★本番で設定すべき環境変数のテンプレート★です。★各行にコメントで「何のための値か」「未設定だとどうなるか」を書くこと★。
ALLOW_DIRECT_ACCESS は値を書かないこと★。★コメントアウトした上で「本番では設定しない」と明記する★ (★行があると、つい設定してしまいます。かといって完全に書かないと「知らずに設定してしまう」ので、 「設定するな」という指示として残すのが正解です★)AUTH_TOKEN は changeme のようなダミーを置き、「★必ず変更する★」と明記★USE_FIXTURES は本番では false を明記★。★未設定だと true になり(lib/db/use-fixtures.ts:17 の !== "false")、実DBに繋がらないままサンプルデータを表示します★ — ★これも例外が出ない事故です★書き方の例(★内容は着手時の実測に合わせること★):
# 本番で設定する環境変数のテンプレート(docs/deploy.md を先に読むこと) # ★このファイルに実値を書かないこと。コピーして .env.local を作り、そちらに書く★ # 実DBに接続する。★本番では必ず false★(未設定だと true になり、サンプルデータのまま動く) USE_FIXTURES=false # TeamWorks構築のMySQL(★参照用アカウント★)。未設定だと起動しても画面が全部エラーになる DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/DATABASE" # 簡易トークン認証の共有トークン(指示書I 段階1-2)。 # ★必ず変更すること★。未設定・空なら誰もログインできない(=全機能が使えない) AUTH_TOKEN=changeme # 動作ログの出力レベル。未設定なら NODE_ENV から判定(本番は info) LOG_LEVEL=info # ★★ALLOW_DIRECT_ACCESS は本番では設定しないこと★★ # nginx を通らない直接アクセスを素通りさせる開発専用スイッチ。 # 未設定なら false(拒否)。ローカル開発の .env.local にだけ書く。 # ALLOW_DIRECT_ACCESS=true
★あわせて .env.example(=開発用)にも ALLOW_DIRECT_ACCESS=true を追記すること★。 ★開発者は docs/getting-started.md:96 の cp .env.example .env.local で環境を作ります★。 ★ここに書いておかないと、新しく入った人はローカルで全ページ 403 になり、原因に辿り着けません★ (★本番用の .env.production.example とは向きが逆になりますが、それが意図です。 両方のファイルにその旨をコメントで書くこと★)。
★置き場所と実行方法は実装者が決めてよい★。推奨は次の形です(★seed:local が tsx で動いている前例に倣う★)。
| 置き場所 | 中身 | |
|---|---|---|
★lib/config/env-check.ts★ |
★純粋関数★。`Record<string, string \ | undefined>を受け取り、検査結果の配列を返す。★process.env` を直接読まないこと★ |
★lib/config/__tests__/env-check.test.ts★ |
上のテスト(★vitest.config.ts の include に入る場所★。地雷4 参照) |
|
scripts/check-env.ts |
★薄い実行部だけ★。dotenv で読み込み → 純粋関数へ渡す → 整形して出力 → ★終了コードを返す★ | |
package.json の scripts |
"check:env": "tsx scripts/check-env.ts" を追加(★seed:local の隣★) |
| # | 検査 | 結果 | ||
|---|---|---|---|---|
| ★1★ | ★AUTH_TOKEN が未設定・空・changeme のまま★ |
★エラー★(.env.example:12 の初期値がまさに changeme。★これを本番に持ち込むと共有トークンが公開情報になります★) |
||
| ★2★ | ★ALLOW_DIRECT_ACCESS=true が設定されている★ |
★本番判定なら エラー / それ以外なら 警告★(開発機では正常な設定なので落としてはいけない) | ||
| ★3★ | ★USE_FIXTURES が未設定または false 以外★ |
★本番判定なら 警告★(実DBに繋がらずサンプルデータのまま動く) | ||
| ★4★ | ★DATABASE_URL が未設定★ |
★エラー★ | ||
| ★5★ | ★DATABASE_URL のホストが localhost / 127.0.0.1★ |
★本番判定なら 警告★ | ||
| 6 | LOG_LEVEL が想定外の値 |
★警告★(pino のレベル名 trace / debug / info / warn / error / fatal 以外なら警告)★**注意: lib/logger/index.ts:20 の LogLevel は `"info" \ |
"warn" \ | "error"の3つだけですが、resolveLevel()(:22-25) は開発時にdebugを返します。検査でdebug` を弾かないこと**★ |
★要点1 — 「本番判定」を何で行うかを決めること★
NODE_ENV === "production" が素直です★(lib/logger/index.ts:27-29 の isProduction() が同じ判定をしている前例)NODE_ENV ではなく設定値で明示的に切れ」と矛盾しません。★ ★あの指示は ALLOW_DIRECT_ACCESS(= リクエストを通す/弾くという実際の挙動)の話★です。 ★検証スクリプトの分岐は「警告の文言をどちらに寄せるか」でしかなく、挙動を変えません★。 ★この区別を実装のコメントにも書くこと★(後任が「NODE_ENV を使っている、規約違反だ」と誤読するのを防ぐため)isProduction: boolean)。テストで両方を回せる形にする★要点2 — エラーメッセージに値そのものを出さないこと★
DATABASE_URL にはパスワードが入ります★。★出力はターミナルに残り、CI のログにも残ります★lib/db/local-guard.ts が同じ配慮をしている前例です★ — lib/db/local-guard.ts:7-8 のコメント(★「呼び出し側はエラーメッセージに DATABASE_URL の値を出さないこと」 「CLAUDE.md §9.3。パスワードがログに残るため、この関数自身も受け取った url を返さない」★)と、 lib/db/local-guard.ts:26-28 の実際のメッセージ(★末尾が「値はこのエラーには出しません」★)を 読んでから書くことDATABASE_URL が未設定です」「DATABASE_URL が localhost を指しています」のように、 何が問題かだけを言うこと★。★AUTH_TOKEN の値も同様に出さない★ (検査1 は「changeme のままです」とキーワード名だけ言えば足ります)★要点3 — 終了コードを返すこと★
process.exitCode = 1)★。警告だけなら 0npm run build の前に挟めるようにするため★です(下の (3) の手順3)ERROR: / WARN: の接頭辞)★要点4 — 純粋関数に切り出してテストを書くこと★
lib/db/local-guard.ts と同じ形★(isLocalDwatchDatabaseUrl() が純粋関数、 assertLocalDwatchDatabaseUrl() が薄い包み)。★DB 無し・環境変数無しでテストできます★CLAUDE.md §6-2)。最低限、検査1〜6 それぞれについて ★「引っかかる入力」「引っかからない入力」「本番判定 true/false で結果が変わること」★ を固定するDATABASE_URL を渡し、★出力にそのパスワード文字列が含まれないこと★を assert する)。 ★これは要点2 が後から壊されるのを防ぐ唯一の手段です★★置き場所は docs/deploy.md を推奨★(★docs/ に既存の同種文書が無いことを確認済み★。 開発側の手順は docs/getting-started.md にあるので、★本番側はそれと別文書にする★。 docs/getting-started.md:96 の cp .env.example .env.local が開発側の対応する記述です)。
書く内容:
AUTH_TOKEN が5つ目になる」ことを明記★.env.production.example をコピーして .env.local を作るAUTH_TOKEN は必ず変更する★)npm run check:env を実行して通ることを確認する★(★エラーが出たら先に進まない★)npm run buildnpm run startALLOW_DIRECT_ACCESS は本番で設定しない、と太字で書くこと★★。 ★設定すると nginx を通らない直接アクセスが素通りします★X-Forwarded-For に「追記」すること★(D-077 項5。$proxy_add_x_forwarded_for 相当)。 ★上書きにすると TRUSTED_PROXY_HOPS の前提が崩れます★ALLOW_DIRECT_ACCESS はあくまで『XFF が取れないとき』の保険であって、 偽装 XFF は防げません」★)と、段階6 の申し送り(★「アプリのポートが nginx からのみ到達可能かは未確認のまま」★) を引くことALLOWED_IPS)はコードにあるので、変更にはデプロイが必要★ と明記する (★環境変数ではないので、.env.local を直しても変わりません★。1-1-d の理由も1行で添える)| 症状 | 疑うところ |
|---|---|
| ★全員が 403★ | ①ALLOWED_IPS(lib/config/allowed-ips.ts)に接続元が入っていない ②TRUSTED_PROXY_HOPS の段数違い(★2段構成なら末尾は LB の内部IPになる。段階5-2 の判定表を参照★) ③ALLOW_ALL_WHEN_EMPTY=false かつ allowlist が空 |
| ★特定の人だけ 403★ | ALLOWED_IPS の表記ゆれ(★IPv6 短縮形 / IPv4射影 ::ffff: / ポート付き は正規化しないので、書き方が違うだけで拒否されます。段階1-1-b★) |
| ★ローカルで 403★ | .env.local に ALLOW_DIRECT_ACCESS=true が無い(★これが正しい既定の挙動です★) |
| ★ログインできるが誰でも入れてしまう★ | ★ALLOW_DIRECT_ACCESS=true が本番に残っている★(★npm run check:env の検査2 が検出します★)/ ALLOWED_IPS にプライベートIPを書いてしまっている |
npm run check:env(相当)が動き、危険な値でエラーになること★
AUTH_TOKEN=changeme のまま実行してエラー(終了コード非0)になることを実際に確認する★ALLOW_DIRECT_ACCESS=true で警告(開発機)/エラー(本番判定)になることを確認する★lib/ 配下に置くこと。scripts/ では vitest が拾いません★)
DATABASE_URL の値が含まれないことのテストを含む★.gitignore に !.env.production.example を追加し、git status にこのファイルが現れること★ (★追加しないと Git に入りません。地雷1★)docs/deploy.md が存在し、★手順1〜5 をなぞれば本番が立ち上がる状態になっていること★npx tsc --noEmit エラー0 / npm test 全パス / npm run lint 0).env.production.example に実値(実在のホスト名・ユーザー名・パスワード・トークン)が入っていないこと★docs/decisions.md(★着手直前に最終番号を確認★)securitySchemes の変更内容ALLOW_ALL_WHEN_EMPTY の既定を true にしたこと。本番では false にする必要があること★ALLOW_DIRECT_ACCESS の既定を false にしたこと。ローカル開発でのみ true にすること★ (★ALLOW_ALL_WHEN_EMPTY で代用してはいけない理由も残す★)ALLOW_DIRECT_ACCESS を TypeScript 定数ではなく環境変数にした理由★★(2026-09-10 のユーザー判断。段階1-1-c)
true にした行をそのままコミットする事故が起こりえた。 .gitignore:34 の .env* により、環境変数なら構造的にコミットできない★=== "true" にした理由★★ = ★未設定・空文字・タイポ・1・TRUE がすべて false(拒否)になり、 本番で設定し忘れても安全側(fail-closed)に倒れるから★USE_FIXTURES(lib/db/use-fixtures.ts:17)は !== "false"(未設定なら true)だが、 あれは「開発しやすさ優先」の設計であり、セキュリティに関わる本設定では意図的に逆にしたこと★★。 ★「既存に合わせて統一しよう」として戻されるのを防ぐため、必ず記録に残すこと★ALLOWED_IPS / TRUSTED_PROXY_HOPS / ALLOW_ALL_WHEN_EMPTY は環境変数に移さず TypeScript 定数のままにしたこと★(段階1-1-d)。★理由 = この3つは本番で意味のある値なのでコードで管理し レビューと履歴を残す。ALLOW_DIRECT_ACCESS だけが「本番では常に false、true にするのは開発中だけ」という 性質だから分けた★.env.production.example / ★npm run check:env(検証スクリプト)★ / docs/deploy.md
.gitignore に !.env.production.example を追加したこと★(★.env* に飲まれて Git に入らないため。 .gitignore:34-35★)NODE_ENV === "production" を使ったこと★と、 ★それが「NODE_ENV ではなく設定値で切れ」という本指示書の方針と矛盾しない理由★ (★あの方針は ALLOW_DIRECT_ACCESS = リクエストの通す/弾くの話。検証スクリプトの分岐は 警告文言の寄せ方でしかなく、アプリの挙動を変えない★)DATABASE_URL にパスワードが入るため。lib/db/local-guard.ts:7-8 と同じ配慮★)DATABASE_URL / LOG_LEVEL / NODE_ENV / USE_FIXTURES の4つで、 本指示書で AUTH_TOKEN が5つ目になったこと★X-Forwarded-For は nginx が「追記」する(D-077 項5、2026-09-08 のユーザー回答)。 したがって末尾側を信じる★TRUSTED_PROXY_HOPS = 1(末尾を取る)で確定したこと★X-Forwarded-For の要素数と末尾の値の種別。★生のIPは書かない★)と、 ★最終的に決めた TRUSTED_PROXY_HOPS の値★TRUSTED_PROXY_HOPS を1段確定後も設定値のまま残した理由★ = 後から2段だと発覚しても ★fail-closed(全員403)で即座に気づき、値を 2 にするだけで直る(コード変更不要)★LogLevel に debug が無いため 既定は削除。拡張した場合はその旨も)ALLOWED_IPS は文字列の完全一致のみで CIDR / レンジ / ワイルドカード非対応。 P1 は単一IPの列挙のみと割り切ったこと。拠点ネットワーク全体を許可したくなったら lib/config/allowed-ips.ts と isAllowedIp() を作り直すことになる★ALLOWED_IPS には nginx が実際に付ける表記をそのまま書く★serverActions.allowedOrigins の設定が必要になる可能性npm test のファイル数・テスト数 / npx tsc --noEmit / npm run lint)★行番号は着手時にズレている可能性があります。必ず該当箇所を実際に開いて確認すること。★
| 文書 | 内容 |
|---|---|
docs/architecture.md:39 |
proxy.ts の状態 ★「未着手(認証方式の決定待ち)」のまま残っています★ → 実装済みに |
docs/architecture.md:49-52 |
securitySchemes 未対応の注記(★bearerAuth / Laravel Sanctum は D-001 / D-003 で Next.js に変更済みの残骸★)と、「HTTPS の終端場所が回答待ちで proxy.ts も未着手だから保留」という理由をまとめて削除(段階0 で解決) |
docs/architecture.md:188-191 |
未確定表(## 8. 未確定 は 184 行目)から「リバースプロキシの有無」「HTTPS の終端場所」(★D-072 項1 と D-077 項5 で解決済み★)「ログイン画面の追加可否」「cookie の失効方式」を解決済みとして外す |
docs/architecture.md:71,85,127 |
app/login/ / app/api/auth/ / lib/auth/ の「予定」を実装済みに |
docs/logging-design.md:438 |
「フェーズ2 は proxy.ts は認証との共存方針(§9 の申し送り)を確認してから着手」→ ★解除。共存の形を実際に書いたので、その内容に更新★ |
docs/spec.md:141 |
「API共通実装(認証、エラー形式、ページネーション)」のチェックボックス |
★docs/getting-started.md:96,100★ |
★cp .env.example .env.local の直後に、ローカルでは ALLOW_DIRECT_ACCESS=true が必要だと1行足す★(★これが無いと新しく入った人はローカルで全ページ 403 になり、原因に辿り着けません。段階5-B (1)★) |
★docs/deploy.md(新規)★ |
★段階5-B (3) で作成★。開発側の getting-started.md と対になる本番側の手順書 |
docs/handoff.md |
末尾に追記を1つ追加。冒頭の「最新の追記」ポインタも更新 |
.claude/commands/d-watch.md |
「認証が未着手で URL を知っていれば全画面が開く」という記述を消す。テスト数と決定番号を実測値に更新 |
X-Forwarded-For の挙動を確認」という外部待ちの記述を消すこと★★D-077 項5(追記か上書きか)と D-079 項1(何段か)でどちらも 2026-09-08 に解決済み★なので、 指示書I の実行時に残っている全箇所から消してください。 ★行番号は着手時にズレている可能性があります。必ず該当箇所を実際に開いて確認すること★ (下表は 2026-09-08 時点の実測値)。
| 文書 | 該当行 |
|---|---|
.claude/commands/d-watch.md:166 |
未実行指示書の表の「外部待ち」列。★X-Forwarded-For は解決済みと書きつつ「LB と nginx は同一か2段か が新たな未確認事項」と書いてある★ → ★D-079 項1 で解決したので、この未確認事項の記述も消して「外部待ちなし」にする★ |
docs/handoff.md:1165, 1167〜1170 |
★「着手前にユーザーへ確認が必要な点(1件)」の見出しと本文★(「追記か上書きか」の確認事項と、先頭を取ると素通りする旨の注記)。★素通りの注意自体は残す価値があるので、「確認待ち」の部分だけ落とす★ |
docs/handoff.md:1214, 1297, 1427, 1515 |
各追記の「次にやること」にある ★「着手前にLBのX-Forwarded-Forの挙動を確認」★ |
docs/handoff.md:1604 |
「D-077 項5 で解決した」の記述に ★段数も D-079 項1 で解決したことを追記★ |
★docs/handoff.md:1157 の「request.ip は Next 15 で削除済み。X-Forwarded-For を読む以外に手段が無い」 は事実なので消さないこと。★
npx tsc --noEmit エラー0 / npm test 全パス / npm run lint 0middleware.ts が存在しないこと★(proxy.ts であること)npm run dev の出力に ★The "middleware" file convention is deprecated の警告が出ないこと★/dashboard を開くと /login へ飛ぶこと(実際にブラウザで確認)/api/dashboard を叩くと 401 で、本文が { error: { code, message } } であることdwatch_session cookie が httpOnly / Secure / SameSite=Lax で、★Expires / Max-Age を持たないこと★X-Forwarded-For: <許可IP> を自分で付けたリクエストが素通りしないこと★ (許可リストを設定した状態で、先頭に許可IPを書いた偽装ヘッダを送って 403 になることを確認) ★追記が確定した(D-077 項5)ので、これが最も重要な検証です。絶対に省略しないこと。★
TRUSTED_PROXY_HOPS = 2)になった場合は、 末尾から2番目を偽装しても弾けることも確認する★ (例: X-Forwarded-For: <許可IP>, <許可IP> を送る。nginx の追記で末尾に実接続元が足されるため、 末尾から2番目には自称の値がそのまま入り得ます)ALLOW_DIRECT_ACCESS を未設定(= false)にした状態で X-Forwarded-For を付けずに 直接叩くと 403 になること★(allowlist が空のままでも 403 であること = 期待値表の行9).env.local から ALLOW_DIRECT_ACCESS の行を消して(またはコメントアウトして)確認すること★。 ★ALLOW_DIRECT_ACCESS=false と書いて確認するだけでは不十分です★ — ★本番で起きるのは「未設定」であって「false と書いてある」ではないため★ (★=== "true" なので ALLOW_DIRECT_ACCESS=1 や =TRUE でも 403 になることを1回見ておくとなお良い★)matcher の除外が効いているかの確認。ログイン画面が崩れていたらここが原因)dwatch_session の値やトークンが出ていないこと(実際にログを見て確認)docs/ と .claude/commands/d-watch.md の更新TRUSTED_PROXY_HOPS の値を確定して段階6 に記録したこと★
X-Forwarded-For の生値を1回だけ lib/logger 経由で出し、要素数と末尾の値の種別を確認した★console.log が1つも残っていないこと★ (npm run lint の no-console で機械的にも確認できる).env.production.example があり、.gitignore に !.env.production.example を追加したので git status に現れること★(★追加を忘れると Git に入りません★)npm run check:env が動き、AUTH_TOKEN=changeme のままなら終了コード非0 で落ちること★ALLOW_DIRECT_ACCESS=true を検証スクリプトが検出すること★(本番判定でエラー/開発機で警告)DATABASE_URL の値が出ないことを固定していること★docs/deploy.md があり、手順1〜5 と「ALLOW_DIRECT_ACCESS を本番で設定しない」の明記があること★.env.production.example に実値(実在のホスト名・ユーザー名・パスワード・トークン)が無いこと★