A Temp-Password Guard Written Per Page Leaks by URL
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Implement the forced redirect separately on each customer page and every new page will miss one. The hole was not a missing line — it was where the guard had been placed.
The short version
Implement a forced redirect separately on each customer page, and every time you add a page you will miss one. In the advertiser portal, the redirect that stops a customer from skipping the forced change while still on a temp password (must_change_password=true) was written on / and /dashboard only; no other customer page looked at the temp-password state at all. After logging in, going straight to /creatives or /materials by URL walked past the forced change. The fix was to move the decision into one authorisation function, requireCustomer(), and have every page just call it.
Where it started
The advertiser portal has a mechanism that always routes a customer who signed in with a first-login temp password to the password-change screen. That routing was written in two places, / and /dashboard, each as its own redirect.
// equivalent to the pre-fix code
export async function requireCustomer(): Promise<CurrentUser> {
const user = await requireUser();
if (user.role !== "customer") redirect("/admin");
return user;
}
requireCustomer() itself checked only “logged in and in the customer role”; the temp-password state was decided by the calling code on / and /dashboard individually. Other customer pages — /report, /creatives, /materials, /account — simply called this requireCustomer() (or requireUser(), which only checks login) and carried no temp-password logic.
Why
When an advertiser on a temp password logged in and went straight to /creatives from a bookmark or a shared URL rather than through /dashboard, there was no code there reading the temp password, so the submission screen opened without ever passing through the forced change. The cause is less a missing implementation than a design that put “which page checks the temp password” on the page itself. Every page that ought to hold the check was a hole until someone implemented it there.
There was also a path that a page-side guard alone could not close. submitCreative, the server action that saves a submission, can be invoked directly regardless of whether the calling page redirected.
// equivalent to the pre-fix code (creatives/actions.ts)
import { requireUser } from "@/lib/auth";
// ...
export async function submitCreative(
_prev: SubmitState,
formData: FormData
): Promise<SubmitState> {
const user = await requireUser();
// ...
}
Guarding only what the page displays leaves the form submission itself callable as a separate path, so the same decision had to run on both the screen and the write.
Fixing it
We consolidated the temp-password decision into requireCustomer(), and split the decision itself into customerGuardRedirect, a pure function with no redirect side effect.
export function customerGuardRedirect(
user: Pick<CurrentUser, "role" | "mustChangePassword">,
options: { allowTempPassword?: boolean } = {}
): "/admin" | "/account" | null {
if (user.role !== "customer") return "/admin";
if (!options.allowTempPassword && user.mustChangePassword) return "/account";
return null;
}
export async function requireCustomer(
options: { allowTempPassword?: boolean } = {}
): Promise<CurrentUser> {
const user = await requireUser();
const dest = customerGuardRedirect(user, options);
if (dest) redirect(dest);
return user;
}
Every customer page (account, creatives, materials) was standardised on this requireCustomer() rather than requireUser(). The account screen alone, being where the password is changed, is the exception: it calls requireCustomer({ allowTempPassword: true }) and lets a temp password through. Standardising also closed a separate inconsistency where admin and staff roles could walk through customer pages untouched.
The writes were aligned too.
// after the fix (creatives/actions.ts)
import { requireCustomer } from "@/lib/auth";
// ...
export async function submitCreative(
_prev: SubmitState,
formData: FormData
): Promise<SubmitState> {
// Submission is customers only. On a temp password requireCustomer sends them to /account
const user = await requireCustomer();
// ...
}
Now the page-render path and the direct server-action path both run through the same single decision function. The account screen also gained company_id filtering on its contracts and ad_metrics reads, matching the defence in depth of the other pages.
Preventing a repeat
Because customerGuardRedirect is a pure function with no redirect side effect, every branch and its precedence can be pinned in unit tests.
it("internal role beats the password exception (staff still goes to /admin with allowTempPassword)", () => {
expect(
customerGuardRedirect(
{ role: "staff", mustChangePassword: true },
{ allowTempPassword: true }
)
).toBe("/admin");
});
Four cases are pinned as tests — rejecting internal users, forcing the temp-password change, the allowTempPassword exception, and the precedence (the internal-role check always beats the temp-password exception) — so however many pages get added, calling this function guarantees the same decision.
While there were only four customer pages, writing the redirect per page did not obviously leak. The more pages there are, the more per-page implementation becomes “remember and copy the same check from every existing page each time you make a new one”, and that work will, probabilistically, be skipped somewhere. An authorisation decision is not something to write once per page; it belongs in one place that pages only have to call, however many of them there are.
よくある質問
Q1Why did everything except / and /dashboard skip the forced change?
The redirect sending a must_change_password advertiser to the password screen existed on two pages only, / and /dashboard. The others (/report /creatives /materials /account) called requireUser() for a login check and never read the temp-password flag, so a direct URL skipped it.
Q2Wasn't closing the page redirects enough?
No. submitCreative, the server action that saves a creative, runs regardless of whether the calling page redirected. Fixing only the page guard leaves direct form submissions and other clients open, so the same guard must apply to the writing function itself.
Q3Why does the account screen still allow access on a temp password?
Because changing the temp password happens on the account screen itself. Customers mid-change have to be locked out of every other screen while account stays reachable, so requireCustomer() takes an explicit allowTempPassword option that only the account screen passes.
確認した環境
- Next.js 16.2.7 / React 19.2.4
- Found and fixed in a commit on 2026-06-18
この記事の根拠
- TypeScriptファイル 60〜93行目コミット 225c323
- TypeScriptファイルコミット 225c323
- TypeScriptファイルコミット 225c323
- TypeScriptファイルコミット 225c323
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。