Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docker/security/docker-compose.csrf-matrix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
services:
mysql:
image: mysql:8.0
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
ports:
- "${MYSQL_PORT:-3308}:3306"
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
interval: 5s
timeout: 3s
retries: 30

redis:
image: redis:7-alpine
ports:
- "${REDIS_HOST_PORT:-6381}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 2s
retries: 30

app:
image: gradle:8.10-jdk17
working_dir: /workspace
command: ["bash", "-lc", "./gradlew bootRun --no-daemon"]
volumes:
- ../..:/workspace
- gradle-cache:/home/gradle/.gradle
environment:
SPRING_PROFILE: ${SPRING_PROFILE}
DB_HOST: jdbc:mysql://mysql:3306/${MYSQL_DATABASE}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
DB_USERNAME: root
DB_PASSWORD: ${MYSQL_ROOT_PASSWORD}
REDIS_HOST: redis
REDIS_PORT: 6379
DDL_TYPE: update
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
MAIL_USER_NAME: ${MAIL_USER_NAME}
MAIL_APP_PASSWORD: ${MAIL_APP_PASSWORD}
SYNCLY_LINK: ${SYNCLY_LINK}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_CLOUDFRONT_DOMAIN: ${AWS_CLOUDFRONT_DOMAIN}
AWS_CLOUDFRONT_KEY_PAIR_ID: ${AWS_CLOUDFRONT_KEY_PAIR_ID}
AWS_CLOUDFRONT_PRIVATE_KEY: ${AWS_CLOUDFRONT_PRIVATE_KEY}
LIVEKIT_INGRESS_API_KEY: ${LIVEKIT_INGRESS_API_KEY}
LIVEKIT_INGRESS_API_SECRET: ${LIVEKIT_INGRESS_API_SECRET}
LIVEKIT_ADMIN_API_KEY: ${LIVEKIT_ADMIN_API_KEY}
LIVEKIT_ADMIN_API_SECRET: ${LIVEKIT_ADMIN_API_SECRET}
LIVEKIT_WEBHOOK_KEY: ${LIVEKIT_WEBHOOK_KEY}
LIVEKIT_WEBHOOK_SECRET: ${LIVEKIT_WEBHOOK_SECRET}
LIVEKIT_URL: ${LIVEKIT_URL}
LIVEKIT_TURN_ENABLED: ${LIVEKIT_TURN_ENABLED}
LIVEKIT_TURN_UDP_PORT: ${LIVEKIT_TURN_UDP_PORT}
LIVEKIT_TURN_TLS_PORT: ${LIVEKIT_TURN_TLS_PORT}
LIVEKIT_TURN_DOMAIN: ${LIVEKIT_TURN_DOMAIN}
LIVEKIT_TURN_USERNAME: ${LIVEKIT_TURN_USERNAME}
LIVEKIT_TURN_PASSWORD: ${LIVEKIT_TURN_PASSWORD}
DISCORD_ERROR_WEBHOOK_URL: ""
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${APP_PORT:-8081}:8080"

volumes:
gradle-cache:
43 changes: 43 additions & 0 deletions docker/security/env.csrf-matrix.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Runtime
SPRING_PROFILE=local
APP_PORT=8081
MYSQL_PORT=3308
REDIS_HOST_PORT=6381

# DB
MYSQL_DATABASE=syncly
MYSQL_ROOT_PASSWORD=syncly_root_pw

# Test account
MATRIX_EMAIL=csrf.matrix@syncly.local
MATRIX_PASSWORD=Aa!12345
MATRIX_PASSWORD_BCRYPT='$2a$10$SHBeLpz3IRNpx6gvHLW6Je6IcJa4p/zdNBrFOGtFK93eZUKov4qO2'

# Token signing
JWT_SECRET_KEY=10E9wxTvZquQWfNFVyBAjFNihDHwSZdYghS3mehdzmUVps9jau1/mC5oDYpdj3c15IFgDBiNZB+7du7oJ0kLbg==

# Dummy values for required properties
GOOGLE_CLIENT_ID=dummy-google-client-id
GOOGLE_CLIENT_SECRET=dummy-google-client-secret
MAIL_USER_NAME=dummy@example.com
MAIL_APP_PASSWORD=dummy-app-password
SYNCLY_LINK=http://localhost:5173
AWS_ACCESS_KEY_ID=dummy
AWS_SECRET_ACCESS_KEY=dummy
AWS_S3_BUCKET_NAME=dummy-bucket
AWS_CLOUDFRONT_DOMAIN=dummy.cloudfront.net
AWS_CLOUDFRONT_KEY_PAIR_ID=dummy-keypair
AWS_CLOUDFRONT_PRIVATE_KEY=dummy-private-key
LIVEKIT_INGRESS_API_KEY=dummy
LIVEKIT_INGRESS_API_SECRET=dummy
LIVEKIT_ADMIN_API_KEY=dummy
LIVEKIT_ADMIN_API_SECRET=dummy
LIVEKIT_WEBHOOK_KEY=dummy
LIVEKIT_WEBHOOK_SECRET=dummy
LIVEKIT_URL=http://localhost:7880
LIVEKIT_TURN_ENABLED=false
LIVEKIT_TURN_UDP_PORT=3478
LIVEKIT_TURN_TLS_PORT=5349
LIVEKIT_TURN_DOMAIN=localhost
LIVEKIT_TURN_USERNAME=dummy
LIVEKIT_TURN_PASSWORD=dummy
70 changes: 70 additions & 0 deletions docs/security/csrf-attack-matrix-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
## 근본 목적
Cross-site 구조에서 SameSite 쿠키 단독 방어가 불충분한 전제를 기준으로, 현재 Origin/Referer 기반 CSRF 필터가 공격 요청을 차단하고 정상 요청을 통과시키는지 자동화 실험으로 검증한다.

## 비목적
인증 설계 변경, CSRF 필터 로직 변경, 쿠키 정책 변경은 이번 문서의 범위가 아니다.

## 실험 질문
- 공격 요청(악성 Origin/Referer + 쿠키)을 안정적으로 차단하는가?
- 정상 요청(신뢰 Origin/Referer + 쿠키)을 안정적으로 통과시키는가?
- 헤더 조합 혼합(악성 Origin + 신뢰 Referer)에서 어떤 판정이 발생하는가?

## 검증 키 포인트
- 필터 적용 조건: `/api/auth/reissue`, `/api/auth/logout`, 비안전 메서드, `REFRESH` 쿠키 존재
- 차단 신호: `HTTP 403` + 응답 코드 `CSRF403`
- 집계 지표
- TP: 공격 차단
- FN: 공격 미차단
- FP: 정상 오차단
- TN: 정상 통과

## 시나리오 매트릭스
원본: `scripts/security/csrf-attack-matrix.json`

- 정상(C): C1~C4
- 공격(A): A1~A6

## 자동화 실행
```bash
cp docker/security/env.csrf-matrix.example docker/security/env.csrf-matrix.local
ENV_FILE=$(pwd)/docker/security/env.csrf-matrix.local \
./scripts/security/run-csrf-attack-matrix.sh
```

## 산출물
- 요청 결과: `/tmp/syncly-csrf-matrix/results-<RUN_ID>.ndjson`
- 집계 결과: `/tmp/syncly-csrf-matrix/summary-<RUN_ID>.json`
- 실행 메타: `/tmp/syncly-csrf-matrix/meta-<RUN_ID>.json`

## 결과 해석 원칙
- request level은 개별 요청 판정 정확도를 평가한다.
- incident level은 시나리오 단위 방어 성공률을 평가한다.
- `악성 Origin + 신뢰 Referer`처럼 혼합 헤더에서 미탐이 발생하면 정책 한계로 기록한다.
- 포트폴리오 문구는 측정값과 한계를 함께 제시한다.

## 이슈/PR 기재 필수 한계
- Origin/Referer 중 하나만 신뢰되어도 통과되므로, 혼합 헤더 시나리오에서 우회 여지가 있는지 실측 결과로 명시한다.
- CORS 허용 목록과 CSRF trusted 목록의 불일치로 정상 요청 차단이 발생할 수 있음을 결과 해석에 포함한다.

## 2026-02-26 1차 실행 결과
- 실행 명령: `./scripts/security/run-csrf-attack-matrix.sh`
- 결과 파일: `/tmp/syncly-csrf-matrix/results-20260226T113929Z.ndjson`
- 집계 파일: `/tmp/syncly-csrf-matrix/summary-20260226T113929Z.json`

요청 단위(request level)에서 두 가지 관점으로 분리해 해석했다.

- `security_block_level` (HTTP 403 차단 기준)
- TP=6, FN=0, FP=0, TN=3
- detection_rate=1.0, false_positive_rate=0.0
- 의미: 측정 대상 공격 6건은 모두 차단됐다.

- `csrf_filter_level` (`CSRF403` 코드 기준)
- TP=2, FN=4, FP=0, TN=3
- detection_rate=0.3333, false_positive_rate=0.0
- 의미: 공격 차단의 상당수는 CSRF 필터 응답(`CSRF403`)이 아니라 다른 계층(주로 CORS 403)에서 발생했다.

시나리오 해석:
- A3/A4는 `CSRF403`로 차단되어 CSRF 필터가 직접 동작했다.
- A1/A2/A5/A6은 403 차단이지만 `CSRF403` 코드가 없어, 실행 관찰상 CORS 단계 차단으로 분류된다.
- C1/C2/C3는 정상 통과(200)로 오차단은 관찰되지 않았다.
- C4(쿠키 없음)는 필터 적용 대상이 아니므로 측정 지표에서 제외하고 동작 확인용 진단 케이스로만 사용했다.
104 changes: 104 additions & 0 deletions scripts/security/csrf-attack-matrix.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
[
{
"id": "C1",
"group": "normal",
"title": "신뢰 Origin + 쿠키로 재발급",
"gt_attack": false,
"endpoint": "reissue",
"origin": "trusted",
"referer": "none",
"send_cookie": true
},
{
"id": "C2",
"group": "normal",
"title": "신뢰 Referer + 쿠키로 재발급",
"gt_attack": false,
"endpoint": "reissue",
"origin": "none",
"referer": "trusted",
"send_cookie": true
},
{
"id": "C3",
"group": "normal",
"title": "신뢰 Origin + 쿠키로 로그아웃",
"gt_attack": false,
"endpoint": "logout",
"origin": "trusted",
"referer": "trusted",
"send_cookie": true
},
{
"id": "C4",
"group": "normal",
"title": "쿠키 없는 재발급 요청(필터 스킵 경로)",
"gt_attack": false,
"endpoint": "reissue",
"origin": "trusted",
"referer": "trusted",
"send_cookie": false,
"measurable": false
},

{
"id": "A1",
"group": "attack",
"title": "악성 Origin 단독 + 쿠키",
"gt_attack": true,
"endpoint": "reissue",
"origin": "malicious",
"referer": "none",
"send_cookie": true
},
{
"id": "A2",
"group": "attack",
"title": "악성 Origin + 악성 Referer + 쿠키",
"gt_attack": true,
"endpoint": "reissue",
"origin": "malicious",
"referer": "malicious",
"send_cookie": true
},
{
"id": "A3",
"group": "attack",
"title": "Origin/Referer 모두 없음 + 쿠키",
"gt_attack": true,
"endpoint": "reissue",
"origin": "none",
"referer": "none",
"send_cookie": true
},
{
"id": "A4",
"group": "attack",
"title": "악성 Referer 단독 + 쿠키",
"gt_attack": true,
"endpoint": "reissue",
"origin": "none",
"referer": "malicious",
"send_cookie": true
},
{
"id": "A5",
"group": "attack",
"title": "악성 Origin + 신뢰 Referer 혼합 + 쿠키",
"gt_attack": true,
"endpoint": "reissue",
"origin": "malicious",
"referer": "trusted",
"send_cookie": true
},
{
"id": "A6",
"group": "attack",
"title": "유사 도메인 Origin(syncly-io.com.evil.com) + 쿠키",
"gt_attack": true,
"endpoint": "reissue",
"origin": "lookalike",
"referer": "none",
"send_cookie": true
}
]
Loading