Next.js Server Actions는 use-server 파일에서 export된 모든 함수를 공개 HTTP 엔드포인트로 자동 노출하며, 클라이언트가 해당 함수를 호출할 때 사용하는 고유 식별자를 할당합니다. 버튼, 링크 또는 기타 UI 요소의 렌더링 여부와 관계없이 엔드포인트는 존재하므로, 식별자를 알아낸 사람은 누구나 함수를 직접 호출할 수 있습니다.
프레임워크의 설계는 Server Actions를 일반적인 헬퍼 함수처럼 취급하지만, 런타임 시에는 접근 가능한 URL이 됩니다. UI가 유일한 관문이라고 생각했던 개발자들에게 이는 단 한 번의 조작된 요청만으로도 악용될 수 있는 보이지 않는 공격 표면(attack surface)을 생성합니다.
Server Actions가 안전해 보였던 이유
Server Actions는 개발자가 별도의 API 라우트를 만드는 번거로움(boilerplate)을 피하고 컴포넌트 바로 옆에서 서버 측 코드를 작성할 수 있도록 도입되었습니다. 일반적인 사용 사례는 다음과 같습니다:
<form action={deleteInvoice}>
<button type="submit">Delete</button>
</form>
폼(form)이 deleteInvoice를 트리거하는 유일하게 보이는 방법이기 때문에, 많은 개발자가 권한이 없는 사용자를 위해 버튼을 숨기거나, TypeScript 시그니처가 잘못된 데이터를 막아줄 것이라 믿거나, 해당 함수가 서버 전용 모듈에 존재한다는 사실에 의존합니다. 하지만 이러한 가정 중 그 어느 것도 실질적인 보호를 제공하지 않습니다.
숨겨진 노출
파일에 “use server”가 포함되어 있으면, Next.js는 각 export된 함수를 다음과 같은 엔드포인트로 컴파일합니다:
POST /_next/data/<build-id>/<page>.json?__rsc=<action-id>
<action-id>는 클라이언트 번들에 포함되는 안정적인 해시 값입니다. 공격자는 다음과 같은 방법으로 이를 획득할 수 있습니다:
- 페이지의 네트워크 트래픽 조사.
- 번들링된 JavaScript 읽기 (ID는 일반 문자열임).
- 프로젝트가 예측 가능한 패턴을 따르는 경우 명명 규칙을 기반으로 추측.
ID를 알게 되면 cURL, Postman 또는 악성 스크립트와 같은 어떤 도구를 통해서도 요청을 보낼 수 있으며, 이는 모든 UI 수준의 체크를 우회합니다.
세 가지 구체적인 위험
| 위험 | 중요한 이유 |
|---|---|
| UI 체크는 효과가 없음 | 버튼이나 링크를 숨긴다고 해서 기반이 되는 엔드포인트가 삭제되는 것은 아닙니다. 숨겨진 관리자 페이지가 서버에 여전히 존재하는 것과 마찬가지로 엔드포인트는 여전히 접근 가능합니다. |
| TypeScript는 런타임 안전성을 제공하지 않음 | 코드가 실행될 때 타입은 제거됩니다. deleteInvoice(id: number)로 선언된 함수는 거대한 문자열, 배열 또는 악성 JSON을 받을 수 있으며, 이는 로직 오류나 인젝션 공격으로 이어질 수 있습니다. |
| 기본 인증 또는 인가 기능 부재 | Server Actions는 로컬 헬퍼 함수처럼 보이기 때문에, 개발자들은 전통적인 API 라우트에서 표준적으로 사용하는 세션 체크, CSRF 보호 또는 행 수준(row-level) 권한 체크를 추가하는 것을 잊곤 합니다. |
Server Action을 보호하는 방법
- 호출자 인증 – 비즈니스 로직이 실행되기 전에 유효한 세션 또는 토큰이 존재하는지 확인합니다.
- 페이로드 검증 – 스키마 라이브러리(예: Zod, Yup)를 사용하여 런타임에 데이터 타입과 값 제약 조건을 강제합니다.
- 작업 인가 – "사용자가 로그인했는가?"를 넘어, 사용자가 수정하거나 삭제하려는 특정 레코드의 소유자인지 확인합니다.
최소한의 예시:
'use server';
import { getSession } from '@/auth';
import { z } from 'zod';
import { db } from '@/db';
const DeleteInvoiceSchema = z.object({
id: z.number().int().positive(),
});
export async function deleteInvoice(formData: FormData) {
const session = await getSession();
if (!session) throw new Error('Unauthenticated');
const parsed = DeleteInvoiceSchema.safeParse({
id: Number(formData.get('id')),
});
if (!parsed.success) throw new Error('Invalid input');
const invoice = await db.invoice.findUnique({ where: { id: parsed.data.id } });
if (!invoice || invoice.ownerId !== session.userId) {
throw new Error('Unauthorized');
}
await db.invoice.delete({ where: { id: invoice.id } });
}
이 코드는 인증을 명시적으로 확인하고, 들어오는 id를 검증하며, 삭제를 수행하기 전에 로그인한 사용자가 실제로 해당 인보이스의 소유자인지 확인합니다.
기타 미묘한 Next.js 함정들
| 문제 | 증상 | 해결책 |
|---|---|---|
NEXT_PUBLIC_ 환경 변수 |
NEXT_PUBLIC_ 접두사가 붙은 모든 것은 클라이언트에 번들링되어 비밀 정보를 노출합니다. |
비밀 정보는 일반 환경 변수에 보관하고, 절대 NEXT_PUBLIC_을 접두사로 사용하지 마세요. |
| 오픈 리다이렉트 | redirect 쿼리 매개변수를 받아 그대로 연결하면 사용자를 //evil.com으로 보낼 수 있습니다. |
화이트리스트를 통해 대상 URL을 검증하거나 동일 출처(same-origin) 체크를 강제하세요. |
| 서버 측 요청 위조 (SSRF) | 사용자가 제공한 URL을 가져오면 공격자가 내부 서비스나 클라우드 메타데이터 엔드포인트에 접근할 수 있습니다. | 호스트네임을 허용 목록(allow-list)에 추가하고, 사설 IP 범위를 차단하며, 타임아웃을 설정하세요. |
dangerouslySetInnerHTML |
정화(sanitisation) 과정 없이 사용자가 제공한 HTML을 렌더링하면 XSS가 발생합니다. | DOMPurify와 같은 라이브러리를 사용하거나 원시 HTML 사용을 아예 피하세요. |
이러한 패턴을 자동으로 스캔하기
정적 분석 도구를 사용하면 위에 나열된 위험한 구조를 찾아낼 수 있습니다. 가볍고 빠르게 실행되며 CI 파이프라인에 통합할 수 있는 옵션 중 하나는 Semgrep입니다:
npx --yes semgrep --config https://raw.githubusercontent.com/catidegla/stacksec/main/rules .
이 규칙 세트에는 export된 서버 함수, NEXT_PUBLIC_ 오용, 오픈 리다이렉트, SSRF 패턴 및 안전하지 않은 HTML 삽입에 대한 체크가 포함되어 있습니다.
다음에 살펴볼 내용
- 프레임워크 업데이트 – Next.js 릴리스를 주의 깊게 살펴보세요. 팀에서 내장 인증 훅(authentication hooks)을 도입하거나 생성된 엔드포인트를 샌드박스화할 수도 있습니다.
- 커뮤니티 도구 – 여기서 설명한 보안 조치들을 자동화하기 위해 새로운 ESLint 플러그인과 Next.js 전용 Semgrep 규칙이 등장하고 있습니다.
- 실제 사례 – 더 많은 프로젝트가 Server Actions를 채택함에 따라, 실제 위험을 보여주는 공개된 익스플로잇(exploit) 사례를 주의 깊게 살펴보세요. 조기 발견을 통해 침해 사고가 발생하기 전 내부 보안 검토에 반영할 수 있습니다.
핵심 요약: Next.js Server Action은 프라이빗 헬퍼가 아닙니다. export하는 순간 공개적인 HTTP 엔드포인트가 됩니다. 다른 API 라우트와 마찬가지로 인증(authenticate), 검증(validate), 권한 부여(authorize)를 수행하세요. 그렇지 않으면 UI 옆에 서버 코드를 작성하는 편리함이 순식간에 보안상의 위험 요소가 될 수 있습니다.
