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>
14 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 | a11-f007-dns | 혼동이라 적어 둔 이름이 아직 검사 문구에 있다 | http-client-and-resilience | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a11-f007-dns | 2026-09-02 | case-a11-f007-dns.body.md |
|
|
|
혼동이라 적어 둔 이름이 아직 검사 문구에 있다
전송 능력 검증기의 블로킹 오버로드에 동적 모드 검사가 둘 있다. 앞엣것은 안정 플래그를 읽으면서 뒤엣것이 지키는 능력의 이름을 문구에 쓴다. 반응형 오버로드에는 앞엣것만 있고, 그래서 그 문구가 반응형 거부에도 그대로 나온다.
관계
- 두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다 두 사례 모두 오버로드가 둘인데 검사가 한쪽에만 있다.
- 재생 가능으로 인증된 본문이 다른 바이트를 낸다 같은 리프의 다른 검사 사례다.
- 가드의 존재가 곧 테넌트 격리 보장은 아니다 지금 노출이 없는 것과 검사가 있는 것은 다르다.
문제
능력 검증기는 블로킹용과 반응형용 오버로드를 따로 갖는다.
두 오버로드가 같은 검사를 하는지 대조했다.
결론
블로킹 쪽에만 있는 검사가 하나 있다.
동적 모드인데 호출 범위 검증된 DNS 고정 능력이 없으면 누락 목록에 넣는 검사다. 그 앞에는 같은 조건에서 동적 대상 안정 능력을 보는 검사가 따로 있다.
뒤엣것의 주석이 이유를 적는다. 그 능력 플래그가 모든 능력 레코드에 선언되어 있었지만 아무도 읽지 않았다는 것이다. 서버 측 요청 위조 방어의 주소 검증이 소켓까지 살아남는지를 결정하는 것이 그 능력이므로, 그것이 없는 전송은 동적 대상 안정 플래그가 무엇을 말하든 동적 대상을 서비스할 수 없다는 것이다. 둘이 혼동되고 있었다는 것이다.
그런데 앞엣것의 문구가 자기가 읽지 않는 플래그의 이름을 쓴다. 그 검사는 안정 플래그를 보면서 검증된 DNS 고정이 없다고 적고, 고정 플래그를 실제로 읽는 것은 뒤엣것이다. 주석이 지나간 일로 적은 혼동이 이 문구에는 아직 남아 있다.
그래서 하나뿐인 그 test 가 둘째 줄을 놓친다. 그 test 는 두 플래그가 모두 거짓인 능력을 넣고 메시지에 검증된 DNS 고정이 들어 있는지만 본다. 앞엣것의 문구만으로 맞는 조건이다. 뒤엣것을 통째로 지워도 초록이다.
반응형 오버로드에는 앞엣것만 있다.
실행으로 확인했다. 동적 프로파일 하나에 두 플래그 조합 넷을 넣었다. 둘 다 참이면 양쪽 통과다. 안정이 거짓인 두 조합은 양쪽 다 거부하는데, 그때 반응형이 내놓는 문구가 검증된 DNS 고정이 없다는 말이다. 고정이 거짓이고 안정이 참인 조합에서만 갈린다. 블로킹은 호출 범위 항목으로 거부하고 반응형은 통과한다.
지금 그 조합은 만들어지지 않는다. 다섯 능력 모두 두 플래그에 같은 값을 넣어 둔다. 셋은 둘 다 참이고 둘은 둘 다 거짓이다.
막고 있는 것은 프로파일 검증기가 아니다. 프로파일 검증기는 동적 모드와 두 전송 상수의 조합을 이름으로 막는데, 전송이 하나 늘면 그 목록에 없다. 오늘 이 오버로드에 닿는 능력이 하나뿐인 이유는 기본 자동 구성이 반응형 제공자 맵에 전송 하나만 넣기 때문이다.
그 맵은 빈 부재 조건이 붙어 있다. 자기 맵을 올리는 배포는 소스를 건드리지 않고 그 자리의 능력을 갈아 끼울 수 있다.
판정은 P3 다.
위험은 그렇게 올린 능력이 동적 대상 안정을 참으로, 호출 범위 검증된 DNS 고정을 거짓으로 선언하는 경우다. 주석이 못박은 정확히 그 조합이 반응형 쪽에서는 통과한다.
수정은 38~44 를 반응형 오버로드에 복사하는 것이다. 다만 출하된 다섯이 두 플래그를 같은 값으로 두고 있으므로 오늘 바뀌는 판정은 없다.
반응형 레코드에는 어느 검사도 읽지 않는 플래그가 둘 더 있다. 주석이 적은 상태가 그 둘에는 그대로다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 두 오버로드 대조, 플래그 참조 전수 확인, 실행 탐침 소스 수정 : x
재현 조건
- 전송 능력 검증기의 두 오버로드를 나란히 읽는다.
- 블로킹 쪽 마지막 두 검사가 각각 어떤 플래그를 읽고 어떤 문구를 넣는지 대조한다.
- 그 두 검사에 걸린 test 를 찾아 무엇을 단언하는지 읽는다.
- 세 능력 플래그를 읽는 곳을 저장소 전체에서 센다.
- 출하된 능력 다섯이 두 플래그에 넣는 값을 뽑는다.
- 반응형 제공자 맵에 어떤 전송이 들어가는지, 그 빈에 어떤 조건이 붙어 있는지 읽는다.
- 프로파일 검증기가 동적 모드에 대해 이름으로 막는 전송을 확인한다.
- 동적 프로파일 하나에 두 플래그 조합 넷을 넣어 양쪽 오버로드에 통과시킨다.
본문
전송 능력 검증기에 오버로드가 둘 있다.
한쪽에만 있는 검사
:::evidence key="a11-f007-dns" alt="전송 능력 검증기의 블로킹 오버로드에 있는 마지막 두 검사와 그 사이의 주석, 반응형 오버로드의 마지막 검사, 그 두 검사에 걸린 유일한 test 가 단언하는 것, 세 능력 플래그를 읽는 곳 전부, 출하된 능력 다섯이 두 플래그에 넣는 값, 반응형 제공자 맵에 들어가는 전송과 그 빈에 붙은 조건, 그리고 프로파일 검증기가 동적 모드에 대해 이름으로 막는 전송을 출력한 터미널 기록." caption="36 은 안정 플래그를 읽으면서 검증된 DNS 고정이라는 문구를 넣고, 고정 플래그를 읽는 것은 43 이다 · test 는 검증된 DNS 고정이라는 문자열만 확인한다 · 출하된 능력 다섯이 두 플래그를 같은 값으로 선언한다 · 반응형 제공자 맵에는 REACTOR_NETTY 하나가 들어가고 그 빈은 빈 부재 조건이다 — 68줄 · exit 0" zoom="true" :::
블로킹 쪽의 마지막 두 검사다.
35: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
36: missing.add("validated DNS pinning for dynamic targets");
37: }
38: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
39: // `validatedDnsPinning` was declared on every capability record and read by nothing. It is
40: // the capability that decides whether the SSRF address validation survives to the socket, so
41: // a transport that does not have it cannot serve a dynamic target no matter what its
42: // `dynamicTargetStable` flag says — the two were being conflated.
43: missing.add("call-scoped validated DNS pinning");
44: }
반응형 쪽은 여기서 끝난다.
64: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
65: missing.add("validated DNS pinning for dynamic targets");
66: }
67: reject(profile, missing);
68: }
36 의 문구가 가리키는 플래그
36 은 자기가 읽는 플래그를 가리키지 않는다. 그 검사가 보는 것은 dynamicTargetStable 인데 적히는 말은 검증된 DNS 고정이고, validatedDnsPinning 을 실제로 읽는 검사는 43 이다. 주석이 과거형으로 적은 혼동이 이 문구에 남아 있다.
그래서 이 두 검사에 걸린 유일한 test 가 43 을 붙들지 못한다.
39: void rejectsDynamicModeWithoutValidatedPinning() {
40- ClientProfile dynamic = ClientProfiles.builder("webhook").mode(ClientMode.DYNAMIC).build();
41- assertThatThrownBy(
42- () ->
43- validator.validate(
44- dynamic, BlockingTransportCapabilities.lightweightHttp11AndHttp2()))
45- .isInstanceOf(HttpConfigurationException.class)
46- .hasMessageContaining("validated DNS pinning");
넣는 능력은 두 플래그가 모두 거짓이고, 단언은 메시지에 그 문자열이 있는지다. 36 의 문구만으로 맞는다. 38~44 를 통째로 지워도 이 test 는 초록이다.
넣어 보면
:::evidence key="a11-f007-dns-overloads" alt="동적 대상 프로파일 하나에 두 플래그 조합 넷을 넣어, 블로킹 오버로드와 반응형 오버로드가 각각 통과시키는지 거부하는지와 거부할 때 적히는 누락 항목을 출력한 터미널 기록. 출하된 두 반응형 전송의 두 플래그 값도 함께 적는다. 픽스처는 고정 리비전 소스에서 직접 컴파일한다." caption="안정이 거짓인 두 조합은 양쪽 다 거부하고 반응형 문구는 검증된 DNS 고정이 없다는 말이다 · 고정이 거짓이고 안정이 참인 조합에서만 블로킹이 거부하고 반응형이 통과한다 — 20줄 · exit 0" zoom="true" :::
pinning=true stable=true
블로킹 : 통과
반응형 : 통과
pinning=false stable=false
블로킹 : 거부 — validated DNS pinning for dynamic targets, call-scoped validated DNS pinning
반응형 : 거부 — validated DNS pinning for dynamic targets
pinning=true stable=false
블로킹 : 거부 — validated DNS pinning for dynamic targets
반응형 : 거부 — validated DNS pinning for dynamic targets
pinning=false stable=true
블로킹 : 거부 — call-scoped validated DNS pinning
반응형 : 통과
안정이 거짓인 두 조합은 양쪽 다 거부한다. 그때 반응형이 운영자에게 내놓는 말이 검증된 DNS 고정이 없다는 문구다. 검사한 것은 다른 플래그다.
갈리는 것은 마지막 줄뿐이다.
지금 그 조합은 만들어지지 않는다
apacheClassic validatedDnsPinning=true dynamicTargetStable=true
http11AndHttp2 validatedDnsPinning=true dynamicTargetStable=true
lightweightHttp11AndHttp2 validatedDnsPinning=false dynamicTargetStable=false
reactorNetty validatedDnsPinning=true dynamicTargetStable=true
jettyHttp3Experimental validatedDnsPinning=false dynamicTargetStable=false
출하된 능력 다섯이 전부 두 플래그를 같은 값으로 선언한다. 38~44 를 복사해도 오늘 바뀌는 판정은 없다.
막고 있는 것은 프로파일 검증기가 아니다
173- if (profile.mode() == ClientMode.DYNAMIC
174- && (profile.transport() == TransportType.JDK
175- || profile.transport() == TransportType.JETTY)) {
176: out.add(violation("DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED", profile, "transport"));
이 검사가 이름으로 막는 것은 두 상수다. 전송이 하나 늘면 여기에는 걸리지 않는다.
오늘 이 오버로드에 닿는 능력이 하나뿐인 이유는 다른 데 있다.
@Bean
@ConditionalOnMissingBean(name = "httpClientReactiveTransportProviders")
Map<TransportType, ReactiveTransportProvider> httpClientReactiveTransportProviders(
ObjectProvider<MeterRegistry> meterRegistry, TlsMaterialProvider tlsMaterialProvider) {
Map<TransportType, ReactiveTransportProvider> providers = new EnumMap<>(TransportType.class);
providers.put(
TransportType.REACTOR_NETTY,
맵에 들어가는 전송이 하나다. 그리고 그 빈에는 빈 부재 조건이 붙어 있다. 배포가 자기 맵을 올리면 소스를 갈라내지 않고 다른 능력을 그 자리에 넣을 수 있다.
아직 아무도 읽지 않는 플래그
TransportCapabilityValidator.java:38: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
ReactiveTransportCapabilities.java:14: boolean validatedDnsPinning,
ReactiveTransportCapabilities.java:16: boolean serverSentEvents,
ReactiveTransportCapabilities.java:17: boolean cancellationReleasesConnection) {
BlockingTransportCapabilities.java:19: boolean validatedDnsPinning,
serverSentEvents 와 cancellationReleasesConnection 은 선언 줄 말고 나오는 곳이 없다. 39 의 주석이 validatedDnsPinning 에 대해 적은 상태가 이 둘에는 손대지 않은 채로 있다.
확인하지 못한 것
배포가 제공자 맵을 실제로 갈아 끼워 그 조합을 기동시키지 않았다. 확인한 것은 기동 검증기의 판정까지다.