--- kind: CASE slug: a-weaker-private-copy-on-the-wired-path title: 강한 가드가 웹푸시를 지목하는데 값 타입은 약한 검사를 다시 썼다 topic: duplicate-mechanisms project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:a-weaker-private-copy-on-the-wired-path evidenceCapturedOn: 2026-09-02 assets: - key: a-weaker-private-copy-on-the-wired-path file: ../../../final/evidence/rendered/a-weaker-private-copy-on-the-wired-path.svg - key: a-weaker-private-copy-on-the-wired-path-probe file: ../../../final/evidence/rendered/a-weaker-private-copy-on-the-wired-path-probe.svg evidence: - ../../../final/evidence/raw/a-weaker-private-copy-on-the-wired-path.txt - ../../../final/evidence/raw/a-weaker-private-copy-on-the-wired-path-probe.txt source: - 분석 문서는 알림 어댑터 편 §25.1 이다. private 사본의 위치, 이웃 호출처의 주석, 호출처 테스트의 웹푸시 0, 그리고 blind SSRF 로서의 실패 시나리오가 그 절에 있다. 근거가 된 프로브는 같은 편 §24.2 이고, 컴포지션 루트 편 §12.2 의 표가 이 건을 리프 내부 문제로 분류한다. --- # 강한 가드가 웹푸시를 지목하는데 값 타입은 약한 검사를 다시 썼다 알림 모듈의 한 파일에 목적지 판정 함수가 둘 있다. 강한 쪽의 javadoc 은 자기가 웹푸시 엔드포인트와 웹훅 대상을 위해 쓰였다고 적는다. 웹푸시 값 타입은 둘 중 어느 것도 부르지 않고, 약한 쪽 논리를 자기 안에 private 으로 다시 썼다. ## 관계 - **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다** 이 사례가 그 규칙을 필요로 하는 형태다. - **sanitize가 아니라 reject가 기본이다** 강한 쪽 함수가 따르는 규칙이다. - **요청 식별자를 클라이언트가 고를 수 없다는 정책이 뒤에 도는 필터에 뒤집혔다** 같은 형태가 웹 어댑터에서 나타난 사례다. ## 문제 웹푸시 구독에서 엔드포인트 URI 를 정하는 쪽은 클라이언트다. 매퍼는 그 값을 그대로 POST 대상으로 쓴다. 값 타입의 컴팩트 생성자가 여덟을 검사한다. 널 아님 넷, 엔드포인트 하나, 키 길이 둘, 공백 하나다. 다섯째가 이 사례의 대상이다. ## 결론 다섯째 검사는 스킴이 https 이면 호스트를 읽지 않고 통과시킨다. 클라우드 메타데이터 주소도, RFC 1918 주소도, 링크 로컬 주소도, user:pw@ 를 단 주소도 실제로 통과한다. 값 여덟 개를 실제로 통과시켜 봤다. 같은 모듈의 NotificationEndpoints 가 그 문제를 이미 푼 함수를 갖고 있다. requireExternallyRoutable 은 userinfo 를 거절하고, 호스트가 내놓는 주소를 전부 해석해 하나라도 배포 네트워크 안쪽이면 거절한다. 그 함수의 javadoc 이 자기가 왜 있는지 적는데, 거기 웹푸시가 이름으로 적혀 있다. 값 타입은 그 함수를 부르지 않는다. 같은 파일의 약한 공개 함수도 부르지 않는다. 약한 쪽과 같은 논리를 private 메서드로 다시 썼고, 루프백 호스트 집합까지 같다. 그래서 약한 함수 이름으로 검색해도 이 호출처는 나오지 않는다. 이웃 호출처는 같은 결함을 이미 고쳤다. 웹훅 쪽 주석이 고치면서 무엇이 남았는지까지 적는다. 강한 가드는 바로 그 호출처를 위해 쓰였고 한동안 아무 데서도 불리지 않았으며, 자기 테스트는 통과하고 있었다는 것이다. 호출처 도달을 고정하려고 만든 테스트가 있는데, 그 클래스의 머리글이 대상을 웹훅과 SES 둘로 적는다. 가드 자신의 javadoc 이 적은 둘은 웹푸시와 웹훅이다. 두 목록이 어긋나 있고, 그 테스트 파일에 웹푸시를 언급하는 줄은 0 이다. 오늘 이 생성자가 도는 프로덕션 경로는 없다. 값을 만드는 main 코드는 한 곳이고 그것은 저장된 문자열을 되읽어 파싱하는 복호 경로다. 접수 유스케이스는 웹푸시 채널을 주소 문자열로 받기를 거절하고, 플랫폼 전체가 출하 기본값 false 인 스위치에 걸려 있다. 남는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 경로를 붙이는 순간 검증은 이미 통과되어 있다. ## 검증 환경 OpenJDK : 21.0.12 확인 방식 : 두 함수의 구현 대조, 컴파일된 값 타입에 목적지 여덟 개 투입, 호출처 계수 소스 수정 : x ## 재현 조건 1. 값 타입의 컴팩트 생성자가 하는 검사를 세고, 엔드포인트 검사가 몇 번째인지 확인한다. 2. 그 검사의 구현과 같은 모듈의 공개 함수 둘을 나란히 읽는다. 3. 값 타입이 그 둘 중 하나라도 부르는지 센다. 4. 컴파일된 값 타입에 내부망 주소와 메타데이터 주소를 직접 넣어 결과를 본다. 5. 강한 함수의 프로덕션 호출처와, 호출처 도달을 고정하는 테스트의 대상 목록을 비교한다. ## 본문 웹푸시 구독의 엔드포인트 URI 는 클라이언트가 정한다. 매퍼가 그것을 그대로 POST 대상으로 삼는다. ## 생성자가 여덟을 검사하고, 다섯째가 엔드포인트다 :::evidence key="a-weaker-private-copy-on-the-wired-path" alt="웹푸시 구독 값 타입의 컴팩트 생성자 전문과 그 다섯째 검사의 private 구현, 같은 모듈에 있는 공개 함수 둘의 구현, 그 강한 함수의 프로덕션 호출처 목록과 웹푸시 포함 여부, 그리고 호출처 도달을 고정하는 테스트의 머리글과 대상 목록을 차례로 출력한 터미널 기록." caption="생성자의 검사 여덟과 다섯째의 private 구현 · 같은 모듈의 공개 함수 둘 · 강한 함수의 호출처 둘에 웹푸시 없음 · 호출처 테스트의 웹푸시 언급 0 — 102줄 · exit 1" zoom="true" ::: 널 아님 넷을 확인하고, 다섯째로 엔드포인트가 안전하거나 루프백인지 본다. 그 뒤로 키 길이 둘과 VAPID 키 id 공백 여부가 더 온다. 다섯째 검사의 구현은 같은 파일의 private 메서드다. 스킴이 https 면 참을 돌려주고 끝난다. 호스트를 읽는 것은 http 인 경우뿐이고, 그때 루프백 셋 중 하나인지 본다. ## 그 검사는 자기 목적에 대해서는 옳다 메서드 javadoc 에는 다루는 위협이 경로상의 도청이고 루프백 엔드포인트에는 그 경로가 없다고 적혀 있다. 예외를 루프백으로 좁힌 것은 계약 시험이 진짜 소켓을 쓸 수 있게 하려는 것이다. 전송 보안 규칙으로서 이 판단은 유지된다. 다만 같은 모듈의 이웃 파일이 그 논리로 남은 결과를 주석에 적어 뒀고, 그 내용은 뒤에서 본다. ## 같은 모듈에 그 문제를 이미 푼 함수가 있다 `NotificationEndpoints` 가 목적지 판정 함수를 둘 갖는다. 약한 쪽 `requireSecureOrLoopback` 은 스킴을 보고 루프백 호스트 이름을 본다. 강한 쪽 `requireExternallyRoutable` 은 userinfo 를 거절하고, 호스트가 내놓는 주소를 전부 해석해 하나라도 루프백·링크 로컬·사설·멀티캐스트이거나 캐리어 NAT 대역이면 거절한다. 강한 쪽 javadoc 이 자기가 왜 있는지 적는다. ```text Web Push endpoints and webhook targets are supplied by clients, which makes this a server-side request forgery primitive ``` 웹푸시가 이름으로 적혀 있다. 그런데 그 함수의 프로덕션 호출처는 둘이고, SES 엔드포인트와 웹훅 대상이다. 웹푸시는 없다. 값 타입은 두 공개 함수 중 어느 것도 부르지 않는다. 약한 쪽과 같은 논리를 private 으로 다시 썼고, 루프백 호스트 집합 `127.0.0.1`, `::1`, `localhost` 까지 같다. 약한 함수 이름으로 검색하면 이 호출처는 드러나지 않는다. 호출처 도달을 고정하려고 만든 테스트도 있다. 그 클래스의 머리글이 대상을 웹훅 대상과 SES 엔드포인트 둘로 적는다. 가드 javadoc 이 적은 둘과 한 자리가 다르고, 그 파일에 웹푸시를 언급하는 줄은 0 이다. ## 내부망 주소를 넣으면 실제로 통과한다 :::evidence key="a-weaker-private-copy-on-the-wired-path-probe" alt="컴파일된 값 타입의 생성자에 목적지 여덟 개를 직접 넣어 통과와 거절을 출력한 결과, 이웃 호출처가 같은 결함을 고치며 남긴 주석, 그 엔드포인트가 POST 대상이 되는 지점, 값을 만드는 유일한 main 코드와 그 코드가 있는 복호 경로, 접수 유스케이스의 채널 거절, 그리고 플랫폼 마스터 스위치의 출하 기본값을 출력한 터미널 기록." caption="목적지 여덟 개 투입 결과 · 이웃 호출처의 주석 · POST 대상 지점 · 값 생성은 복호 경로 한 곳 · 마스터 스위치 기본값 false — 49줄 · exit 0" zoom="true" ::: 컴파일된 값 타입에 여덟 개를 넣었다. https 인 여섯은 전부 통과한다. 클라우드 메타데이터 주소 둘, 사설 대역, 사설 IPv6, 그리고 `user:pw@` 를 단 주소까지 지난다. 거절된 둘은 http 이고, 메시지는 루프백 밖에서는 https 여야 한다는 것이다. 이웃 호출처의 주석이 같은 결함을 고치며 무엇이 남았는지 적는다. ```text The stronger guard was written for exactly this call site and then called from nowhere: it existed, its own tests were green, and the two sites it was written for kept the weaker check. ``` 두 클라이언트 제공 표면 중 하나가 연결됐고, 다른 하나는 약한 검사의 사본을 그대로 들고 있다. ## 오늘 이 생성자가 도는 경로는 없다 값을 만드는 main 코드는 한 곳이고, 저장 경로가 아니라 저장된 것을 되읽는 경로다. 보호자의 `reveal` 이 저장 문자열을 파싱해 값을 되돌린다. 접수 유스케이스는 웹푸시 채널을 주소 문자열로 받기를 거절하고, 등록된 연락처를 가리키라고 말한다. 플랫폼 전체도 출하 기본값이 false 인 스위치에 걸려 있다. 그래서 지금 성립하는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 엔드포인트를 붙이는 순간 — 그것이 이 모듈의 존재 이유다 — 검증은 이미 통과되어 있다. 고칠 자리는 두 모듈 사이가 아니라 값 타입의 생성자 안이다. 컴포지션 루트 편의 표가 이 건에 배선 지점이 없다고 따로 적어 둔다. ## 확인하지 못한 것 포크가 붙일 구독 등록 경로의 실제 형태는 이 저장소에 없다. 그 경로에서 클라이언트 입력이 어떤 모양으로 도착하는지는 추정이다.