Rebounder Tech Blog

Written by the people who actually run these systems in production.

A Public Route Missing From the middleware matcher

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

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

結論

When a Next.js middleware matcher lists exclusions with a negative lookahead, forgetting to add a newly created anonymous public route means devices without __session are always redirected to /login.

The short version

When Next.js middleware is written as a negative-lookahead regex meaning “protect everything but these”, forgetting to register a newly added anonymous public route in that exclusion list still passes the build and the tests. What we actually hit: a public page was created for devices with no auth cookie, and only the addition to the matcher’s exclusion list was forgotten. The page’s own code was correct, but requests were bounced to /login at the middleware stage before ever reaching the app. The fix is one line in the negative lookahead, in the same form as the existing anonymous routes — but noticing that takes a while.

What it looks like

We implemented a new public display page viewed from a school’s signage device: /signage/{classToken} and the /data endpoint it polls. This path has nothing to do with teacher login, and the device holds no __session cookie. Access is decided by a function that resolves classToken against the database, and looking at the page’s own code there was nothing wrong with it.

On a real device, though, it was redirected to /login before classToken was ever validated, and the screen was never reachable. Nothing arrived even at the entry log, so no amount of reading the page code led to the cause.

Why

This repository’s middleware is a lightweight auth gate that only inspects the presence of a cookie, and paths to leave unprotected are enumerated in a negative lookahead.

// apps/web/middleware.ts (before the fix)
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)$).*)",
  ],
};

Any path not enumerated inside (?!login|s/|student|...) is treated as protected. /s/ and /student, the anonymous student routes, had been in the exclusion list for a long time, but the newly added /signage/ appears nowhere in this regex. So a request to /signage/{classToken} hit the middleware exactly like an admin page, and a device with no __session cookie was unconditionally redirected to /login.

The reason the existing regression test failed to catch this is that the list of paths it verified was itself stale.

// apps/web/__tests__/auth/middleware.test.ts (scope before the fix)
describe("middleware matcher (F05 anonymous route exclusions)", () => {
  const gated = new RegExp(`^${config.matcher[0]!}$`);

  it("F05 anonymous routes /s/{token} and /student are outside the gate (excluded)", () => {
    expect(gated.test("/s/abc123_token")).toBe(false);
    expect(gated.test("/student")).toBe(false);
  });
});

The test pins “are /s/ and /student excluded” and asserts nothing about the new /signage/ path. The matcher regex is a single string, so it is always syntactically valid; neither the build nor the type check fails. A missing exclusion is the kind of defect that only surfaces when someone hits that URL on a real device.

Fixing it

We added signage/ to the negative lookahead, in the same form as the existing /s/ and /student.

// apps/web/middleware.ts (after the fix)
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/ carries a trailing slash to match the shape of s/. That is what keeps an unrelated path like /settings from being swept in, a consideration the existing exclusion patterns already made. Meanwhile /admin/signage-preview, which does require auth, starts with admin, so adding signage/ to the exclusions does not affect its protection.

Alongside the fix, the regression test gained coverage for the new path.

// apps/web/__tests__/auth/middleware.test.ts (added with the fix)
it("F12/#48-E public signage /signage/{classToken}(/data) is outside the gate (excluded)", () => {
  expect(gated.test("/signage/abc123_token")).toBe(false);
  expect(gated.test("/signage/abc123_token/data")).toBe(false);
});

it("the signage/ exclusion does not sweep in the authenticated /admin/signage-preview (not over-excluded)", () => {
  expect(gated.test("/admin/signage-preview/some-class-id")).toBe(true);
});

The first asks “is the new anonymous route correctly excluded”, the second “was the exclusion widened so far that it sweeps in something protected” — paired inside the same test.

Preventing a repeat

This middleware test matches the matcher regex against path strings and asserts the boolean. Which means its reliability depends entirely on whether every anonymous route currently in existence is enumerated in it, and if the commit that adds a new route forgets to update the test, nobody notices — nothing is broken about the regex syntax.

The fix commit therefore keeps the signage/ addition to the matcher in the same diff as both tests: one verifying the new path is excluded, one verifying the exclusion is not too wide. Keeping the list of paths under test inseparable from the regex change at least avoids the state where “a new route was added and the change completed with not a single test covering it”.

よくある質問

Q1Permissions are correct, so why the redirect to the login screen?

Whether this route is protected is decided by the middleware matcher config, not by application code. Forget to add a new path to the exclusion list and requests under it are treated like any other protected route: a device with no __session is redirected to /login before reaching the app.

Q2There were tests. Why didn't they catch the missing exclusion?

A regression test for the matcher regex already existed, but it only verified the two existing anonymous routes, /s/ and /student. The newly added /signage/ was not in its scope. The list of paths the test covers was itself stale by exactly the new route.

Q3What was done so this exclusion isn't missed again?

We added test cases asserting that both /signage/{classToken} and /signage/{classToken}/data are excluded by the matcher regex. The same test also confirms that the authenticated /admin/signage-preview is not excluded by mistake.

確認した環境

  • Next.js ^16.0.0 (Edge Middleware)
  • Fixed on 2026-05-31

この記事の根拠

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

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