127 lines
4.1 KiB
Markdown
127 lines
4.1 KiB
Markdown
---
|
|
id: security-input-validation
|
|
title: 입력 검증 — 경계에서 정규화 + 도메인 타입화
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [security, validation, zod, vibe-coding]
|
|
tech_stack: { language: "TypeScript / zod", applicable_to: ["Backend", "Web"] }
|
|
applied_in: []
|
|
aliases: [zod, schema validation, parse don't validate, sanitize]
|
|
---
|
|
|
|
# 입력 검증
|
|
|
|
> "Parse, don't validate". 경계에서 **schema → 도메인 타입** 으로 변환. 이후 코드는 안전 가정. 클라이언트 검증은 UX 만, 서버에서 항상 재검증.
|
|
|
|
## 📖 핵심 개념
|
|
- 모든 외부 입력 = 적. (HTTP body, query, headers, env, file, 다른 서비스 응답)
|
|
- 검증 = "조건 통과"가 아니라 "정확한 타입으로 변환".
|
|
- 검증 후 타입 = brand 또는 nominal.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### Zod schema (Express)
|
|
```ts
|
|
import { z } from 'zod';
|
|
|
|
const SignupSchema = z.object({
|
|
email: z.string().email().toLowerCase().max(254),
|
|
password: z.string().min(8).max(128),
|
|
age: z.coerce.number().int().min(13).max(120).optional(),
|
|
inviteCode: z.string().regex(/^[A-Z0-9]{6,8}$/).optional(),
|
|
}).strict(); // 추가 키 거부
|
|
|
|
type Signup = z.infer<typeof SignupSchema>;
|
|
|
|
app.post('/api/signup', async (req, res) => {
|
|
const parsed = SignupSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return res.status(422).json({ error: 'validation', issues: parsed.error.flatten() });
|
|
}
|
|
const data: Signup = parsed.data; // 타입 안전, 정규화 됨
|
|
await createUser(data);
|
|
res.status(201).end();
|
|
});
|
|
```
|
|
|
|
### Query / params 검증
|
|
```ts
|
|
const ListUsersQ = z.object({
|
|
page: z.coerce.number().int().positive().default(1),
|
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
|
q: z.string().max(200).optional(),
|
|
});
|
|
|
|
app.get('/api/users', (req, res) => {
|
|
const q = ListUsersQ.parse(req.query);
|
|
// q.page is number now, not string
|
|
});
|
|
```
|
|
|
|
### Env 검증 — 부팅 시
|
|
```ts
|
|
const Env = z.object({
|
|
DATABASE_URL: z.string().url(),
|
|
JWT_SECRET: z.string().min(32),
|
|
NODE_ENV: z.enum(['development', 'production', 'test']),
|
|
PORT: z.coerce.number().default(3000),
|
|
});
|
|
export const env = Env.parse(process.env); // 부팅 시 fail-fast
|
|
```
|
|
|
|
### 외부 API 응답 — 신뢰하지 마라
|
|
```ts
|
|
const StripeCustomer = z.object({
|
|
id: z.string().startsWith('cus_'),
|
|
email: z.string().email().nullable(),
|
|
metadata: z.record(z.string()).default({}),
|
|
});
|
|
|
|
const customer = StripeCustomer.parse(await stripe.customers.retrieve(id));
|
|
// 외부 API 가 형식 바뀌면 즉시 알림
|
|
```
|
|
|
|
### Brand 결합
|
|
```ts
|
|
const Email = z.string().email().toLowerCase().brand<'Email'>();
|
|
type Email = z.infer<typeof Email>;
|
|
|
|
function send(to: Email, body: string) { ... }
|
|
const e: Email = Email.parse(req.body.email);
|
|
send(e, '...'); // 검증된 Email 만 통과
|
|
```
|
|
|
|
## 🤔 의사결정 기준
|
|
| 입력 종류 | 위치 |
|
|
|---|---|
|
|
| HTTP body / query / params | route handler 입구 |
|
|
| 환경 변수 | 부팅 시점 (실패 즉시 종료) |
|
|
| 파일 업로드 (mime, size) | 업로드 미들웨어 |
|
|
| WebSocket 메시지 | 메시지 라우터 입구 |
|
|
| 외부 API 응답 | client wrapper |
|
|
| 큐 메시지 | worker 입구 |
|
|
|
|
## ❌ 안티패턴
|
|
- **`req.body.x` 그대로 사용**: 형식 / 타입 / 길이 보장 X.
|
|
- **클라이언트 검증만**: 우회 가능. 서버 = 진실.
|
|
- **`as Type` 캐스팅**: 검증 없음. parse 사용.
|
|
- **regex 만으로 이메일 검증**: 너무 엄격 / 느슨. zod email 사용.
|
|
- **schema 가 너무 느슨 (`z.any()`)**: 의미 없음.
|
|
- **error 메시지에 입력 그대로 echo**: XSS. 일반 메시지.
|
|
- **HTML / SQL 검증 없이 통과**: 별도 escape 단계 (output encoding) 도 필요.
|
|
- **strict() 안 씀**: 추가 필드 무시 — 클라이언트 의도 파악 어려움 / 잠재 보안 사고.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- 새 endpoint: zod schema 먼저 작성, 핸들러는 parse 결과 사용.
|
|
- env 도 zod parse — 부팅 시 fail-fast.
|
|
- 외부 API 응답도 schema.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Security_Output_Encoding_XSS]]
|
|
- [[TypeScript_Branded_Types]]
|
|
- [[Security_CSRF_Patterns]]
|