5단계
Rate limit + CORS + 보안 헤더
25 분
Rate limit + CORS + 보안 헤더
3 자리가 깔끔하면 대부분의 자동 공격은 막습니다.
1. Rate limit — sliding window (Redis)
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
async function rateLimit(key: string, max: number, windowSec: number) {
const bucket = Math.floor(Date.now() / 1000 / windowSec);
const k = `rl:${key}:${bucket}`;
const count = await redis.incr(k);
if (count === 1) await redis.expire(k, windowSec * 2);
return { allowed: count <= max, count, max };
}
사용:
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "anon";
const { allowed } = await rateLimit(`login:${ip}`, 5, 60);
if (!allowed) return NextResponse.json({ error: "rate_limit" }, { status: 429 });
// ... 로그인 처리
}
분당 5 회 제한.
2. Redis 없이 — PostgreSQL
SELECT count(*) FROM rate_limit_events
WHERE key = $1 AND created_at > now() - interval '1 minute';
장점: 의존성 감소. 단점: 쓰기 부하 증가.
3. 경로별 한도
| 경로 | 한도 |
|---|---|
/api/auth/login |
분당 5 (IP) |
/api/auth/register |
시간당 3 (IP) |
/api/posts (POST) |
분당 10 (user) |
/api/search |
분당 60 (IP) |
| 일반 GET | 분당 300 (IP) |
쓰기는 엄격 · 읽기는 느슨.
4. CORS — Origin 화이트리스트
// Next.js middleware 또는 API route 내
const ALLOWED_ORIGINS = new Set([
"https://example.com",
"https://admin.example.com",
]);
const origin = req.headers.get("origin") ?? "";
const headers = new Headers();
if (ALLOWED_ORIGINS.has(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Access-Control-Allow-Credentials", "true");
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers });
Access-Control-Allow-Origin: * + Allow-Credentials: true 조합 금지 (브라우저가 거부).
5. 보안 헤더 (Caddy or middleware)
Caddy 글로벌:
(security) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
}
}
example.com {
import security
reverse_proxy localhost:3000
}
6. CSP (Content Security Policy)
XSS 2 차 방어. 외부 스크립트 소스를 명시적 화이트리스트.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-xxx' https://www.google-analytics.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
Next.js 에서는 middleware 로 nonce 생성 · inject. 초반에는 Content-Security-Policy-Report-Only 로 수집만 해서 위반 내역 분석 후 차단.
7. 쿠키 플래그
res.cookies.set("session", token, {
httpOnly: true, // JS 접근 불가
secure: true, // HTTPS 만
sameSite: "lax", // CSRF 기본 방어
maxAge: 60 * 60, // 1 시간
path: "/",
});
sameSite: strict— 가장 안전하나 외부 링크 클릭 시 세션 소실sameSite: lax— GET 만 cross-site 허용. 실용적 기본sameSite: none— third-party cookie.secure필수
8. CSRF 추가 방어 (sameSite 외)
sameSite: lax 가 대부분을 막지만 완전하지 않음.
// Double-submit cookie
res.cookies.set("csrf_token", csrfToken, { httpOnly: false });
// 프론트가 이 쿠키 값을 header 에도 함께 보냄
// 서버가 쿠키 값과 header 값 일치 확인
또는 Synchronizer Token Pattern (서버가 세션과 매칭하는 별도 토큰).
9. Clickjacking 방어
X-Frame-Options: SAMEORIGIN 또는 Content-Security-Policy: frame-ancestors 'none'. 다른 사이트가 iframe 으로 감싸지 못하게.
10. 자주 걸리는 자리
- **Allow-Origin: *** — 자격증명 없으면 OK, 있으면 사고
- CSP 없이 — XSS 피해 확산
- Caddy 없이 Nginx 수동 — TLS 인증서 갱신 실수 잦음. Caddy 자동 추천
- rate limit 의 IP 만 기준 — NAT 뒤 사용자 여러 명 공유. user_id 조합 권장
하고픈 말
Caddy + CSP + sameSite cookie 조합이 초보자에게도 안전한 기본값. 과잉 설정보다 기본값이 올바른지 점검하는 게 실수 줄이는 길.
Next
- 06-anonymous-form-hardening