본문 바로가기
C.W.K.
Stream
Lesson 08 of 08 · published

서버 액션의 보안 경계

~22 min · security, CSRF, auth, closures

Level 0호기심
0 XP0/68 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

프레임워크가 기본 방어를 제공해

  • Origin 헤더 검증과 빌드마다 암호화되는 액션 ID로 CSRF를 방어해.
  • 쓰이지 않는 서버 액션은 빌드 결과에서 제거해.
  • 액션은 POST로만 호출되어 GET 주소로 노출되지 않아.
  • 인라인 액션이 캡처한 값은 클라이언트에 참조가 전달되기 전에 암호화돼.

인증과 권한은 여전히 앱의 책임이야

위험필요한 방어
신뢰할 수 없는 입력모든 필드를 서버에서 Zod 같은 schema로 검증해.
다른 사용자의 데이터 변경세션을 읽고 쓰기 직전에 자원 소유권을 확인해.
반복 호출 남용IP나 사용자 단위 rate limit을 둬.
closure 안의 민감한 값비밀을 직접 캡처하지 말고 식별자만 받은 뒤 서버에서 다시 읽어.

암호화는 권한 부여가 아니야

액션 ID나 묶인 값이 암호화됐다는 사실은 현재 사용자가 그 작업을 해도 된다는 증거가 아니야. 모든 액션을 인터넷에서 직접 도달 가능한 POST 엔드포인트라고 생각하고 검증·인증·권한·rate limit을 적용해.

각 액션을 공개 POST처럼 threat model 해. 로그인하지 않은 요청, 다른 사용자의 자원 ID, 너무 큰 입력, 빠른 반복 요청, 오래된 액션 참조를 실제로 보내 보고 가장 깊은 경계가 거부하는지 확인해. 감사 로그에는 사용자·자원·결과를 남기되 비밀과 원문 개인정보는 빼.

프레임워크가 제공하는 CSRF와 암호화는 앱 정책을 알지 못해. 올바른 Origin에서 온 로그인 사용자도 다른 조직의 데이터를 건드릴 수 있고, 정상 UI도 자동화로 남용될 수 있어. 기본 방어 위에 도메인 권한과 사용량 제한을 얹어야 닫힌 경계가 돼. 같은 액션을 정상 UI, 직접 만든 POST, 권한이 다른 세션에서 각각 호출해. 화면에서 버튼을 숨긴 것과 서버가 변경을 거부하는 것은 서로 다른 보안 증거야. 정상 UI를 거치지 않고 액션 POST를 재현해 Origin, 인증, 다른 사용자 ID, rate limit, 오래된 액션 참조를 차례로 시험해. 로그에는 거부 이유와 요청 ID가 남되 비밀 closure와 폼 원문이 노출되지 않는지 확인하고, 같은 요청 재전송의 결과도 봐. 보안 시험은 한 번의 수동 확인으로 끝내지 말고 위험한 액션마다 회귀 테스트로 남겨. Next.js나 인증 라이브러리를 올린 뒤에도 같은 우회 요청이 계속 거부되어야 기본 방어와 앱 정책의 결합을 믿을 수 있어.

Code

항상 인증하고 항상 검증하기·ts
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

const Schema = z.object({ postId: z.string().uuid() });

export async function deletePost(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  const parsed = Schema.safeParse({ postId: formData.get('postId') });
  if (!parsed.success) throw new Error('Bad input');

  const post = await db.post.findUnique({ where: { id: parsed.data.postId } });
  if (post?.authorId !== session.user.id) throw new Error('Forbidden');

  await db.post.delete({ where: { id: parsed.data.postId } });
  revalidatePath('/posts');
}
암호화된 closure와 식별자 사용·tsx
// 비밀값을 캡처하는 인라인 액션 — 암호화되지만 다시 읽는 편이 더 깔끔해
export default async function Page() {
  const apiKey = await getApiKey(); // 서버 전용

  async function callExternal(formData: FormData) {
    'use server';
    const value = formData.get('value') as string;
    await fetch('https://external/api', {
      method: 'POST',
      headers: { Authorization: `Bearer ${apiKey}` }, // 캡처 후 암호화
      body: JSON.stringify({ value }),
    });
  }
  return <form action={callExternal}>{/* … */}</form>;
}

External links

Exercise

서버 액션 하나를 입력, 인증, 소유권, rate limit 네 위험으로 점검해. 각 우회 요청을 재현하고 거부 결과와 감사 로그를 남겨.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.