API를 남용으로부터 보호하기 위한 방어적 구현 패턴에 대해 설명합니다.
속도 제한은 API 남용, DoS 공격, 스크래핑으로부터 시스템을 보호하는 기본적인 방어 메커니즘입니다.
| 알고리즘 | 특성 | 장점 | 단점 |
|---|---|---|---|
| Fixed Window | 정해진 시간 범위 내에서 계산 | 구현이 간단하고 메모리 효율적 | 창 경계에서 버스트 발생 |
| Sliding Window Log | 레코드 요청 타임스탬프 | 정밀한 제어 | 높은 메모리 소비량 |
| Sliding Window Counter | 이전 창과 현재 창의 가중 평균 | 좋은 균형 | 다소 복잡함 |
| Token Bucket | 액세스를 위해 토큰을 소비합니다. | 제한을 적용하면서 버스트를 허용합니다. | 매개변수 조정 필요 |
| Leaky Bucket | 일정한 속도로 요청 처리 | 안정적인 출력 속도 | 버스트 처리에 취약함 |
const rateLimit = require('express-rate-limit'); const RedisStore = require('rate-limit-redis'); // 글로벌 제한: 모든 API에 적용 const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15분 max: 100, standardHeaders: true, // RateLimit-* 헤더 반환 legacyHeaders: false, message: { error: 'Too many requests, please try again later.' }, }); // 인증 엔드포인트의 경우: 더 엄격한 제한 사항 const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, // 로그인 시도는 15분당 5회로 제한됩니다. skipSuccessfulRequests: true, // 성공한 요청은 계산되지 않습니다. }); // 분산 환경의 경우: Redis 백엔드 const distributedLimiter = rateLimit({ store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }), windowMs: 60 * 1000, max: 30, }); app.use(globalLimiter); app.use('/api/auth', authLimiter);
RateLimit-Limit: 100 RateLimit-Remaining: 42 RateLimit-Reset: 1672531200 Retry-After: 30
IP 주소 외에 API 키, 사용자 ID 또는 엔드포인트와 같은 복합 키를 사용하여 속도 제한을 적용하면 합법적인 사용자에게 미치는 영향을 최소화할 수 있습니다.
모든 API 입력에는 악성 데이터가 포함될 수 있습니다. 항상 서버 측에서 유효성을 검사하세요.
허용된 값의 목록을 정의합니다. 거부 목록(차단 목록)보다 안전합니다. 새로운 공격 패턴을 처리할 수 있습니다.
데이터 유형, 최대 문자 수, 숫자 상한/하한을 엄격하게 확인합니다. 버퍼 오버플로우에 대한 근본적인 방어.
유효하지 않은 입력을 거부하는 것이 '수정'하는 것보다 안전합니다. 위생 처리로 인해 예기치 않은 변형이 발생할 수 있습니다.
const Ajv = require('ajv'); const addFormats = require('ajv-formats'); const ajv = new Ajv({ allErrors: true, removeAdditional: true }); addFormats(ajv); // 사용자 생성 API 스키마 const createUserSchema = { type: 'object', required: ['name', 'email'], additionalProperties: false, properties: { name: { type: 'string', minLength: 1, maxLength: 100, pattern: '^[a-zA-Z0-9\\s\\-]+$', // 허용된 문자로 제한 }, email: { type: 'string', format: 'email', maxLength: 254, }, age: { type: 'integer', minimum: 0, maximum: 150, }, }, }; // 유효성 검사 미들웨어 function validateBody(schema) { const validate = ajv.compile(schema); return (req, res, next) => { if (!validate(req.body)) { return res.status(400).json({ error: 'Validation failed', details: validate.errors, }); } next(); }; } app.post('/api/users', validateBody(createUserSchema), createUser);
| 공격 | 대응 방안 | 예 |
|---|---|---|
| SQL 주입 | 매개변수화된 쿼리, ORM 사용 | db.query('SELECT * FROM users WHERE id = ?', [id]) |
| NoSQL 인젝션 | 유형 검사, $ 연산자 위생 처리 | 입력이 문자열인지 확인(객체 거부) |
| XSS(API 응답을 통한) | 콘텐츠 유형 지정, 출력 이스케이프 | Content-Type: application/json |
| 경로 탐색 | 입력에서 경로 구분 기호 제거 | path.basename()로 정규화하기 |
| XXE(XML 외부 엔티티) | 외부 엔티티 해상도 비활성화 | XML 구문 분석기 설정에서 비활성화 |
CORS는 브라우저의 동일 출처 정책을 제어하는 메커니즘입니다. 잘못 구성하면 심각한 보안 위험이 발생할 수 있습니다.
Access-Control-Allow-Origin: *와 Access-Control-Allow-Credentials: true는 함께 사용할 수 없습니다. 와일드카드 사용은 공개 API로만 제한하세요.
const cors = require('cors'); // 허용된 출처를 명시적으로 지정 const allowedOrigins = [ 'https://app.example.com', 'https://admin.example.com', ]; app.use(cors({ origin(origin, callback) { // 서버 간 통신 허용(원본 없음) if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], credentials: true, maxAge: 86400, // 비행 전 캐시: 24시간 }));
| 헤더 | 목적 | 권장 값 |
|---|---|---|
Access-Control-Allow-Origin | 허용된 출처 | 명시적 도메인 사양 |
Access-Control-Allow-Methods | 허용되는 HTTP 메서드 | 최소 요구 사항만 |
Access-Control-Allow-Headers | 허용된 요청 헤더 | 최소 요구 사항만 |
Access-Control-Allow-Credentials | 쿠키 전송 허용 | true (인증이 필요한 경우에만) |
Access-Control-Max-Age | 비행 전 캐시 지속 시간(초) | 86400(24시간) |
Access-Control-Expose-Headers | 자바스크립트에서 읽을 수 있는 응답 헤더 | RateLimit-*와 같은 필수 헤더만 표시합니다. |
LLM APIs introduce new dimensions to rate limiting: token consumption, cost per request, and compute-intensive inference.
| Dimension | Traditional API | LLM API |
|---|---|---|
| Cost per request | Low, predictable | Variable, can be 100x+ (based on tokens) |
| Rate limit unit | Requests per time window | Tokens per minute (TPM) + Requests per minute (RPM) |
| Abuse pattern | Scraping, brute force, DDoS | Prompt injection, resource exhaustion, denial-of-wallet |
| Algorithm fit | Fixed/Sliding window | Token bucket (weighted by token count) |
import tiktoken import time from collections import defaultdict class TokenRateLimiter: """Rate limiter that counts tokens, not just requests.""" def __init__(self, tokens_per_minute=100_000, requests_per_minute=60): self.tpm_limit = tokens_per_minute self.rpm_limit = requests_per_minute self.usage = defaultdict(lambda: {"tokens": [], "requests": []}) self.encoder = tiktoken.encoding_for_model("gpt-4") def count_tokens(self, text: str) -> int: return len(self.encoder.encode(text)) def check_limit(self, user_id: str, prompt: str) -> dict: now = time.time() window = now - 60 # 1-minute sliding window user = self.usage[user_id] # Clean up expired entries user["tokens"] = [(t, c) for t, c in user["tokens"] if t > window] user["requests"] = [t for t in user["requests"] if t > window] # Check RPM if len(user["requests"]) >= self.rpm_limit: return {"allowed": False, "reason": "RPM limit exceeded"} # Check TPM token_count = self.count_tokens(prompt) used_tokens = sum(c for _, c in user["tokens"]) if used_tokens + token_count > self.tpm_limit: return {"allowed": False, "reason": "TPM limit exceeded"} # Record usage user["tokens"].append((now, token_count)) user["requests"].append(now) return {"allowed": True, "tokens_used": token_count}
Related: LLM10: Model Denial of Service, ASI04: Cascading Hallucination Attacks
Prompt injection is the #1 risk for LLM applications. Apply defense-in-depth with input validation, structural separation, and output verification.
Strip or escape special tokens, instruction-like patterns, and control characters from user input before including in prompts.
Use delimiters, XML tags, or separate message roles to clearly isolate system instructions from user-provided content.
Validate LLM responses against expected schemas. Check for data leakage, instruction following, and malicious content before rendering.
| 공격 | 대응 방안 | 예 |
|---|---|---|
| Direct Prompt Injection | Input sanitization + instruction/data separation | "Ignore previous instructions and..." |
| Indirect Prompt Injection | Sanitize RAG results + canary tokens | Malicious instructions hidden in retrieved documents |
| Context Stuffing | Token limits + input truncation | Overloading context window to push out system instructions |
| Parameter Tampering | Schema validation + typed parameters | Manipulating temperature, max_tokens, or model parameters |
from pydantic import BaseModel, Field, field_validator import re class LLMRequest(BaseModel): """Validated LLM request with prompt injection defenses.""" user_message: str = Field(..., max_length=4000) max_tokens: int = Field(default=1000, ge=1, le=4096) temperature: float = Field(default=0.7, ge=0.0, le=2.0) @field_validator("user_message") @classmethod def sanitize_prompt(cls, v: str) -> str: # Block known injection patterns patterns = [ r"(?i)ignore\s+(previous|above|all)\s+(instructions?|prompts?)", r"(?i)you\s+are\s+now\s+", r"(?i)system\s*:\s*", r"(?i)\[INST\]|\[\/INST\]|<\|im_start\|>", ] for pattern in patterns: if re.search(pattern, v): raise ValueError("Input contains disallowed patterns") return v # Usage with FastAPI @app.post("/api/chat") async def chat(request: LLMRequest): # Separate system instructions from user input messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"<user_input>{request.user_message}</user_input>"}, ] return await call_llm(messages, request.max_tokens, request.temperature)
Related: LLM01: Prompt Injection, LLM02: Insecure Output Handling, ASI02: Prompt Injection via Tool Results
# 콘텐츠 유형 자동 감지 비활성화 X-Content-Type-Options: nosniff # iframe 임베딩 방지 X-Frame-Options: DENY # HTTPS 적용 Strict-Transport-Security: max-age=31536000; includeSubDomains # CSP: API는 JSON만 반환하므로 모든 스크립트 실행을 차단합니다. Content-Security-Policy: default-src 'none'; frame-ancestors 'none' # 리퍼러 정보 제한 Referrer-Policy: no-referrer # 브라우저 기능 제한 Permissions-Policy: geolocation=(), camera=(), microphone=()