김정현Frontend Focused Fullstack Developer
🧑💻 Frontend Focused FullStack Developer | React & Next.js enthusiast :: 프론트엔드 집중 풀스택 개발자, React와 Next.js를 좋아합니다.
블로그 짧은 주소에서 admin으로 시작하는 글이 로그인 화면으로 이동했다. 관리 경로를 startsWith("/admin")으로 판정한 세 곳을 경로 세그먼트 단위 비교로 바꿨다.
요약 — 블로그 주소에서 admin으로 시작하는 글(예: blog.hyeoniverse.com/admin-two-factor-totp)을 열면 관리자 로그인 화면으로 이동했다. 관리 경로를 pathname.startsWith("/admin")로 판정해 글 주소까지 관리 경로로 분류했기 때문이다. 판정을 경로 세그먼트 단위(/admin 또는 /admin/…)로 바꿔 #1563에서 고쳤다.
이 글은 hyeoniverse 개발기 시리즈의 두 번째 글이다. 첫 번째 글 「Supabase 관리자 로그인에 TOTP 2단계 인증 강제하기」를 발행한 뒤, 블로그 목록에서 그 글을 누르면 본문 대신 관리자 로그인 화면이 열렸다. 원인과 수정 내용을 정리한다.
운영 서버에 같은 글을 두 주소로 요청한 결과는 다음과 같다.
| 요청 주소 | 응답 |
|---|---|
https://hyeoniverse.com/posts/admin-two-factor-totp | 200, 글 본문 |
https://blog.hyeoniverse.com/admin-two-factor-totp | 307, Location: /admin/login |
메인 주소에서는 글이 정상으로 열리고, 블로그 주소에서만 로그인 화면으로 이동했다. 블로그 목록의 링크는 운영에서 블로그 주소로 그려지므로[1], 방문자는 이 글을 읽을 방법이 없었다.
이 사이트의 블로그는 별도 앱이 아니다. 같은 Next.js 앱의 /posts 아래 화면을 blog.hyeoniverse.com에서도 보여 준다. 블로그 주소는 /posts를 뗀 짧은 주소를 쓰고, proxy[2]가 요청 경로를 앱 경로로 rewrite한다.
| 블로그 주소 경로 | 앱 경로 | 처리 |
|---|---|---|
/ | /posts | rewrite |
/<글 주소> | /posts/<글 주소> | rewrite |
/api/…, /admin…, /auth/… | 그대로 | 통과 |
/works, /about 등 | 메인 주소 | 308 리다이렉트 |
API, 관리 화면, 로그인 콜백은 블로그 주소에서도 앱 경로 그대로 열어야 한다. 이 판정을 passThroughOnBlogHost()가 맡는다. 문제는 세 번째 행의 /admin…이다. 글 주소가 경로 맨 앞에 오기 때문에, 글 주소와 관리 경로가 같은 이름 공간을 공유한다.
관리 경로 판정은 세 곳에 있었고, 모두 문자열 접두어로 비교했다.
// src/lib/siteHosts.ts
export function passThroughOnBlogHost(pathname: string): boolean {
return pathname.startsWith("/api/") || pathname.startsWith("/admin") || pathname.startsWith("/auth/");
}
// src/proxy.ts
function isAdminPath(pathname: string): boolean {
return pathname.startsWith("/admin") || pathname.startsWith("/api/admin");
}
// src/components/layout/navSection.ts
if (pathname.startsWith("/admin")) return "admin";
"/admin-two-factor-totp".startsWith("/admin")은 true다. /api/와 /auth/는 끝에 슬래시가 있어 세그먼트 경계까지 비교하지만, /admin과 /api/admin은 슬래시가 없어 /administrator, /admin-two-factor-totp, /api/admins까지 모두 일치한다.
블로그 주소로 들어온 요청은 다음 순서로 처리됐다.
flowchart TD
req["blog.hyeoniverse.com/admin-two-factor-totp"] --> pass{"passThroughOnBlogHost<br/>startsWith('/admin')"}
pass -->|"true (잘못된 판정)"| keep["rewrite 하지 않음<br/>앱 경로 /admin-two-factor-totp"]
pass -.->|"false (의도)"| rw["/posts/admin-two-factor-totp 로 rewrite"]
keep --> guard{"isAdminPath<br/>startsWith('/admin')"}
guard -->|"true · 로그인 안 됨"| login["307 → /admin/login"]
guard -->|"true · 관리자 로그인 상태"| nf["/admin-two-factor-totp<br/>해당 화면 없음 → 404"]
rw --> post["글 본문 200"]
passThroughOnBlogHost()가 이 경로를 관리 경로로 보고 rewrite를 건너뛴다. 앱 경로가 /posts/admin-two-factor-totp가 아니라 /admin-two-factor-totp로 남는다.isAdminPath()도 관리 경로로 보고 세션을 확인한다. 방문자는 로그인하지 않았으므로 /admin/login으로 리다이렉트된다./admin-two-factor-totp 화면이 없으므로 404가 된다.메인 주소에서는 글 경로가 항상 /posts/로 시작하므로 이 검사에 걸리지 않았다. 접두어 검사 자체는 middleware 시절부터 있었지만, 블로그 짧은 주소가 생기면서 글 주소가 경로 맨 앞에 오게 되었고, 슬러그가 admin으로 시작하는 첫 글을 발행하면서 문제가 드러났다.
proxy 설정의 matcher에도 관리 경로가 있다.
export const config = {
matcher: [
"/admin/:path*",
"/api/:path*",
// 블로그 서브도메인 — 정적 파일을 뺀 모든 화면
{ source: "/((?!_next/|.*\\.[a-zA-Z0-9]+$).*)", has: [{ type: "host", value: "blog.hyeoniverse.com" }] },
],
};
/admin/:path*는 경로 패턴이라 /admin과 /admin/…에만 일치하고 /admin-two-factor-totp에는 일치하지 않는다[3]. 메인 주소에서는 matcher가 먼저 걸러 주기 때문에 proxy 안의 접두어 검사가 틀려도 결과가 같았다. 블로그 주소는 정적 파일을 뺀 모든 경로가 matcher를 통과하므로, proxy 안의 검사가 그대로 결과를 결정했다.
URL 경로는 /로 나뉜 세그먼트의 연속이다[4]. 관리 경로는 첫 세그먼트가 정확히 admin인 경로이므로, 그 조건을 그대로 함수로 옮겼다.
// src/lib/siteHosts.ts
/** 관리 화면 경로인가 — "/admin" 이거나 "/admin/…" */
export function isAdminRoute(pathname: string): boolean {
return pathname === "/admin" || pathname.startsWith("/admin/");
}
/** 관리 API 경로인가 — "/api/admin" 이거나 "/api/admin/…" */
export function isAdminApiRoute(pathname: string): boolean {
return pathname === "/api/admin" || pathname.startsWith("/api/admin/");
}
passThroughOnBlogHost(), proxy의 isAdminPath() · isAdminApi(), navSectionOf()가 모두 이 두 함수를 쓰도록 바꿨다. 같은 파일의 포트폴리오 경로 판정(p === pathname || pathname.startsWith(`${p}/`))은 처음부터 이 방식이었다.
| 경로 | 이전 | 이후 |
|---|---|---|
/admin | 관리 | 관리 |
/admin/posts | 관리 | 관리 |
/admin-two-factor-totp | 관리 | 글 |
/administrator | 관리 | 글 |
/api/admin/auth | 관리 API | 관리 API |
/api/admins | 관리 API | 일반 API |
admin으로 시작하는 슬러그를 금지하는 방법도 있다. 하지만 이 방법은 판정 함수의 결함을 그대로 두고 입력을 제한한다. administrator나 admins처럼 의미가 다른 단어까지 막아야 하고, 나중에 다른 접두어 검사가 추가되면 예약어 목록도 따라 늘어난다. 판정을 세그먼트 단위로 고치면 실제로 충돌하는 이름은 admin 하나뿐이고, 이 이름은 관리 화면 경로와 겹치므로 글 주소로 쓸 수 없는 것이 맞다.
판정 함수 단위 테스트와 proxy 테스트를 추가했다. proxy 테스트는 블로그 주소의 Host 헤더로 요청을 만들고, 로그인 리다이렉트 대신 /posts/admin-two-factor-totp로 rewrite되는지 확인한다.
// src/__tests__/adminProxy.test.ts
it("블로그 주소의 /admin-two-factor-totp 는 로그인으로 보내지 않고 글로 rewrite 한다", async () => {
const res = await proxy(new NextRequest("https://blog.hyeoniverse.com/admin-two-factor-totp", {
headers: { host: "blog.hyeoniverse.com" },
}));
expect(res.headers.get("location") ?? "").not.toContain("/admin/login");
expect(res.headers.get("x-middleware-rewrite")).toContain("/posts/admin-two-factor-totp");
});
로컬 개발 서버에서도 Host 헤더를 블로그 주소로 바꿔 요청했다.
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Host: blog.hyeoniverse.com" localhost:3000/admin-two-factor-totp
200
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -H "Host: blog.hyeoniverse.com" localhost:3000/admin/posts
307 http://blog.hyeoniverse.com/admin/login
글은 열리고, 실제 관리 화면은 여전히 로그인을 요구한다.
p === pathname || pathname.startsWith(p + "/") 형태로 세그먼트 단위로 비교한다.lib/siteHosts의 함수 두 개로 모았다.NEXT_PUBLIC_BLOG_LINKS=1로 켜진다.:path*는 0개 이상의 경로 세그먼트를 뜻한다./로 구분한 세그먼트로 이루어진다.