이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Trust Boundary는 웹 백엔드 요청 흐름의 어디에서 생기는가?
- Attack Surface를 URL 개수가 아니라 입력과 상태 변경 경로로 세는 이유는 무엇인가?
- Gateway, Filter, Controller, Service 경계에서 각각 무엇을 검증해야 하는가?
개요
Trust Boundary는 “이 선을 넘은 값은 이전보다 더 신뢰해도 되는가”를 판단하는 지점이다.
브라우저에서 온 쿠키, 모바일 앱이 보낸 header, Gateway가 붙인 사용자 식별자, Controller가 받은 JSON, Service가 만든 권한 결정, DB에서 읽은 row는 모두 다른 신뢰 수준을 가진다.
Attack Surface는 공격자가 시도할 수 있는 표면이다. URL 수만 세면 부족하다. 같은 URL이라도 header, query, body, file, redirect, sort expression, callback URL, role 값, 상태 변경 action이 많으면 표면은 넓다.
공격 시나리오
사내 관리자 API가 API Gateway 뒤에 있다고 가정한다.
Gateway는 인증된 관리자에게 X-Admin-Id, X-Admin-Role header를 붙여 내부 애플리케이션으로 전달한다. 애플리케이션은 이 header를 읽어 권한을 판단한다.
취약점은 Gateway 뒤라는 가정이 깨질 때 드러난다.
- 운영자가 임시 디버깅을 위해 애플리케이션 포트를 내부 VPN에 직접 노출한다.
- 공격자는 낮은 권한 내부 계정이나 SSRF 취약점을 통해 직접 애플리케이션에 요청한다.
- 요청에
X-Admin-Role: SUPER_ADMIN을 넣는다. - 애플리케이션이 “Gateway가 붙였겠지”라고 믿고 가격 변경이나 권한 부여를 수행한다.
이 문제는 인증 기능이 없어서 생긴 것이 아니다. 신뢰 경계가 Gateway에 있는데 애플리케이션이 그 경계를 검증하지 않았기 때문에 생긴다.
취약한 요청/응답 예시
PATCH /internal/admin/products/77/price HTTP/1.1
Host: app.internal.example.com
X-Admin-Id: 9
X-Admin-Role: SUPER_ADMIN
Content-Type: application/json
{
"price": 100,
"reason": "promotion"
}HTTP/1.1 200 OK
Content-Type: application/json
{"productId":77,"price":100,"changedBy":9}애플리케이션이 외부 요청 header와 Gateway가 만든 내부 header를 구분하지 못하면 header spoofing이 권한 상승이 된다.
주제별 핵심 판단
웹 백엔드의 대표 경계는 다음과 같다.
Browser or external client
-> CDN or WAF
-> API Gateway
-> Spring Security FilterChain
-> Controller
-> Service policy
-> Repository
-> Database각 경계의 질문은 다르다.
- CDN/WAF는 비정상 트래픽, 큰 payload, 알려진 공격 패턴을 줄일 수 있는가?
- Gateway는 외부에서 들어온 보안 header를 제거하고 내부 header를 새로 만드는가?
- FilterChain은 인증, CSRF, CORS, session, token 검증을 수행하는가?
- Controller는 요청 형식과 필수값을 검증하는가?
- Service는 resource ownership과 업무 권한을 판단하는가?
- Repository는 권한 조건을 SQL 조건으로 보완하는가?
- DB는 제약 조건과 최소 권한 계정으로 피해 범위를 줄이는가?
하나의 경계가 모든 공격을 막는 구조는 없다.
개선된 요청/응답 예시
Attack Surface는 다음 항목으로 세는 편이 실무적이다.
| 구분 | 확인할 표면 |
|---|---|
| entry point | public API, admin API, internal API, webhook, upload, batch input |
| input class | path, query, JSON body, multipart file, cookie, authorization header, forwarded header, redirect URL |
| state change | role change, price change, password reset, token refresh, export, delete |
URL 하나라도 file parser, role 변경, redirect, 대량 export가 같이 있으면 위험도는 높아진다.
방어 설계
Gateway 경계에서는 외부가 보낸 내부 보안 header를 제거한다.
drop incoming:
X-Admin-Id
X-Admin-Role
X-User-Id
X-Forwarded-User
create after authentication:
X-Verified-Admin-Id
X-Verified-Authorities
X-Gateway-Signature애플리케이션 경계에서는 “이 header가 Gateway에서 왔다”는 근거를 확인한다.
- mTLS, private network, Gateway signature, allowlisted proxy address 중 하나 이상을 검토한다.
X-Forwarded-*계열 header는 신뢰할 proxy를 통과한 뒤의 값만 사용한다.- 내부 API라도 Service 권한 정책은 유지한다.
- 직접 포트 노출, actuator, debug endpoint, Swagger UI, admin console은 별도 표면으로 관리한다.
Spring/HTTP 예시
Filter는 외부 입력으로 들어온 내부 header를 그대로 믿지 않는다.
public class VerifiedGatewayHeaderFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String gatewaySignature = request.getHeader("X-Gateway-Signature");
String verifiedAdminId = request.getHeader("X-Verified-Admin-Id");
if (request.getRequestURI().startsWith("/internal/admin/")
&& !gatewayVerifier.isValid(gatewaySignature, verifiedAdminId, request)) {
audit.warn("trust_boundary_denied",
Map.of("path", request.getRequestURI(), "reason", "invalid_gateway_signature"));
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
chain.doFilter(request, response);
}
}Service는 Gateway header만으로 최종 권한을 결정하지 않는다.
@Transactional
public void changePrice(AdminPrincipal principal, long productId, Money price, String reason) {
Product product = productRepository.getForUpdate(productId);
if (!adminPolicy.canChangePrice(principal, product, price)) {
audit.priceChangeDenied(principal.adminId(), productId, "policy_denied");
throw new AccessDeniedException("price change denied");
}
product.changePrice(price, reason, principal.adminId());
audit.priceChanged(principal.adminId(), productId, price.maskForLog());
}운영 로그와 감사 지점
level=WARN event=trust_boundary_denied result=blocked boundary=gateway_to_app path=/internal/admin/products/77/price source_ip=10.4.12.9 reason=invalid_gateway_signature spoofed_header=X-Admin-Role trace_id=bc921a0d경계 로그에는 다음 필드가 필요하다.
- boundary 이름
- entry point
- source network 또는 proxy chain
- 거부된 header나 input class
- 인증 principal
- 최종 action
- result와 reason
- trace id
민감한 token 원문은 남기지 않는다. 대신 token id, session id hash, signature validation result처럼 추적 가능한 안전한 값을 남긴다.
실전 판단 기준
- Gateway를 우회해 애플리케이션 포트로 직접 요청하면 차단되는가?
- 신뢰 경계는 네트워크 위치가 아니라 값의 출처와 검증 책임이 바뀌는 지점으로 본다.
- attack surface에는 공개 API뿐 아니라 webhook, batch, admin, actuator, file upload까지 포함한다.
테스트 포인트
- 외부 요청에
X-Admin-Role,X-User-Id를 넣어도 무시되는가? X-Forwarded-For를 조작해 rate limit이나 관리자 IP 제한을 우회할 수 없는가?- 내부 API도 Service 권한 정책을 통과해야 하는가?
- Swagger, actuator, debug endpoint가 운영에서 인증 없이 열려 있지 않은가?
- 새 file upload나 webhook이 추가될 때 Attack Surface 목록이 갱신되는가?
위험 신호!
- “Gateway 뒤라서 안전하다”는 문장만 있고 검증 근거가 없다.
- 애플리케이션이
X-User-Id,X-Role같은 header를 그대로 권한 판단에 사용한다. - 내부 API와 public API가 같은 로그 수준으로만 남는다.
- debug endpoint, actuator, Swagger UI가 Attack Surface 목록에 없다.
- upload, redirect, search filter를 단순 입력 검증 문제로만 보고 신뢰 경계를 그리지 않는다.
- CORS 허용을 서버 간 인증처럼 오해한다.
확인 질문
확인 질문
- Trust Boundary와 Attack Surface의 차이는 무엇인가?
- Trust Boundary는 값의 신뢰 수준이 바뀌는 선이고, Attack Surface는 공격자가 시도할 수 있는 입력과 상태 변경 경로의 전체 표면이다.
- Gateway가 붙인 header를 애플리케이션이 바로 믿으면 안 되는 이유는 무엇인가?
- Gateway 우회, misconfiguration, SSRF, 내부망 침해 상황에서 공격자가 같은 header를 직접 보낼 수 있기 때문이다.
- URL 수만으로 Attack Surface를 판단하면 놓치는 것은 무엇인가?
- header, file parser, redirect, webhook, batch input, role 변경, export 같은 입력 종류와 상태 변경 경로를 놓친다.