Layer: raw/branch-notes/ — outbound webhook (서버 → 외부 consumer) 발송의 signature/replay/retry/observability/security 계약을 정의합니다.
범위 정합 (2026-06-29 ground-truth 대조): outbound HTTP 클라이언트의 공통 factory (OutboundHttpRestClientFactory.java) 및 설정 객체 (OutboundHttpSettings.java)는 adapter-outbound 모듈 내에 이미 구현되어 있으며 (Phase C2), 본 branch는 Webhook 발송 특유의 보안 및 신뢰성 정책을 얹기 위해 (a) Egress Proxy 설정 추가, (b) Redirect 강제 차단 설정, (c) HMAC-SHA256 서명 계산 모듈 및 (d) Full Jitter 재시도 백오프를 주입하는 구체적 구현 사양을 규정합니다.
java.net.http.HttpClient를 빌드할 때, app.outbound.http 설정 하위에 egress-proxy 설정을 결합하여 ProxySelector를 직접 바인딩하도록 설계함 / trade-off: Spring Cloud Gateway 등의 전역 프록시 설정을 타지 않고, 외부 아웃바운드 템플릿용 RestClient만 격리하여 프록시를 태움으로써 내부 통신(Kafka, DB 등)이 프록시 영향으로 단절되는 것을 방지함.
followRedirects(HttpClient.Redirect.NEVER)를 명시적으로 호출함 / trade-off: java.net.http.HttpClient 기본값도 Redirect.NEVER로 확인되어 보안 요구에 부합하지만, 코드 리뷰와 회귀 테스트에서 redirect 차단 계약이 드러나도록 명시성을 선택함.
수정 사양:
OutboundHttpSettings 레코드에 boolean egressProxyEnabled, String egressProxyHost, Integer egressProxyPort 필드를 추가하고, compact constructor에서 egressProxyEnabled가 true일 때 host 및 port의 null/blank/범위 초과 여부를 Fail-Fast로 검증함.
OutboundHttpRestClientFactory.create 메서드를 다음과 같이 리다이렉트 차단 및 프록시 주입이 가능하도록 수정함:
// dev.caskeleton.adapter.outbound.httpclient.OutboundHttpRestClientFactory.javastaticClientscreate(StringdependencyName,StringbaseUrl,OutboundHttpSettingssettings){HttpClient.Builderbuilder=HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).followRedirects(HttpClient.Redirect.NEVER);// D4: Redirects disabled// D4: Route all outbound requests through Smokescreen Egress Proxy if enabledif(settings.egressProxyEnabled()){builder.proxy(ProxySelector.of(newInetSocketAddress(settings.egressProxyHost(),settings.egressProxyPort())));}HttpClienthttpClient=builder.build();JdkClientHttpRequestFactoryrequestFactory=newJdkClientHttpRequestFactory(httpClient);requestFactory.setReadTimeout(settings.readTimeout());// rest client 빌드 생략...}
dev.caskeleton.adapter.outbound.httpclient.webhook.WebhookSignatureCalculator (application layer 또는 outbound helper)
UNSUPPORTED_IMPL_DECISION:
서명 대상 payload 조립 시 JSON Body of serialization 형태 변형으로 인한 서명 깨짐을 막기 위해, 반드시 RestClient에서 송신하기 직전의 raw byte array를 그대로 활용하도록 서명 계산 유틸을 바이트 단위로 설계함.
서명 헤더명을 X-Webhook-Signature로 정하고, 서명 결과를 Hex 문자열로 인코딩함 / trade-off: Stripe/GitHub/Svix 문서는 HMAC-SHA256과 raw payload 기반 서명을 뒷받침하지만, Svix는 Base64 인코딩을 사용하므로 Hex vs Base64 및 자체 헤더명은 프로젝트 로컬 convention 으로 문서화하고 consumer 검증 샘플을 함께 제공해야 함.
Egress Proxy 장애 (SPOF): Smokescreen 프록시가 다운되는 경우 모든 외부 웹훅 발송이 즉시 차단됨. 이 경우 retryable 에러(WEBHOOK_DELIVERY_FAILED)로 로깅 및 메트릭 기록을 남겨 재시도 큐에 보관해야 함.
Redirect 우회 시도: 수신 서버가 정상적인 퍼블릭 IP를 제공한 후, HTTP 응답 시 302 Found 등의 리다이렉션을 반환하여 내부 http://169.254.169.254로 우회를 유도할 때, HTTP 클라이언트가 리다이렉션 추적을 금지(Redirect.NEVER)했으므로 302 응답을 그대로 받아 WEBHOOK_REDIRECT_BLOCKED 에러로 격리하고 전송을 영구 중단함.
Clock Skew 엣지: 송신 서버와 수신 서버의 NTP 동기화가 깨져 시각 차이가 5분을 초과하는 경우 서명 검증은 통과하나 타임스탬프 스큐 검증에서 거절당함. 이를 모니터링하기 위해 X-Webhook-Timestamp 값이 수신 측 시간 대비 300초 이상 벗어난 경우의 예외 처리를 디버깅할 수 있도록 로깅해야 함.
HttpClient의 followRedirects(Redirect.NEVER)가 실제로 3xx 리다이렉션을 따라가지 않고, 3xx 응답을 WEBHOOK_REDIRECT_BLOCKED로 매핑할 수 있는가
JDK 기본값은 Redirect.NEVER로 확인됐지만, RestClient/JdkClientHttpRequestFactory 조합에서 응답 처리 경로를 프로젝트 테스트로 고정해야 함
Testcontainers에 MockWebServer를 띄우고 301/302 Redirect 응답을 던져 리다이렉션을 따라가지 않으며 3xx 응답을 차단 에러로 매핑하는지 JUnit 테스트로 검증
planned
Smokescreen Egress Proxy가 사설 IP 대역 호출 시도를 정책대로 차단하고 차단 응답을 반환하는가
프록시 룰셋이 잘못 설정되어 우회 경로가 존재할 위험이 있음
로컬 docker-compose에 Smokescreen을 띄우고 http://10.0.0.1로의 웹훅 발송이 프록시에 의해 차단됨을 확인
planned
Full Jitter Exponential Backoff 난수 분포가 편향 없이 고르게 분포하는가
Java의 ThreadLocalRandom 사용 시 특정 스레드 경쟁 조건에서 Jitter가 편향되어 스파이크 부하를 일으킬 수 있음
시뮬레이션을 통해 1,000회 재시도 대기시간의 표준 편차 및 분포 균일성을 검증
planned
shared secret key rotation 시 헤더에 다중 서명이 들어올 때 수신 측이 순회하며 성공적으로 하나라도 매칭하는가
다중 서명 파싱 및 서명 목록 추출 파서가 예외를 던질 위험이 있음
두 개 이상의 active secret을 임의로 생성하고 파싱 로직을 통과하는지 검증
planned
관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
2026-06-29 보정: governing_docs는 현재 존재하는 outbound HTTP canonical인 wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound를 가리킨다. 아래 표는 webhook outbound 세부 관심사 초안이며, /coverage feature-webhook-outbound-contract 재실행으로 canonical 요구사항 대비 covered/delegated/missing 판정을 갱신해야 한다.
FINDING-1 — Outbound HTTP Client 내 Redirect / Proxy 바인딩 코드 부재:
ca-tmpl 의 src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient/OutboundHttpRestClientFactory.java:21 을 확인한 결과, 단순히 HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).build() 로 HTTP 클라이언트를 생성하고 있음. (2026-07-21 실물 소스에서 재확인. 원래 인용은 repomix 덤프 ca-tmpl코드내용.xml 의 병합 행번호 L39733-39735 를 가리켰으나, 덤프는 재생성 시 행번호가 바뀌는 일회성 산출물이라 정본 경로로 교체.)
리다이렉트 정책은 JDK 기본값(Redirect.NEVER)에 의존해도 요구를 만족할 수 있으나, 코드에 명시되어 있지 않아 보안 계약이 리뷰/테스트 표면에 드러나지 않는다. Egress Proxy 설정을 바인딩하는 builder.proxy(...) 코드는 누락된 상태임.
권고: 본 branch note의 §구현 가이드 1에 명시된 대로 OutboundHttpSettings 및 OutboundHttpRestClientFactory 에 Egress Proxy 바인딩을 추가하고, redirect 차단은 명시 설정 + 테스트로 회귀를 방지해야 함.
FINDING-2 — Webhook 관련 에러 코드 및 레지스트리 설정 부재:
docs/registries/error-codes.yaml 에 webhook 전송 실패, SSRF 차단, 리다이렉트 차단과 관련된 에러 코드가 정의되지 않음.
권고: 본 branch note의 §Registry Updates 에 정의된 신규 YAML 설정을 레지스트리 파일에 통합해야 함.
Registry Updates (자체 명세)
본 branch merge 시, docs/registries/ 하위 파일들에 아래 항목을 반드시 추가/업데이트해야 합니다.
1. docs/registries/error-codes.yaml
# ============================================================# WEBHOOK OUTBOUND (feature-webhook-outbound-contract)# ============================================================- code:WEBHOOK_DELIVERY_FAILEDcategory:TRANSIENT_DEPENDENCYhttp_status:500retryable:trueretry_after_seconds:10owner_branch:feature-webhook-outbound-contractowner_layer:infrastructureclient_safe_message:"Webhook delivery attempt failed. Retrying..."log_level:WARNrunbook_link:"runbook://webhook/delivery-failed"compatibility_impact:nonerequired_test:contract-verification:webhook-retry-policy- code:WEBHOOK_SSRF_BLOCKEDcategory:CONFLICThttp_status:400retryable:falseretry_after_seconds:nullowner_branch:feature-webhook-outbound-contractowner_layer:infrastructureclient_safe_message:"Webhook target endpoint blocked due to SSRF policy"log_level:ERRORrunbook_link:"runbook://webhook/ssrf-blocked"compatibility_impact:nonerequired_test:contract-verification:webhook-ssrf-prevention- code:WEBHOOK_REDIRECT_BLOCKEDcategory:CONFLICThttp_status:400retryable:falseretry_after_seconds:nullowner_branch:feature-webhook-outbound-contractowner_layer:infrastructureclient_safe_message:"Webhook target redirected. Redirects are forbidden."log_level:ERRORrunbook_link:"runbook://webhook/redirect-blocked"compatibility_impact:nonerequired_test:contract-verification:webhook-redirect-blocked