[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
---
|
||||
id: security-csp-headers
|
||||
title: CSP / 보안 헤더 — XSS / Clickjacking 방어
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [security, csp, headers, web, vibe-coding]
|
||||
tech_stack: { language: "TS / HTTP", applicable_to: ["Frontend", "Backend"] }
|
||||
applied_in: []
|
||||
aliases: [CSP, Content-Security-Policy, nonce, HSTS, X-Frame-Options, helmet]
|
||||
---
|
||||
|
||||
# CSP / 보안 헤더
|
||||
|
||||
> XSS 방어 마지막 보호막 = **Content-Security-Policy**. 인라인 script 차단 + nonce. **HSTS / X-Frame-Options / Permissions-Policy** 같이 묶어서 helmet 으로.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- CSP: 어떤 origin 의 자원만 로드 가능한지 명시.
|
||||
- nonce: 매 요청마다 random — 인라인 script 1회 통과.
|
||||
- Strict CSP: `'strict-dynamic'` + nonce — host allowlist 안 필요.
|
||||
- Report-only: 위반 보고만, 차단 X (점진 도입).
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Helmet (Express)
|
||||
```ts
|
||||
import helmet from 'helmet';
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", `'nonce-${res.locals.cspNonce}'`, "'strict-dynamic'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // CSS 는 보통 강 X
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'", 'https://api.example.com'],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
||||
objectSrc: ["'none'"],
|
||||
frameAncestors: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
},
|
||||
},
|
||||
crossOriginOpenerPolicy: { policy: 'same-origin' },
|
||||
crossOriginResourcePolicy: { policy: 'same-origin' },
|
||||
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||
}));
|
||||
```
|
||||
|
||||
### nonce 발급
|
||||
```ts
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.locals.cspNonce = randomBytes(16).toString('base64');
|
||||
next();
|
||||
});
|
||||
|
||||
// 템플릿
|
||||
res.render('index', { nonce: res.locals.cspNonce });
|
||||
```
|
||||
|
||||
```html
|
||||
<script nonce="<%= nonce %>">
|
||||
// OK
|
||||
</script>
|
||||
```
|
||||
|
||||
### Next.js (next.config.js)
|
||||
```js
|
||||
const cspHeader = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'nonce-NONCE' 'strict-dynamic';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: https:;
|
||||
font-src 'self';
|
||||
connect-src 'self' https://api.example.com;
|
||||
frame-ancestors 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
`.replace(/\s{2,}/g, ' ').trim();
|
||||
|
||||
module.exports = {
|
||||
async headers() {
|
||||
return [{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'Content-Security-Policy', value: cspHeader },
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
],
|
||||
}];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### CSP report-only (점진)
|
||||
```
|
||||
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
|
||||
```
|
||||
|
||||
```ts
|
||||
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
|
||||
log.warn('csp violation', req.body['csp-report']);
|
||||
res.status(204).end();
|
||||
});
|
||||
```
|
||||
|
||||
### Permissions-Policy (sensors / 권한)
|
||||
```
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=()
|
||||
```
|
||||
|
||||
### HSTS (HTTPS 강제)
|
||||
```
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
|
||||
```
|
||||
|
||||
`preload`: hstspreload.org 등록 시 브라우저 hardcoded list 에 포함.
|
||||
|
||||
### Subresource Integrity (CDN script)
|
||||
```html
|
||||
<script src="https://cdn.example.com/lib.js"
|
||||
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
|
||||
crossorigin="anonymous"></script>
|
||||
```
|
||||
|
||||
CDN 가 변조되면 브라우저가 차단.
|
||||
|
||||
### X-Frame-Options vs frame-ancestors
|
||||
- `X-Frame-Options: DENY` (legacy)
|
||||
- CSP `frame-ancestors 'none'` (modern, 우선)
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 헤더 | 우선 |
|
||||
|---|---|
|
||||
| 모든 사이트 | HSTS + X-Content-Type-Options + Referrer-Policy + Permissions-Policy |
|
||||
| XSS 방어 강 | CSP strict + nonce |
|
||||
| Iframe 사용 안 함 | frame-ancestors 'none' |
|
||||
| 외부 cdn 의존 | SRI integrity |
|
||||
| SaaS 임베드 가능 필요 | frame-ancestors specific origin |
|
||||
| 점진 도입 | Report-only → enforce |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **`'unsafe-inline'` 그대로**: CSP 의미 절반 잃음. nonce.
|
||||
- **`*` 허용**: CSP 무의미.
|
||||
- **Report 검토 안 함**: 위반 모름.
|
||||
- **HSTS 없음 (HTTP 가능)**: SSL strip.
|
||||
- **Self-signed cert prod**: HSTS 못 씀.
|
||||
- **X-XSS-Protection 1**: 옛 헤더, modern X. 빼기.
|
||||
- **Helmet 끄기 — 빠르다고**: 기본은 거의 무료.
|
||||
- **Hash 만 + dynamic script**: 매 변경 hash 갱신 필요.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Helmet defaults 그대로 좋다.
|
||||
- CSP = nonce + strict-dynamic.
|
||||
- Report-only 로 시작 → enforce.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Security_OWASP_Top_10_Practical]]
|
||||
- [[Web_CORS_Practical_Guide]]
|
||||
- [[Security_Output_Encoding_XSS]]
|
||||
Reference in New Issue
Block a user