generated from ttipo/project-base
feat(docker): 12단계 계정 보안 — 엔진 웹훅 · /api/internal 닫기 · 요청 대상 400
메일 프로그램(IMAP · SMTP) 로그인 기록을 엔진 웹훅으로 받는다. - 루트 env MAIL_WEBHOOK_SECRET(setup · backend 공용, 운영 필수)과 backend 의 APP_NETWORK_SUBNET - setup 이 엔진 웹훅을 맞추고(표시 머리글 — 소금 + 키 지문) 서명한 빈 묶음으로 받는 곳을 확인한다 키를 바꾼 실행 · 게이트웨이 OTP · 테스트 직원의 402 를 따로 다룬다 - nginx 두 server 가 /api/internal/ 을 닫고, 요청 대상의 날 \(경로) · # · 0x80 이상은 location 전에 400 Express 가 이런 대상을 다시 읽어 닫은 경로(/api/internal · /api/health)가 백엔드에서 열렸다(2026-10-31 리뷰) - CI "닫는 경로 시험"(두 server × 세 경로의 변형) · 지시어만 세는 정적 검사 - mail/README: 웹훅 · 12단계 전부터 운영 중인 서버를 올리는 차례 · 휴대폰을 잃은 직원(관리 API 명령) 엔진 DNS 가 Custom(운영)이어도 웹훅은 backend 를 푼다 — 메일 컨테이너가 도커 DNS 를 쓴다(개발에서 확인). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+38
-1
@@ -23,6 +23,7 @@
|
||||
# 두 server 가 함께 쓰는 location 은 /etc/nginx/snippets/ 의 파일이다(템플릿이 아니라 그대로 쓴다):
|
||||
# share-open.conf · share-closed.conf server 단계 — 공개 화면 /s/ (연다 · 닫는다)
|
||||
# share-api-open.conf · share-api-closed.conf /api/ 블록 안 — 공개 API /api/public-links/ (연다 · 닫는다)
|
||||
# internal-api-closed.conf /api/ 블록 안 — 엔진만 부르는 /api/internal/ (늘 닫는다 — 12단계 엔진 웹훅)
|
||||
# api-errors.conf server 단계 — nginx 가 직접 끝내는 /api 오류의 JSON(@api_*)
|
||||
# 이 파일은 http 단계에 들어간다(이미지의 /etc/nginx/nginx.conf 가 conf.d 를 http 안에서 읽는다) — 아래 map · proxy_* · gzip 은 두 server 가 물려받는다.
|
||||
# ==========================================
|
||||
@@ -159,6 +160,25 @@ limit_conn_zone $download_total_key zone=download_app_total:1m;
|
||||
limit_conn_status 429;
|
||||
limit_conn_log_level info;
|
||||
|
||||
# ----------------------------------------
|
||||
# 원문 요청 대상 검사 (12단계 리뷰 — 두 server 의 server 단계 if 가 쓴다, 400 VAL_001 JSON — nginx-api-errors.conf)
|
||||
#
|
||||
# 요청 줄의 대상(원문 $request_uri)에 아래가 있으면 location 을 고르기 전에 400 으로 끝낸다:
|
||||
# - 경로(? 앞)의 날 역슬래시 \ — nginx 는 \ 를 경로 구분자로 보지 않아 /api/internal\… 이 닫는 정규식 location 에 맞지 않고 /api/ 로 넘어간다
|
||||
# - 날 # (어디든) — nginx 는 # 뒤를 버리고 location 을 고른다
|
||||
# - 0x80 이상 바이트 (어디든) — 퍼센트 인코딩하지 않은 비 ASCII (NBSP 0xA0 등)
|
||||
# 백엔드로는 원문이 그대로 간다. 백엔드의 Express(parseurl)는 원문에 # · 0xA0 같은 글자가 있으면 옛 url.parse 로 다시 읽어 경로의 \ 를 / 로 바꾼다 —
|
||||
# 그래서 /api/internal\mail-engine-events#x · /api/health\#… · /api/public-links\… 가 nginx 의 닫기를 비켜 백엔드 처리기에 닿았다(2026-10-31 리뷰).
|
||||
# 브라우저는 이런 요청을 보내지 않는다 — 경로의 \ 는 / 로 바꾸고, 비 ASCII 는 퍼센트 인코딩하고, # 뒤(조각)는 보내지 않는다.
|
||||
# 쿼리의 \ 는 막지 않는다 — 주소창에 친 검색어처럼 브라우저가 그대로 보낼 수 있고, url.parse 는 ? 앞의 \ 만 바꾼다. %5C(인코딩한 \)는 nginx 가 풀어
|
||||
# location 을 고르므로 닫는 정규식들이 [/\x5c] 로 받는다(두 겹). 정규식의 \x5c 는 역슬래시, \x23 은 # 이다(설정 파일의 \ · # 을 피하려고)
|
||||
# ----------------------------------------
|
||||
map $request_uri $request_target_rejected {
|
||||
"~^[^?]*\x5c" 1;
|
||||
"~[\x23\x80-\xff]" 1;
|
||||
default 0;
|
||||
}
|
||||
|
||||
# ----------------------------------------
|
||||
# /api → 백엔드 (같은 주소 배포) — 두 server 가 함께 쓴다
|
||||
# ----------------------------------------
|
||||
@@ -210,6 +230,11 @@ server {
|
||||
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장·대용량 조각 경로만 아래 /api 에서 늘린다
|
||||
client_max_body_size 1m;
|
||||
|
||||
# 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트 → 400 VAL_001(JSON) — location 을 고르기 전에 (위 http 단계의 map, nginx-api-errors.conf)
|
||||
if ($request_target_rejected) {
|
||||
rewrite ^ /__request-target-rejected last;
|
||||
}
|
||||
|
||||
# ----------------------------------------
|
||||
# 보안 헤더 (화면용)
|
||||
#
|
||||
@@ -281,9 +306,14 @@ server {
|
||||
# ⚠️ 이 안의 정규식 location 은 아래 prefix location(/api/messages 등)보다 먼저 정해진다 — prefix 에 ^~ 가 없을 때.
|
||||
# 받기 경로가 걸리는 /api/messages 는 그래서 ^~ 없이 둔다 (^~ 면 /api/messages/… 가 아래 받기 location 을 비켜 간다)
|
||||
|
||||
# 엔진만 부르는 /api/internal/(엔진 웹훅 — 12단계)은 바깥에 열지 않는다 — 404 RES_001(JSON), 대소문자를 가리지 않는다.
|
||||
# 엔진은 app-network 로 backend 에 바로 붙는다(이 nginx 를 거치지 않는다). 공개 내려받기 server 도 같은 파일을 넣는다
|
||||
include /etc/nginx/snippets/internal-api-closed.conf;
|
||||
|
||||
# 헬스체크는 내부 전용 — 바깥에서 오면 없는 경로와 같은 404 (실패하면 DB 오류 문자열 등 원인이 담기기 때문이다).
|
||||
# 대소문자·끝 / 를 가리지 않는 백엔드 라우팅에 맞춰 막는다. 내부 확인 방법은 docker 스킬 문서 "운영 환경".
|
||||
location ~* ^/api/health(?:/|$) {
|
||||
# [/\x5c] — %5C(nginx 가 \ 로 풀어 고른다)도 막는다. 날 \ · # · 0x80 이상 바이트는 server 단계에서 400 이다(위 $request_target_rejected)
|
||||
location ~* ^/api/health(?:[/\x5c]|$) {
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
@@ -444,6 +474,11 @@ server {
|
||||
# 받기만 한다 — 본문을 받는 경로가 없다
|
||||
client_max_body_size 1m;
|
||||
|
||||
# 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트 → 400 VAL_001(JSON) — 포털과 같다 (http 단계의 map)
|
||||
if ($request_target_rejected) {
|
||||
rewrite ^ /__request-target-rejected last;
|
||||
}
|
||||
|
||||
# 보안 헤더 (화면용) — 포털과 같고 둘이 다르다: Referer 를 아예 보내지 않고(no-referrer — 화면 주소에 토큰이 있다),
|
||||
# 검색 엔진에 올리지 않는다(X-Robots-Tag). 공개 API 는 location 에서 상속을 끈다(백엔드가 정한다)
|
||||
add_header_inherit merge;
|
||||
@@ -476,6 +511,8 @@ server {
|
||||
add_header_inherit off;
|
||||
error_page 404 @api_not_found;
|
||||
error_page 413 @api_payload_too_large;
|
||||
# 엔진만 부르는 /api/internal/(12단계) — 아래 return 404 로도 닫히지만 포털과 같은 파일로 따로 닫아 둔다(공개 API 파일이 바뀌어도 열리지 않게)
|
||||
include /etc/nginx/snippets/internal-api-closed.conf;
|
||||
include /etc/nginx/snippets/share-api-open.conf;
|
||||
return 404;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user