Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

middlewareのmatcher除外に登録し忘れた公開ページが、匿名端末をログイン画面へ誤誘導していた

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Next.jsのmiddleware matcherにnegative lookaheadで除外パスを列挙する設計では、新しく増やした匿名公開ルートをその除外リストへ登録し忘れると、__sessionを持たない端末は新設ルートであっても常に/loginへリダイレクトされる。

結論

Next.jsのmiddlewareをnegative lookaheadの正規表現で「これ以外を保護対象にする」という書き方にしていると、新しく追加した匿名公開ルートをその除外リストに登録し忘れても、ビルドもテストも通ってしまう。 実際に踏んだのは、認証クッキーを持たない端末向けの公開ページを新設したのに、matcherの除外リストへの追加だけを忘れていたケースだった。ページのコードは正しくても、リクエストはアプリに到達する前にmiddlewareの段階で/loginへ弾かれ続けた。直し方は、既存の匿名経路と同じ書式でnegative lookaheadに1行足すだけだが、それに気づくまでが長い。

症状

学校向けサイネージ端末から見る公開表示ページ(URLは/signage/{classToken}とそのポーリング先/data)を新しく実装した。この経路は教員のログインとは無関係で、端末は__sessionクッキーを持たない。ページ自体のアクセス可否はclassTokenをDBで解決する関数が判定する設計で、ページのコードだけを見れば実装に不備はなかった。

ところが実機で開くと、classTokenの正当性を確認する前に/loginへリダイレクトされ、常に画面へ到達できなかった。ログの入口にすら来ていないため、ページ側のコードをいくら読んでも原因にたどり着けない状態だった。

原因

このリポジトリのmiddlewareは、cookieの有無だけを見る軽量な認証ゲートで、保護対象外にしたいパスをnegative lookaheadで列挙する書き方になっていた。

// apps/web/middleware.ts(修正前)
export const config = {
  matcher: [
    "/((?!login|s/|student|api/auth|api/health|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|css|js|map|woff|woff2|ttf)$).*)",
  ],
};

(?!login|s/|student|...)の中に列挙されていないパスは、すべて「保護対象」として扱われる。生徒向けの匿名経路である/s//studentは以前から除外リストに入っていたが、今回新設した/signage/はこの正規表現のどこにも登場しない。つまり/signage/{classToken}へのリクエストは、他の管理画面用ページと全く同じ扱いでmiddlewareに引っかかり、__sessionクッキーを持たない端末は無条件で/loginへリダイレクトされていた。

回帰テストが既にあったにもかかわらずこれを検知できなかったのは、そのテストが検証していたパスの一覧自体が古いままだったからだ。

// apps/web/__tests__/auth/middleware.test.ts(修正前の検証範囲)
describe("middleware matcher (F05 匿名経路の除外)", () => {
  const gated = new RegExp(`^${config.matcher[0]!}$`);

  it("F05 匿名経路 /s/{token}・/student はゲート対象外 (除外)", () => {
    expect(gated.test("/s/abc123_token")).toBe(false);
    expect(gated.test("/student")).toBe(false);
  });
});

テストは「/s//studentが除外されているか」を固定するもので、/signage/という新設パスについては何も主張していない。matcherの正規表現自体は1本の文字列なので構文的には常に妥当であり、ビルドも型チェックも落ちない。除外し忘れは、実機でそのURLを叩いて初めて表面化する種類の不具合だった。

直し方

既存の/s//studentと同じ書式で、negative lookaheadにsignage/を追加した。

// apps/web/middleware.ts(修正後)
export const config = {
  matcher: [
    "/((?!login|s/|student|signage/|api/auth|api/health|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|css|js|map|woff|woff2|ttf)$).*)",
  ],
};

signage/は末尾にスラッシュを付けてs/と同じ形にしている。これは/settingsのような無関係なパスまで巻き込まないための書き方で、既存の除外パターンがすでにその配慮をしていた。一方、認証が必要な/admin/signage-previewadminから始まるパスなので、signage/を除外に加えてもこちらの保護には影響しない。

修正と同時に、回帰テストにも新設パスの検証を追加した。

// apps/web/__tests__/auth/middleware.test.ts(修正後に追加した検証)
it("F12/#48-E 公開サイネージ /signage/{classToken}(/data) はゲート対象外 (除外)", () => {
  expect(gated.test("/signage/abc123_token")).toBe(false);
  expect(gated.test("/signage/abc123_token/data")).toBe(false);
});

it("signage/ 除外が認証必須の /admin/signage-preview を巻き込まない (過剰除外しない)", () => {
  expect(gated.test("/admin/signage-preview/some-class-id")).toBe(true);
});

前者は「新設した匿名経路が正しく除外されているか」、後者は「除外を広げすぎて別の保護対象を巻き込んでいないか」を、同じテストの中で対にして確認している。

再発防止

このmiddlewareのテストは、matcherの正規表現そのものを対象パス文字列でマッチさせて真偽を確認する形になっている。裏を返すと、テストの信頼性は「今ある匿名経路をすべて列挙できているか」に完全に依存しており、新しいルートを追加した回のコミットでテストを更新し忘れれば、正規表現の構文としては何も壊れていないぶん誰も気づけない。

今回の修正コミットでは、matcherへのsignage/追加と同じ差分の中に、新設パスの除外を検証するテストと、除外を広げすぎていないことを確認するテストの両方を含めている。テストの対象パス一覧を正規表現の変更と切り離さずに同じ変更内に置いておけば、少なくとも「新設ルートを足したのに、そのルートに対するテストが1件も無いまま変更が完結する」という状態は避けられる。

よくある質問

Q1なぜ権限は正しいのに、ログイン画面へ飛ばされるのですか?

このルートは、アプリのコード側ではなくmiddlewareのmatcher設定の時点で保護対象かどうかが決まります。matcherの除外リストに新しいパスを足し忘れると、そのパス配下のリクエストは他の保護ルートと同じ扱いになり、__sessionクッキーを持たない端末やブラウザは、アプリのコードに到達する前にmiddlewareの段階で/loginへリダイレクトされます。

Q2テストがあったのに、なぜこの除外漏れを検知できなかったのですか?

matcherの正規表現に対する回帰テストは既に存在していましたが、既存の/s/・/studentという2つの匿名経路だけを検証する内容で、新しく追加した/signage/はそのテストの対象に入っていませんでした。テストの対象パスの一覧そのものが、新設ルートの分だけ古いままだったため検知できませんでした。

Q3同じ除外漏れをまた起こさないために何をしましたか?

matcherの正規表現をテストする際に、新設した/signage/{classToken}と/signage/{classToken}/dataの両方が除外されていることを検証するテストケースを追加しました。あわせて、認証必須の/admin/signage-previewが誤って除外されていないことも同じテストで確認するようにしています。

確認した環境

  • Next.js ^16.0.0(Edge Middleware)
  • 2026-05-31 に修正

この記事の根拠

  • TypeScriptファイル 50〜55行目コミット c4a9263
  • TypeScriptファイル 40〜58行目コミット 63f0d54
  • TypeScriptファイル 49〜56行目コミット c4a9263
  • TypeScriptファイル 58〜62行目コミット 63f0d54
  • TypeScriptファイル 76〜79行目コミット 63f0d54

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。