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>
11 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | assets | evidence | source | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a-weaker-private-copy-on-the-wired-path | 강한 가드가 웹푸시를 지목하는데 값 타입은 약한 검사를 다시 썼다 | duplicate-mechanisms | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a-weaker-private-copy-on-the-wired-path | 2026-09-02 |
|
|
|
강한 가드가 웹푸시를 지목하는데 값 타입은 약한 검사를 다시 썼다
알림 모듈의 한 파일에 목적지 판정 함수가 둘 있다. 강한 쪽의 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
재현 조건
- 값 타입의 컴팩트 생성자가 하는 검사를 세고, 엔드포인트 검사가 몇 번째인지 확인한다.
- 그 검사의 구현과 같은 모듈의 공개 함수 둘을 나란히 읽는다.
- 값 타입이 그 둘 중 하나라도 부르는지 센다.
- 컴파일된 값 타입에 내부망 주소와 메타데이터 주소를 직접 넣어 결과를 본다.
- 강한 함수의 프로덕션 호출처와, 호출처 도달을 고정하는 테스트의 대상 목록을 비교한다.
본문
웹푸시 구독의 엔드포인트 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 이 자기가 왜 있는지 적는다.
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 여야 한다는 것이다.
이웃 호출처의 주석이 같은 결함을 고치며 무엇이 남았는지 적는다.
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 인 스위치에 걸려 있다.
그래서 지금 성립하는 것은 계약이다. 이 레코드의 표준 생성자가 이 값의 유일한 검증 지점이고, 포크가 구독 등록 엔드포인트를 붙이는 순간 — 그것이 이 모듈의 존재 이유다 — 검증은 이미 통과되어 있다.
고칠 자리는 두 모듈 사이가 아니라 값 타입의 생성자 안이다. 컴포지션 루트 편의 표가 이 건에 배선 지점이 없다고 따로 적어 둔다.
확인하지 못한 것
포크가 붙일 구독 등록 경로의 실제 형태는 이 저장소에 없다. 그 경로에서 클라이언트 입력이 어떤 모양으로 도착하는지는 추정이다.