Rebounder Tech Blog

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

Next.jsのmiddleware matcherは文字列prefix一致、api/auth-adminも除外する

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

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

結論

Next.jsのmiddleware matcherでconfig.matcherの否定先読みにprefix文字列だけを書くと、除外したいパスの直後に別名の保護ルートが続くだけで、そのルートまで静かに認証ゲート対象外になる。

結論

Next.jsのconfig.matcherに書く否定先読み(?!token)はJavaScript正規表現のprefix一致であり、tokenを単体で書くと「その文字列で始まるか」しか見ない。 キミテラスv2の認証middleware(apps/web/middleware.ts)はlogin / student / api/auth / api/healthの4トークンを境界なしで書いていたため、これらと同じ接頭辞を共有するだけの別パス(/api/authorityや/studentsのような、将来追加されうる保護ルート)までnegative lookaheadにマッチし、静かにmiddlewareの認証ゲート対象外になる経路があった。直し方は各tokenの直後に(?:/|$)という境界アンカーを足すことだった。

症状

2026-05-31のPR #250(#139 L3)で見つかった。修正前のconfig.matcherは次のように書かれていた。

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

login student api/auth api/healthの4トークンは前方一致のまま残っていた(guideだけは別PR #227 Reviewer Low-1の指摘で先にguide(?:/|$)へ境界化済みだった)。この状態で/api/authorityのような保護ルートを追加すると、api/authという文字列で始まっている以上negative lookaheadが成立し、config.matcherの除外対象=middleware非適用=認証ゲート対象外になってしまう。このコードのdocstring自身が「将来の保護対象ルート(/students, /api/authority, /api/healthcheck, /loginland等)を静かにゲート対象外にし得る」と明記していた。

原因

JavaScriptの否定先読み(?!...)は、その位置から後ろの文字列が指定した選択肢のいずれかで始まっているかだけを判定する。単語境界や終端の指定が無ければ、tokenの後ろに何が続いても「一致した」とみなされる。guideだけはguide(?:/|$)という境界が別PR #227で先に入っていたのに、残りの4トークンは素のまま放置されていた。レビューで「prefix一致の取りこぼし」という同じ構造の問題が指摘され、今回まとめて境界化した。

直し方

各tokenの直後に(?:/|$)(スラッシュまたは文字列終端)を追加した。

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

s/ signage/ api/guide/はもともと末尾が/なので既に境界済みであり変更していない。回帰テストは、tokenの直後に別の文字が続くケースが保護対象(gate対象)に戻ること、既存の正規の除外パス(bareとサブパスの両方)が変わらず除外されたままであることの両方を確認している。

// middleware.test.ts(追加分、sha 787653311)
it("境界厳格化 (#139 L3): prefix 一致だった token が look-alike な保護ルートを過剰除外しない", () => {
  expect(gated.test("/loginland")).toBe(true);
  expect(gated.test("/students")).toBe(true);
  expect(gated.test("/student-records")).toBe(true);
  expect(gated.test("/api/authority")).toBe(true);
  expect(gated.test("/api/auth-admin")).toBe(true);
  expect(gated.test("/api/healthcheck")).toBe(true);
  expect(gated.test("/api/health-internal")).toBe(true);
});

it("既存の認証不要パスも除外のまま (bare とサブパスの両方)", () => {
  expect(gated.test("/login")).toBe(false);
  expect(gated.test("/student")).toBe(false);
  expect(gated.test("/api/auth/session")).toBe(false);
  expect(gated.test("/api/auth/signout")).toBe(false);
  expect(gated.test("/api/health")).toBe(false);
});

再発防止

このmiddlewareはcookieの存在チェックのみを行う軽量ゲートで、実際のcookie検証とRLS contextの確立はServer Component / Route Handler側がgetCurrentUser() / withSession()を通じて最終防衛線として行う設計になっている。そのためmatcherの取りこぼしだけで即時の認可バイパスにはならないが、defense-in-depthを1枚剥がすことに変わりはない。docstringには境界化の意図と対象トークンが明記され、テストスイートには「bare/サブパスは除外のまま」「look-alikeな保護ルートは除外しない」の両方向のケースが揃っているため、将来誰かが新しいprefixトークンを境界なしで足そうとしても、どちらかのテストがすぐに落ちる。

よくある質問

Q1config.matcherのnegative lookaheadはなぜprefix一致になりますか?

JavaScriptの否定先読み`(?!token)`は、tokenという文字列がその位置から始まるかどうかだけを見る。tokenの後ろに境界を示す記号が無ければ、続けて別の文字が連なっていても『tokenが現れた』と判定され、config.matcherの除外(middleware非適用)対象になる。

Q2具体的にどのパスが誤って除外されていましたか?

`login`・`student`・`api/auth`・`api/health`の4トークンがprefix一致のままで、`/loginland`・`/students`・`/student-records`・`/api/authority`・`/api/auth-admin`・`/api/healthcheck`・`/api/health-internal`のような保護ルートを将来追加すると、middlewareの認証ゲートを素通りしてしまう状態だった。

Q3なぜguideは先に直っていたのですか?

別のPR(#227 Reviewer Low-1)で`/guidelines`が`guide`のprefix一致に巻き込まれて除外される同種の指摘が先に入り、`guide(?:/|$)`という境界化が先行して適用されていた。今回はその前例と同方針で残り4トークンにも境界アンカーを広げた。

Q4直し方は何をしましたか?

各tokenの直後に`(?:/|$)`(スラッシュまたは文字列終端)という境界アンカーを追加した。`login(?:/|$)`は`/login`と`/login/xxx`にはマッチするが、`/loginland`のようにtokenの直後がスラッシュでも終端でもない文字列にはマッチしなくなる。

Q5middlewareのmatcherから外れても安全な理由は何ですか?

このmiddlewareはcookieの存在チェックのみを行う軽量ゲートで、実際のcookie検証はServer Component / Route Handler側が最終防衛線として必ず行う設計。matcherの取りこぼしはdefense-in-depthを1枚剥がすだけで、即時の認可バイパスにはならない。

確認した環境

  • Next.js ^16.0.0(apps/web)/ middleware.ts の config.matcher
  • 2026-05-31 fix commit 787653311(PR #250、#139 L3)

この記事の根拠

  • TypeScriptファイル 58〜62行目コミット 26c1858
  • TypeScriptファイル 66〜70行目コミット 7876533
  • TypeScriptファイル 82〜92行目コミット 7876533

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