Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/notification-and-delivery/case/case-a13-f008-host.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

12 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE a13-f008-host 포트를 뺀 호스트를 서명하는 자리가 둘이고, 같은 저장소의 네 자리는 그 포트를 지킨다 notification-and-delivery clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a13-f008-host 2026-09-02 case-a13-f008-host.body.md
key file
a13-f008-host ../../../final/evidence/rendered/a13-f008-host.svg
key file
a13-f008-host-wire ../../../final/evidence/rendered/a13-f008-host-wire.svg
../../../final/evidence/raw/a13-f008-host.txt
../../../final/evidence/raw/a13-f008-host-wire.txt
원본 분석 절은 final/document.md#a13#L831 이다.

포트를 뺀 호스트를 서명하는 자리가 둘이고, 같은 저장소의 네 자리는 그 포트를 지킨다

SES 요청 매퍼는 서명할 때 엔드포인트의 포트를 버린다. 전선에 나가는 값은 표준 라이브러리가 URI 에서 채우므로 기본이 아닌 포트가 거기 붙는다. 웹푸시 서명기도 같은 식을 쓰고, 그 포트를 지키는 코드는 같은 저장소에 네 벌 있다.

관계

  • 지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다 같은 SigV4 경로의 다른 자리다.
  • 로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다 어느 결함이 어느 환경에서만 보이는지 적어 둔다는 규칙이 같다.
  • 음수 Retry-After 를 거르지 않는 파서 셋이 조립되지 않아 결함이 잠재로 남는다 같은 리프에서 조립 여부가 등급을 정한 사례다.

문제

서명 방식은 정규 요청에 실제로 전송되는 호스트 헤더 값을 담도록 요구하고, 기본이 아닌 포트는 그 값에 든다. 매퍼가 무엇을 서명하는지, 그리고 전선에 무엇이 나가는지 확인했다.

결론

매퍼는 엔드포인트에서 호스트만 꺼내 서명 대상 헤더 맵에 넣는다.

요청 자체가 싣는 헤더는 넷이고 그 안에 호스트가 없다. 매퍼가 넣지 않기 때문이다. 게이트웨이의 제한 목록이 막는 것은 다른 경우다. 공급된 헤더를 막는다.

왕복을 한 번 쟀다. 로컬 서버를 띄우고 같은 게이트웨이로 세 가지 헤더 모양을 보냈다. 매퍼가 실제로 보내는 넷만 넣었을 때도, 거기에 호스트를 더했을 때도, 대문자 키로 더했을 때도 서버가 받은 값은 같았고 서명 대상과 달랐다.

어긋나는 경우를 가르는 것은 권한부가 아니다. 권한부는 기본 포트를 적기만 해도, 사용자 정보가 붙기만 해도 호스트와 달라진다. 엔드포인트 여섯에 규칙을 적용해 보면 권한부와 갈리는 것이 다섯인데 전선의 값과 갈리는 것은 둘뿐이다. 적힌 포트가 스킴의 기본이 아닐 때에만 어긋난다. 이 표는 실행이 아니라 계산이다.

두 번째 자리가 있다. 웹푸시 서명기가 대상 클레임을 스킴과 호스트로만 조립한다. 그 클레임은 규격상 출처이고 출처에는 기본이 아닌 포트가 들어간다. 원문에는 이 자리가 없다.

지켜야 할 판정은 이미 저장소에 네 벌 쓰여 있다. 인바운드 웹의 콜백 URL 재구성과 출처 조립 둘, 그리고 아웃바운드 httpclient 의 정규 대상 하나다. 방향으로 갈리는 문제가 아니다. 그중 콜백 쪽 javadoc 은 이것을 틀리면 정상 웹훅이 전부 서명 실패로 뒤집힌다고 적는다.

엔드포인트 가드에는 포트를 읽는 줄이 하나도 없다. 비표준 포트를 단 엔드포인트가 어디서도 걸리지 않는다는 뜻이다. SES 속성이 루프백 허용을 상수 참으로 넘기므로 프로파일로 좁혀지지도 않는다.

이미 벌어지고 있는 일이기도 하다. 제공자 시험 대역이 임시 포트로 주소를 만들고, SES 와 웹푸시 계약 시험이 그 주소를 실제 매퍼에 그대로 먹인다. 검증하는 쪽이 없어 시험은 통과한다. 서명한 호스트 값을 단언하는 시험도 없다.

수정은 두 자리 모두에 필요하다.

판정은 P3 다. 근거가 둘이다. 하나는 SES 어댑터가 이 빌드에서 조립되지 않는다는 것이다. main 의 조립기는 SMTP 하나뿐이다. 다른 하나는 조립되더라도 실제 SES 가 443 이라 두 값이 같다는 것이다.

조립되고, 엔드포인트가 비기본 포트이며, 그 엔드포인트가 서명을 검증하면 그 SES 프로파일로 나가는 발송이 전부 거절된다. 403 은 인가 실패로 분류되어 재시도도 대체 경로도 붙지 않는다. 그 조건에서는 P2 다. 첫째가 없으므로 나머지 둘은 이 빌드에서 물을 수조차 없다. 그래서 한 단계 내린다. 배선되지 않은 발견을 도달 가능한 등급에서 한 단계 내리는 것은 이 저장소가 websocket 리프에서 쓴 방식이다.

검증 환경

OpenJDK : 21.0.12 확인 방식 : 서명 대상 식과 전송 값 대조, 로컬 서버로 헤더 세 모양 왕복, 기본 포트만 생략하는 조립 자리 전수 검색 소스 수정 : x

재현 조건

이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 #L831 절이다.

  1. 매퍼가 호스트를 꺼내는 식과 그 값이 들어가는 헤더 맵을 읽는다.
  2. 요청이 실제로 싣는 헤더 목록을 읽는다.
  3. 게이트웨이의 제한 목록에 호스트가 있는지 확인한다.
  4. 로컬 서버를 띄우고 헤더 세 모양으로 요청을 보내 서버가 받은 값을 읽는다.
  5. 엔드포인트마다 전선의 값과 권한부가 각각 서명 대상과 갈리는지 계산한다.
  6. 엔드포인트 가드가 포트를 읽는 줄이 있는지 센다.
  7. 임시 포트 주소를 실제 매퍼에 먹이는 시험 자리를 찾는다.
  8. 출처나 호스트를 문자열로 조립하면서 기본 포트만 생략하는 자리를 전수로 찾는다.

본문

SigV4 의 정규 요청은 실제로 전송되는 Host 헤더 값을 서명해야 한다. 매퍼가 서명하는 것은 다른 값이다.

// SesRequestMapper.java:103-110
    String host = properties.endpoint().getHost();

    var signed =
        signer.sign(
            "POST",
            PATH,
            "",
            Map.of("host", host, "content-type", "application/json"),

요청은 그 헤더를 싣지 않는다

:::evidence key="a13-f008-host" alt="코드베이스 정적 검색 출력 46줄. 매퍼가 서명 대상에 넣는 값과 요청에 싣는 헤더 넷, 게이트웨이의 제한 목록, 엔드포인트 가드에서 포트를 읽는 줄을 찾은 결과와 SES 속성이 그 가드를 부르는 줄, 임시 포트 주소를 만드는 대역과 그것을 먹이는 시험 자리 일곱, 같은 식의 다른 서명기, 기본 포트만 생략하는 조립 자리를 두 문자열로 검색한 결과가 차례로 보인다." caption="서명 대상을 정하는 자리와 그 값이 나가지 못하는 자리 — 46줄 · exit 0" zoom="true" :::

SesRequestMapper:122-126이 요청에 넣는 헤더는 content-type, x-amz-date, x-amz-content-sha256, authorization 넷이다. 서명한 host 는 거기 없다. 매퍼가 넣지 않아서이지 무엇이 걸러서가 아니다.

이것은 매퍼가 넣지 않는다는 사실과 별개다. 누가 그 헤더를 공급하더라도 전선에 닿지 못한다.

// JdkNotificationHttpGateway.java:26-27, :54
      Set.of("connection", "content-length", "expect", "host", "upgrade");
              if (!RESTRICTED.contains(name)) {

세 가지 헤더 모양으로 보내 봤다

:::evidence key="a13-f008-host-wire" alt="JVM 프로브 출력 18줄. 첫 줄은 자바 판이고, 로컬 서버에 헤더 세 모양으로 요청을 보내 서버가 받은 Host 를 적은 결과와, 엔드포인트 여섯에 대해 전선의 값과 권한부가 각각 서명 대상과 갈리는지 표시한 표가 나온다. 포트는 실행마다 달라 자리표시로 바꿨다." caption="서버가 받은 Host 와 갈리는 조건 — 18줄 · exit 0" zoom="true" :::

앞 묶음이 왕복이다. 로컬 서버를 띄우고 같은 JdkNotificationHttpGatewayPOST 를 세 번 보냈다. 첫 번째는 매퍼가 실제로 넣는 헤더 넷만, 두 번째는 거기에 host 를 더해서, 세 번째는 대문자 Host 로 더해서 보냈다. 셋 다 서버가 받은 값이 127.0.0.1:<포트> 였고 서명 대상 127.0.0.1 과 달랐다.

둘째와 셋째는 매퍼가 하지 않는 일이다. 제한 목록이 실제로 거르는지, 그리고 대문자 키에도 걸리는지를 함께 재려고 일부러 더 넣었다. NotificationHttpRequest 가 정규 생성자에서 키를 소문자로 바꾸므로 대문자도 같은 자리에 걸린다.

갈리는 조건은 권한부가 아니다

뒤 표가 그것이다. 전선의 값과 갈리는 엔드포인트는 :8443localhost:4566 둘뿐인데, 권한부와 갈리는 것은 다섯이다. 이 표는 왕복이 아니라 ExternalRequestUrlResolver 의 판정을 엔드포인트마다 적용한 계산이다.

https://email.eu-central-1.amazonaws.com:443 은 기본 포트를 적었을 뿐이라 전선의 값은 호스트 그대로다. http://127.0.0.1:80 도 같다. https://user@email.example.com 은 포트가 아예 없는데 사용자 정보 때문에 권한부가 달라진다. 세 경우 모두 서명은 어긋나지 않는다.

어긋나는 것은 포트가 적혀 있고 그 포트가 스킴의 기본이 아닌 엔드포인트뿐이다.

옳은 식은 이미 저장소에 있다

// ExternalRequestUrlResolver.java:57-63
    boolean defaultPort =
        port < 0
            || ("https".equalsIgnoreCase(scheme) && port == 443)
            || ("http".equalsIgnoreCase(scheme) && port == 80);
    if (!defaultPort) {

그 클래스의 javadoc 이 이유를 적어 뒀다.

Several providers sign the request URL, so getting this wrong turns every valid webhook into a signature failure.

같은 판정이 ExternalOrigin:43, ExternalRequestContext:57, 그리고 아웃바운드 httpclient 의 CanonicalTarget:39 에도 있다. 자료의 마지막 묶음은 port == 443port == 80 두 문자열로 훑은 것이다. 네 자리가 모두 포트 비교를 port == 443port == 80 으로 적어서 이 검색에 다 잡힌다. 스킴 비교는 셋이 서로 다른 형태다. 삼항으로 기본값을 고르는 형태는 걸리지 않으므로, 이 넷은 기본 포트를 판정하는 자리 전부가 아니라 출처를 문자열로 조립하면서 기본 포트만 생략하는 자리 전부다. 인바운드와 아웃바운드로 갈리지 않는다. 규칙을 안 쓰는 것이 이 알림 리프의 두 서명 자리다.

같은 어긋남이 한 자리 더 있다

// VapidJwtSigner.java:50
    String audience = endpoint.getScheme() + "://" + endpoint.getHost();

VAPID 의 aud 는 푸시 서비스의 출처이고, 출처는 기본이 아닌 포트를 포함한다. 원문은 SES 한 자리를 셌다.

가드는 포트를 보지 않는다

자료의 네 번째 묶음이 그것이다. NotificationEndpoints 에는 getPort 를 읽는 줄이 없다. 그래서 라우팅 가능한 https 엔드포인트에 :8443 을 달아도 어느 프로파일에서든 통과한다.

// SesProviderProperties.java:27
    NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);

세 번째 인자는 상수 참이다. NotificationEndpoints:61의 javadoc 이 그 인자를 로컬과 계약 프로파일용이라고 적지만, 그 범위를 강제하는 코드는 없다.

이 어긋남은 오늘 이미 일어난다

// ProviderFaultHarness.java:52
    return URI.create("http://127.0.0.1:" + server.getAddress().getPort());

SesNotificationProviderAdapterTest:254ContractAdapters:102 가 그 주소를 실제 SesRequestMapper 에 넘긴다. 웹푸시 쪽은 WebPushProviderAdapterTest:173ContractAdapters:264 가 같은 주소로 aud 를 만든다. 자료의 나머지 줄은 호스트를 서명하지 않는 어댑터들이다. 대역이 서명을 검증하지 않으므로 초록으로 지나갈 뿐이다.

확인하지 못한 것

서명을 검증하는 대역을 띄워 거절 응답까지 받아 보지는 않았다. 잰 것은 왕복 하나이고, 엔드포인트별 갈림은 계산이다. 웹푸시 쪽은 식과 규격만 읽었고 실제 토큰을 만들어 대조하지는 않았다.