AI가 작성한 Next.js 코드에서 발생하기 쉬운 보안 취약점과 해결 방법
요약
AI가 작성한 Next.js 코드에서 발생하기 쉬운 보안 취약점 9가지 유형을 실제 공격 재현을 통해 분석했습니다. 특히, 단순히 코드를 형태만으로 판단하는 것이 아니라 검증 로직 누락이 주요 원인임을 강조합니다. Server Action이나 Route Handler 등 핵심 기능에 대한 인증 및 권한 확인 로직 추가가 필수적입니다.
핵심 포인트
- Server Action은 호출 주체(사용자)의 세션 기반 업데이트를 강제해야 합니다.
- Route Handler는 삭제나 수정 시 로그인 여부와 소유자 여부를 모두 검증해야 합니다.
- 취약점은 코드 형태보다 '검증 로직 누락'에서 발생하는 경우가 많습니다.
- 공개 Semgrep 규칙으로는 탐지하기 어려운 논리적 취약점이 주를 이룹니다.
AI에게 의존하여 작성된 Next.js 코드는 대부분 작동합니다. 하지만 '작동한다'는 것과 '안전하다'는 것은 별개의 문제입니다.
본문에서는 Next.js (App Router)의 작은 애플리케이션을 대상으로 실제 공격을 재현하고, 수정 후 방어할 수 있는 취약점들을 9가지 유형으로 정리했습니다. 재현된 취약점 중 다수는 이상한 코드가 있어서가 아니라, 반드시 확인해야 할 검증 로직이 누락되어 있었던 경우였습니다. 실제로 실행 가능한 공개 Semgrep 규칙 세트로는 수정 전 코드에서 단 하나도 탐지되지 않았습니다. 따라서 코드를 형태만으로 판단하기 어려운 취약점들이기에, 공격을 테스트 케이스로 작성하여 검증했습니다.
AI에게 의존하여 작성된 코드를 검토할 기회가 늘면서, 자주 발생하는 취약점들을 직접 재현하고 확인해 보았습니다.
검증 조건 및 결과
| 항목 | 내용 |
|---|---|
| 구성 | Next.js 15.5.2 (App Router), React 19.1, TypeScript, SQLite (node:sqlite), Node.js 22.22.0 |
| ... | next start로 구동된 서버에 대한 HTTP 공격, 더미 API 키를 사용한 빌드, npm audit |
테스트는 로컬 복사본과 더미 데이터만을 사용하여 실행했습니다. 공개 서버에서는 테스트하지 마십시오.
1. Server Action은 누구나 직접 호출할 수 있다. 업데이트 대상을 세션으로 결정해야 한다 (A02)
프로필 수정 폼입니다. 수정 대상을 숨겨진 항목인 userId로 보내고, Server Action이 이 값을 이용해 데이터베이스를 업데이트했습니다.
<form action={updateProfile}>
<input type="hidden" name="userId" value={user?.id} />
<input name="name" defaultValue={user?.name} />
...
Bob으로 로그인한 후, userId를 Alice의 ID로 변경하고 role=admin을 추가하여 전송하자, Alice의 이름이 바뀌고 관리자(admin)가 되었습니다. 액션 내부에 로그인 확인 로직이 없기 때문에, 로그인하지 않은 사람도 전송할 수 있는 코드입니다.
AI에게서 발생하기 쉬운 이유: Server Action은 함수처럼 호출되므로 외부에서 호출하는 진입점처럼 보이지 않습니다. 폼 역시 자신의 ID만 보내는 것처럼 보입니다. `formData.get(
처음에 작성한 버전의 테스트는 수정된 코드를 '방어했다'고 판정했습니다. 하지만 실제로는 Next.js 외부에서 cookies()가 예외를 발생시켰을 뿐, 업데이트까지 진행되지 않았던 것뿐이었습니다. 이제는 '예외가 발생하지 않았다'와 'Bob의 이름만 바뀌었다' 두 가지 조건을 모두 만족했을 때만 방어했다고 계산합니다.
next start로 실행한 서버에도 HTTP로 동일한 공격을 보냈습니다. Next.js 15.5에서는 Server Action의 폼 HTML에 $ACTION_ID_…라는 숨겨진 항목이 들어갑니다. 여기에 userId=1과 role=admin을 추가하여 POST합니다. Next.js는 Origin 헤더와 Host 헤더가 다르면 처리하지 않기 때문에, Origin은 페이지와 같은 호스트로 설정했습니다. 결과는 수정 전에는 'Alice가 admin'이었고, 수정 후에는 'Bob의 이름만 바뀌는' 수준이었습니다. 숨겨진 항목의 형태는 내부 메커니즘이므로 버전에 따라 다를 수 있습니다.
2. Route Handler는 '로그인 여부'와 '소유자 여부'를 모두 확인합니다 (A03~A05)
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
...
누구나 글을 삭제할 수 있었습니다 (A03). 초안(draft) 글도 로그인 없이 ID만 지정하면 읽을 수 있었습니다 (A05). 사용자 정보 API는 SELECT *의 결과를 그대로 반환하여, 이메일 주소와 평문 비밀번호가 로그인 없이 노출되었습니다 (A04).
AI로 발생하기 쉬운 이유: '글을 삭제하는 API를 만들어줘'라고 요청하면, 삭제 처리는 작성됩니다. 하지만 '본인의 글만'이라는 조건은 요청 방식에 포함되지 않으면 코드에도 들어가지 않습니다. SELECT *를 그대로 반환하는 것도 동작하게 하는 가장 짧은 방법입니다.
수정 방법: 로그인하지 않았으면 401, 글이 없으면 404, 소유자가 아니면 403을 반환합니다.
const user = getSessionUser(req.cookies.get(SESSION_COOKIE)?.value);
if (!user) {
return NextResponse.json({ error:
검색창에 `x' UNION SELECT id, id, email, password, 1 FROM users --`
을 넣자 검색 결과의 제목란에는 모든 사용자의 이메일 주소가, 본문란에는 비밀번호가 나타났습니다 (A01). `/api/posts/`
의 ID 부분도 마찬가지로 SQL을 넣으면 데이터를 읽어낼 수 있었습니다 (A12).
**AI로 발생하기 쉬운 이유**: 템플릿 리터럴(``...${q}...``)은 TypeScript에서 일반적인 작성 방식이라 외관상 어색함이 없습니다. Next.js 문서의 예시에 있는 `sql`SELECT * FROM user WHERE slug = ${slug}``
은 값을 안전하게 전달할 수 있는 라이브러리 사용법(태그付きテンプレート)입니다. 문자열을 조합하여 `prepare()`에 전달하는 코드와 외관이 매우 유사합니다.
**수정 방법**: 값은 `?`로 별도로 전달합니다. ID는 zod를 사용하여 양의 정수만 받도록 합니다. 또한, `LIKE`의 `%`와 `_`도 이스케이프하여 검색어의 기호를 문자로 취급하게 합니다.
export const IdParam = z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER);
const POST_COLUMNS = "id, author_id, title, body, published";
// Escape LIKE wildcards so that "%" or "_" in the query match literally
function escapeLike(value: string): string {
...
**확인 방법**: 위의 검색어로 `searchPosts`를 호출하여 결과 제목에 `@example.com`이 포함되지 않는지 확인합니다. ID에 `0 UNION SELECT …`을 전달했을 때 응답에 이메일 주소가 포함되지 않는지 확인합니다. 수정된 코드는 ID가 양의 정수가 아니면 400을 반환합니다.
##
`NEXT_PUBLIC_`
를 붙인 값은 브라우저에 배포됩니다(빌드 시 확인).
4. ```
"use client";
// ...
const res = await fetch("https://api.llm.example.com/v1/generate", {
...
기사를 AI로 요약하는 버튼입니다. 브라우저에서 직접 AI의 API를 호출하기 때문에, API 키에 NEXT_PUBLIC_를 붙였습니다. Next.js는 NEXT_PUBLIC_를 붙인 환경 변수를 빌드 시점에 브라우저용 JavaScript에 값 자체로 내장합니다. 더미 키 sk-demo-LEAK-CHECK-123으로 빌드하자, .next/static/chunks/app/posts/[id]/page-*.js 파일에 키가 그대로 들어 있었습니다. 사이트를 본 사람이라면 누구나 추출할 수 있습니다.
AI로 발생하기 쉬운 이유: NEXT_PUBLIC_가 없는 환경 변수는 서버에서만 읽을 수 있습니다. Client Component에서 process.env.LLM_API_KEY를 사용하면 브라우저에서는 값이 들어가지 않습니다. 이 'undefined가 되는 것'을 수정하려다 보니, NEXT_PUBLIC_를 붙이는 것이 가장 빠르고 쉽게 보입니다.
수정 방법: 키는 서버에서만 읽는 LLM_API_KEY로 변경하고, AI API는 서버의 루트(POST /api/posts/[id]/summary)에서 호출합니다. 브라우저는 그 루트만 호출하면 됩니다. 비용이 임의로 사용되지 않도록 로그인한 사람만 사용할 수 있게 하고, 타임아웃도 설정했습니다.
const apiKey = process.env.LLM_API_KEY;
// ...
const res = await fetch("https://api.llm.example.com/v1/generate", {
...
공개했던 것이라면, 변수명만 바꾸는 것만으로는 부족합니다. 배포된 키는 무효화하고 새로 발급해야 합니다.
확인 방법: 더미 값으로 빌드하여 브라우저용 파일을 검색해 봅니다. 수정된 코드(LLM_API_KEY에 동일한 값을 넣고 빌드)에서는 찾을 수 없었습니다.
5. 記事の本文もAIの出力も、HTMLとして表示しない(A06)
<div dangerouslySetInnerHTML={{ __html: post.body }} />
<div dangerouslySetInnerHTML={{ __html: summary }} />
본문에 <img src=x onerror="alert(document.cookie)">를 저장하면, 기사 페이지의 HTML에 그대로 출력되었습니다. 브라우저에서 열면 스크립트가 작동하는 형태입니다 (저장형 XSS). 테스트에서는, HTML에 그대로 출력되는 지점까지를 확인하고 있습니다.
AI로 발생하기 쉬운 이유: '본문을 리치 텍스트로 표시해 주세요'라는 요청에 대한 가장 짧은 답변이 dangerouslySetInnerHTML입니다. AI의 요약은 자신의 서비스가 만든 문자열처럼 보입니다. 그럼에도 불구하고, 입력된 기사 내용에 이끌려 HTML을 포함하는 경우가 있으므로, 외부에서 온 문자열로 취급해야 합니다.
수정 방법: 텍스트로 표시합니다. React가 < 등을 문자로 그대로 표시하는 처리(이스케이프)를 자동으로 수행합니다.
<div style={{ whiteSpace: "pre-wrap" }}>{post.body}</div>
<p style={{ whiteSpace: "pre-wrap" }}>{summary}</p>
이 수정으로 인해 굵게나 링크 등의 서식은 사라집니다. 서식을 유지하려면, 서버 측에서 허용된 태그만 남기거나 (OWASP는 DOMPurify를 권장), Markdown을 사용해 원본 HTML을 허용하지 않는 방식으로 처리합니다.
확인 방법: react-dom/server의 renderToStaticMarkup으로 페이지를 문자열로 만들고, <img src=x onerror=가 그대로 포함되지 않음을 확인합니다.
6. 받은 URL은, 허용된 호스트만 가져간다(A07)
const url = new URL(req.url).searchParams.get("url");
// ...
const res = await fetch(url);
외부 이미지를 자신의 도메인에서 표시하기 위한 중계(이미지 프록시)입니다. ?url=http://127.0.0.1:…/를 전달하면, 서버가 내부 주소에 연결하고 그 응답을 그대로 반환했습니다. 서버에 내부 목적지로 요청을 보내게 하는 이 공격을 SSRF라고 부릅니다. Railway의 경우, 프라이빗 네트워크(*.railway.internal)에 있는 데이터베이스나 관리 화면이 목표가 됩니다.
AI로 발생하기 쉬운 이유: '외부 이미지를 표시하고 싶다'는 요청에 대한 가장 짧은 코드가 이것입니다. 어디에 연결해도 좋다는 조건은, 요청 방식에서 나오지 않습니다.
수정 방법: https를 사용하고, 허용된 호스트만 받습니다. 리디렉트는 추적하지 않습니다. 허용된 호스트에서 내부 주소로 넘어가는 것을 방지하기 위함입니다.
const allowedHosts = parseHostList(process.env.IMAGE_PROXY_ALLOWED_HOSTS);
// ...
const url = parseAllowedImageUrl(new URL(req.url).searchParams.get("url"), allowedHosts);
...
parseAllowedImageUrl는 다음 조건을 충족하지 않는 URL을 null로 만듭니다.
if (url.protocol !== "https:") return null;
if (url.username !== "" || url.password !== "") return null;
if (url.port !== "") return null;
...
더불어, 이미지의 종류(SVG 제외)와 크기(10MB까지)를 확인하고, X-Content-Type-Options: nosniff를 붙여 반환합니다.
확인 방법: 테스트 내에서 127.0.0.1
작은 HTTP 서버를 띄워 INTERNAL-SECRET이라는 문자를 반환하게 합니다. 이미지 프록시를 통해 이 문자열을 가져올 수 없다면, 방어된 것입니다.
7. 저장하는 파일명은 서버가 결정하도록 한다 (A08)
const filePath = path.join(process.cwd(), "public", "uploads", file.name);
await writeFile(filePath, bytes);
파일명에 ../../pwned.html을 지정하자, public/uploads 바깥(앱 폴더 바로 아래)에 파일을 쓸 수 있었습니다. 로그인도 필요 없고 HTML 파일도 배치할 수 있었습니다.
AI가 취약점을 일으키기 쉬운 이유: 전달받은 파일명을 그대로 사용하는 것이 가장 직관적인 코드입니다. path.join은 ..을 해결하여 경로를 조합하기 때문에, 폴더 바깥을 가리킬 수 있게 됩니다.
수정 방법: 로그인을 필수화하고, 파일명은 서버가 UUID로 생성하도록 합니다. 종류는 파일명이 아니라, checkUpload가 시작 바이트로 판별하며 PNG・JPEG・WebP의 5MB까지 제한했습니다. flag: "wx"를 사용하여 기존 파일은 덮어쓰지 않습니다.
const check = checkUpload(bytes);
if (!check.ok)
return NextResponse.json({ error: check.error }, { status: check.status });
...
다른 문제도 발견했습니다. next start로 실행하면, 저장된 파일은 디스크에 존재하지만, 반환된 URL을 열면 404 에러가 발생했습니다 (수정 전에도 동일). Railway 문서에 따르면, 배포를 넘어서 유지하고 싶은 데이터에는 볼륨(volume)이 필요합니다. 볼륨 외부에 작성된 파일은 배포할 때마다 사라진다고 생각해야 합니다. 저장 위치는 오브젝트 스토리지나 볼륨으로 옮길 필요가 있으며, 이 샘플에서는 미지원입니다.
확인 방법: new File([...], "../../pwned.html")을 보내어 앱 폴더 바로 아래에 pwned.html이 생성되지 않음을 확인합니다.
8. CORS・로그인 후 리다이렉트 대상・쿠키는 '허용된 것만'으로 한다 (A09〜A11)
세 가지 모두 설정 작성 방식의 문제입니다.
const origin = req.headers.get("origin") ?? "*";
res.headers.set("Access-Control-Allow-Origin", origin);
res.headers.set("Access-Control-Allow-Credentials", "true");
const token = Math.random().toString(36).slice(2);
// ...
const res = NextResponse.redirect(new URL(next || "/dashboard", req.url), 303);
...
CORS (A09)는 요청의 Origin을 그대로 반환하고, 쿠키가 포함된 호출도 허용했습니다. 주석에는 'Allow the frontend (and any other client) to call our API'라고 되어 있었습니다. CORS 에러를 없애는 빠른 방법으로 이렇게 모든 것을 허용하는 작성이 있습니다.
다만 악용에는 조건이 있습니다. SameSite 지정이 없는 쿠키를 Lax로 취급하는 브라우저에서는, 다른 사이트에서의 fetch에 쿠키가 포함되지 않습니다. MDN에 따르면, 그렇게 처리하는 것은 일부 브라우저입니다. 같은 사이트의 다른 서브도메인에서의 호출에는 이 제한이 적용되지 않습니다. 자신의 페이지에서 오는 호출에는 CORS 설정이 필요 없으므로 제거하고, 필요한 Origin만 환경 변수로 허용했습니다.
로그인 후 리다이렉트 대상 (A10)은 next에 https://evil.example/phish를 넣자 외부 사이트로 이동했습니다. new URL()은 첫 번째 인자가 절대 URL이면 두 번째 인자를 사용하지 않기 때문입니다. 가짜 로그인 화면으로 유도하는 피싱에 사용됩니다. 같은 사이트 내의 경로만 허용하고, //host, /\host, 제어 문자가 포함된 것은 /dashboard로 처리했습니다.
으로 되돌립니다.
Cookie (A11)는 HttpOnly, Secure, SameSite 속성이 없고 유효 기간은 1년이었습니다. 토큰은 Math.random()
으로 만들고 있었습니다. MDN에서는 Math.random()을 안전하게 사용되는 용도로 쓰지 않도록 작성하고 있습니다. OWASP는 세션 ID를 암호용 난수 생성기(CSPRNG)로 만들고 64비트 이상의 엔트로피를 갖도록 요구합니다.
const token = newSessionToken();
// ...
const res = NextResponse.redirect(new URL(safeRedirectPath(next), req.url), 303);
...
export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
/** 256-bit random token from a CSPRNG (Math.random() is predictable). */
export function newSessionToken(): string {
...
확인 방법: middleware에 origin: https://evil.example의 요청을 전달하여, Access-Control-Allow-Origin이 반환되지 않는지 확인합니다. next=https://evil.example/phish로 로그인하고, Location이 외부를 가리키지 않는지 확인합니다. Set-Cookie에 HttpOnly와 SameSite가 있는지 확인합니다.
9. AI가 작성한 버전 번호는 그대로 사용하지 않기 (npm audit)
package.json에는 `
참고로, 코드를 읽은 후에 이 앱 전용의 Semgrep 규칙을 12개 작성했습니다. 수정 전에는 17군데에서 발견되었고, 수정 후에는 0곳이었습니다. 답을 알고서 만든 규칙이라 탐지 성능을 보여주는 수치는 아닙니다. 공개 규칙의 0건도, 전용 규칙의 수정 후 0건도 '규칙에 해당하는 것이 없었다'는 의미일 뿐입니다. 안전성을 증명하는 것은 아닙니다.
테스트는 일부러 망가뜨려서 효과를 확인한다
수정 후, 의도적으로 코드를 망가뜨린 코드 6가지 버전을 만들어 테스트가 실패하는지 확인했습니다(변이 테스트).
- Server Action에서 세션은 확인하지만, 업데이트는 폼의
userId와role로 수행한다. - 검색 SQL에 검색어를 직접 삽입한다.
- 삭제 시 생성자를 확인하지 않는다.
- 사용자 API가 모든 항목을 반환한다.
- 미등록된 이메일 주소일 때 비밀번호 계산을 건너뛴다(응답 시간으로 계정 유무를 알 수 있다).
- 이메일 주소를 대소문자를 구분하여 비교한다.
6가지 경우 모두 테스트가 실패하면서 문제점을 발견할 수 있었습니다. 첫 번째 경우가 특히 중요해서, 로그인 확인만 추가하는 것만으로는 해결되지 않았습니다. 테스트가 통과하는 것보다, 망가뜨렸을 때 무너지는 것이 테스트의 효과를 보여줍니다.
AI에게 부탁할 때 덧붙일 조건
요청 방식에 조건을 추가하면 지금까지의 허점은 줄일 수 있다고 생각합니다. 하지만 확실하게 검증하지는 않았으므로, 나온 코드는 위와 같은 테스트로 확인해 주십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기