The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
11 KiB
Markdown
140 lines
11 KiB
Markdown
---
|
|
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. 강한 함수의 프로덕션 호출처와, 호출처 도달을 고정하는 테스트의 대상 목록을 비교한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
웹푸시 구독의 엔드포인트 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 인 스위치에 걸려 있다.
|
|
|
|
그래서 지금 성립하는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 엔드포인트를 붙이는 순간 — 그것이 이 모듈의 존재 이유다 — 검증은 이미 통과되어 있다.
|
|
|
|
고칠 자리는 두 모듈 사이가 아니라 값 타입의 생성자 안이다. 컴포지션 루트 편의 표가 이 건에 배선 지점이 없다고 따로 적어 둔다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
포크가 붙일 구독 등록 경로의 실제 형태는 이 저장소에 없다. 그 경로에서 클라이언트 입력이 어떤 모양으로 도착하는지는 추정이다.
|
|
|
|
<!-- body:end -->
|