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>
224 lines
14 KiB
Markdown
224 lines
14 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a11-f007-dns
|
|
title: 혼동이라 적어 둔 이름이 아직 검사 문구에 있다
|
|
topic: http-client-and-resilience
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a11-f007-dns
|
|
evidenceCapturedOn: 2026-09-02
|
|
body: case-a11-f007-dns.body.md
|
|
assets:
|
|
- key: a11-f007-dns
|
|
file: ../../../final/evidence/rendered/a11-f007-dns.svg
|
|
- key: a11-f007-dns-overloads
|
|
file: ../../../final/evidence/rendered/a11-f007-dns-overloads.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a11-f007-dns.txt
|
|
- ../../../final/evidence/raw/a11-f007-dns-overloads.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L583 이다. 등급은 P3 이다. 블로킹 오버로드에만 그 검사와 주석이 있다는 대조, 반응형 오버로드는 동적 대상 안정만 본다는 지적, 그래서 주석이 혼동이라 적은 상태가 반응형 경로에 남아 있다는 판정, 출하된 두 반응형 전송의 두 플래그가 같은 값이라 지금 노출이 없다는 확인, 프로파일 검증기가 동적 모드와 실험적 전송 조합을 이미 거부한다는 사실, 위험이 열리는 조합과 수정 방향이 그 절에 있다.
|
|
- 이 기록이 더한 것은 넷이다. 36 의 문구가 자기가 읽지 않는 플래그의 이름을 쓴다는 것 — 혼동은 과거형이 아니다. 그래서 이 두 검사에 걸린 유일한 test 가 36 의 문구만으로 통과하므로 43 을 지워도 빨개지지 않는다는 것. 두 플래그 조합 넷을 실제로 넣어, 갈리는 것이 한 조합뿐이고 나머지 거부에서 반응형이 내놓는 문구가 검사하지 않은 능력의 이름이라는 것. 그리고 오늘 막고 있는 것이 프로파일 검증기가 아니라 반응형 제공자 맵에 전송이 하나뿐이라는 사실이고, 그 맵은 빈 부재 조건이라 배포가 갈아 끼울 수 있다는 것이다.
|
|
---
|
|
|
|
# 혼동이라 적어 둔 이름이 아직 검사 문구에 있다
|
|
|
|
전송 능력 검증기의 블로킹 오버로드에 동적 모드 검사가 둘 있다. 앞엣것은 안정 플래그를 읽으면서 뒤엣것이 지키는 능력의 이름을 문구에 쓴다. 반응형 오버로드에는 앞엣것만 있고, 그래서 그 문구가 반응형 거부에도 그대로 나온다.
|
|
|
|
## 관계
|
|
|
|
- **두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다**
|
|
두 사례 모두 오버로드가 둘인데 검사가 한쪽에만 있다.
|
|
- **재생 가능으로 인증된 본문이 다른 바이트를 낸다**
|
|
같은 리프의 다른 검사 사례다.
|
|
- **가드의 존재가 곧 테넌트 격리 보장은 아니다**
|
|
지금 노출이 없는 것과 검사가 있는 것은 다르다.
|
|
|
|
## 문제
|
|
|
|
능력 검증기는 블로킹용과 반응형용 오버로드를 따로 갖는다.
|
|
|
|
두 오버로드가 같은 검사를 하는지 대조했다.
|
|
|
|
## 결론
|
|
|
|
블로킹 쪽에만 있는 검사가 하나 있다.
|
|
|
|
동적 모드인데 호출 범위 검증된 DNS 고정 능력이 없으면 누락 목록에 넣는 검사다. 그 앞에는 같은 조건에서 동적 대상 안정 능력을 보는 검사가 따로 있다.
|
|
|
|
뒤엣것의 주석이 이유를 적는다. 그 능력 플래그가 모든 능력 레코드에 선언되어 있었지만 아무도 읽지 않았다는 것이다. 서버 측 요청 위조 방어의 주소 검증이 소켓까지 살아남는지를 결정하는 것이 그 능력이므로, 그것이 없는 전송은 동적 대상 안정 플래그가 무엇을 말하든 동적 대상을 서비스할 수 없다는 것이다. 둘이 혼동되고 있었다는 것이다.
|
|
|
|
그런데 앞엣것의 문구가 자기가 읽지 않는 플래그의 이름을 쓴다. 그 검사는 안정 플래그를 보면서 검증된 DNS 고정이 없다고 적고, 고정 플래그를 실제로 읽는 것은 뒤엣것이다. 주석이 지나간 일로 적은 혼동이 이 문구에는 아직 남아 있다.
|
|
|
|
그래서 하나뿐인 그 test 가 둘째 줄을 놓친다. 그 test 는 두 플래그가 모두 거짓인 능력을 넣고 메시지에 검증된 DNS 고정이 들어 있는지만 본다. 앞엣것의 문구만으로 맞는 조건이다. 뒤엣것을 통째로 지워도 초록이다.
|
|
|
|
반응형 오버로드에는 앞엣것만 있다.
|
|
|
|
실행으로 확인했다. 동적 프로파일 하나에 두 플래그 조합 넷을 넣었다. 둘 다 참이면 양쪽 통과다. 안정이 거짓인 두 조합은 양쪽 다 거부하는데, 그때 반응형이 내놓는 문구가 검증된 DNS 고정이 없다는 말이다. 고정이 거짓이고 안정이 참인 조합에서만 갈린다. 블로킹은 호출 범위 항목으로 거부하고 반응형은 통과한다.
|
|
|
|
지금 그 조합은 만들어지지 않는다. 다섯 능력 모두 두 플래그에 같은 값을 넣어 둔다. 셋은 둘 다 참이고 둘은 둘 다 거짓이다.
|
|
|
|
막고 있는 것은 프로파일 검증기가 아니다. 프로파일 검증기는 동적 모드와 두 전송 상수의 조합을 이름으로 막는데, 전송이 하나 늘면 그 목록에 없다. 오늘 이 오버로드에 닿는 능력이 하나뿐인 이유는 기본 자동 구성이 반응형 제공자 맵에 전송 하나만 넣기 때문이다.
|
|
|
|
그 맵은 빈 부재 조건이 붙어 있다. 자기 맵을 올리는 배포는 소스를 건드리지 않고 그 자리의 능력을 갈아 끼울 수 있다.
|
|
|
|
판정은 P3 다.
|
|
|
|
위험은 그렇게 올린 능력이 동적 대상 안정을 참으로, 호출 범위 검증된 DNS 고정을 거짓으로 선언하는 경우다. 주석이 못박은 정확히 그 조합이 반응형 쪽에서는 통과한다.
|
|
|
|
수정은 38~44 를 반응형 오버로드에 복사하는 것이다. 다만 출하된 다섯이 두 플래그를 같은 값으로 두고 있으므로 오늘 바뀌는 판정은 없다.
|
|
|
|
반응형 레코드에는 어느 검사도 읽지 않는 플래그가 둘 더 있다. 주석이 적은 상태가 그 둘에는 그대로다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 두 오버로드 대조, 플래그 참조 전수 확인, 실행 탐침
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 전송 능력 검증기의 두 오버로드를 나란히 읽는다.
|
|
2. 블로킹 쪽 마지막 두 검사가 각각 어떤 플래그를 읽고 어떤 문구를 넣는지 대조한다.
|
|
3. 그 두 검사에 걸린 test 를 찾아 무엇을 단언하는지 읽는다.
|
|
4. 세 능력 플래그를 읽는 곳을 저장소 전체에서 센다.
|
|
5. 출하된 능력 다섯이 두 플래그에 넣는 값을 뽑는다.
|
|
6. 반응형 제공자 맵에 어떤 전송이 들어가는지, 그 빈에 어떤 조건이 붙어 있는지 읽는다.
|
|
7. 프로파일 검증기가 동적 모드에 대해 이름으로 막는 전송을 확인한다.
|
|
8. 동적 프로파일 하나에 두 플래그 조합 넷을 넣어 양쪽 오버로드에 통과시킨다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
전송 능력 검증기에 오버로드가 둘 있다.
|
|
|
|
## 한쪽에만 있는 검사
|
|
|
|
:::evidence key="a11-f007-dns" alt="전송 능력 검증기의 블로킹 오버로드에 있는 마지막 두 검사와 그 사이의 주석, 반응형 오버로드의 마지막 검사, 그 두 검사에 걸린 유일한 test 가 단언하는 것, 세 능력 플래그를 읽는 곳 전부, 출하된 능력 다섯이 두 플래그에 넣는 값, 반응형 제공자 맵에 들어가는 전송과 그 빈에 붙은 조건, 그리고 프로파일 검증기가 동적 모드에 대해 이름으로 막는 전송을 출력한 터미널 기록." caption="36 은 안정 플래그를 읽으면서 검증된 DNS 고정이라는 문구를 넣고, 고정 플래그를 읽는 것은 43 이다 · test 는 검증된 DNS 고정이라는 문자열만 확인한다 · 출하된 능력 다섯이 두 플래그를 같은 값으로 선언한다 · 반응형 제공자 맵에는 REACTOR_NETTY 하나가 들어가고 그 빈은 빈 부재 조건이다 — 68줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
블로킹 쪽의 마지막 두 검사다.
|
|
|
|
```java
|
|
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: }
|
|
```
|
|
|
|
반응형 쪽은 여기서 끝난다.
|
|
|
|
```java
|
|
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 을 붙들지 못한다.
|
|
|
|
```java
|
|
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"
|
|
:::
|
|
|
|
```text
|
|
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 고정이 없다는 문구다. 검사한 것은 다른 플래그다.
|
|
|
|
갈리는 것은 마지막 줄뿐이다.
|
|
|
|
## 지금 그 조합은 만들어지지 않는다
|
|
|
|
```text
|
|
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 를 복사해도 오늘 바뀌는 판정은 없다.
|
|
|
|
## 막고 있는 것은 프로파일 검증기가 아니다
|
|
|
|
```text
|
|
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"));
|
|
```
|
|
|
|
이 검사가 이름으로 막는 것은 두 상수다. 전송이 하나 늘면 여기에는 걸리지 않는다.
|
|
|
|
오늘 이 오버로드에 닿는 능력이 하나뿐인 이유는 다른 데 있다.
|
|
|
|
```java
|
|
@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,
|
|
```
|
|
|
|
맵에 들어가는 전송이 하나다. 그리고 그 빈에는 빈 부재 조건이 붙어 있다. 배포가 자기 맵을 올리면 소스를 갈라내지 않고 다른 능력을 그 자리에 넣을 수 있다.
|
|
|
|
## 아직 아무도 읽지 않는 플래그
|
|
|
|
```text
|
|
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` 에 대해 적은 상태가 이 둘에는 손대지 않은 채로 있다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
배포가 제공자 맵을 실제로 갈아 끼워 그 조합을 기동시키지 않았다. 확인한 것은 기동 검증기의 판정까지다.
|
|
|
|
<!-- body:end -->
|