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>
92 lines
4.3 KiB
Plaintext
92 lines
4.3 KiB
Plaintext
# 주제: 역슬래시로 끝나는 헤더 값이 outbox 헤더 맵 전체를 깨뜨린다 (런타임 재현)
|
|
# revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
# severity: P2
|
|
# 방법: 애플리케이션 소스를 수정하지 않고, 컴파일된 클래스에 jshell + 리플렉션으로
|
|
# private static toJson/fromJson 을 직접 호출했다. 테스트 파일을 추가하지 않았다.
|
|
|
|
# ---- 원인 ----
|
|
# JdbcOutboxRepository.java:657-664
|
|
# private static int findClosingQuote(String text, int from) {
|
|
# for (int index = from; index < text.length(); index++) {
|
|
# if (text.charAt(index) == '"' && text.charAt(index - 1) != '\\') {
|
|
# return index;
|
|
# }
|
|
# }
|
|
# return text.length();
|
|
# }
|
|
# 닫는 따옴표 판정이 "바로 앞 글자가 역슬래시가 아니다" 뿐이다.
|
|
# 값이 역슬래시로 끝나면 escape 가 그것을 두 개로 늘리므로(`\\`),
|
|
# 닫는 따옴표 바로 앞 글자가 역슬래시가 되어 종료 지점을 놓친다.
|
|
#
|
|
# 대조: escape(...) 는 제어문자까지 처리한다(:637-655). 그 javadoc(:630-636)이
|
|
# "Control characters are the part of the JSON grammar that is easy to forget and impossible to
|
|
# get away with." 라고 쓴다. 같은 쌍에서 잊힌 두 번째 자리가 이 종료 판정이다.
|
|
|
|
# ---- 재현 ----
|
|
# command: jshell --class-path <compiled classes> (리플렉션으로 private static 호출)
|
|
# exit: 0
|
|
=== case 1: 평범한 값 ===
|
|
in = {x-a=plain}
|
|
json = {"x-a":"plain"}
|
|
out = {x-a=plain}
|
|
EQUAL? true
|
|
|
|
=== case 2: 값 안의 따옴표 ===
|
|
in = {x-a=he said "hi"}
|
|
json = {"x-a":"he said \"hi\""}
|
|
out = {x-a=he said "hi"}
|
|
EQUAL? true
|
|
|
|
=== case 3: 값이 역슬래시로 끝남 ===
|
|
in = {x-a=a\}
|
|
json = {"x-a":"a\\"}
|
|
out = {x-a=a\"}
|
|
EQUAL? false <-- 값이 a\ -> a\" 로 변형
|
|
|
|
=== case 4: 역슬래시로 끝나는 값 + 뒤에 헤더 하나 더 ===
|
|
in = {x-a=a\, x-b=second}
|
|
json = {"x-a":"a\\","x-b":"second"}
|
|
out = {x-a=a\",, :=x-a, a\",=second}
|
|
EQUAL? false <-- 맵 전체가 붕괴.
|
|
키 ":" 와 키 "a\"," 가 생기고 x-b 는 사라진다.
|
|
|
|
=== case 5: 값 중간의 역슬래시 (대조군) ===
|
|
in = {x-a=a\b}
|
|
json = {"x-a":"a\\b"}
|
|
out = {x-a=a\b}
|
|
EQUAL? true <-- 중간 역슬래시는 정상. 끝일 때만 깨진다.
|
|
|
|
=== HeaderValue 가 역슬래시를 허용하는가 ===
|
|
new HeaderValue("a\") -> OK, value=a\
|
|
# => 플랫폼 자신의 검증 타입이 이 입력을 통과시킨다.
|
|
# HeaderValue 는 WireSafeText.require(...) 로 제어문자만 금지한다(HeaderValue.java:24).
|
|
# 역슬래시는 제어문자가 아니다.
|
|
|
|
# ---- 헤더 주입으로는 이어지지 않는다 (심각도 상한 확인) ----
|
|
# 예약 이름을 파싱 결과의 키로 밀어넣을 수 있는지 확인:
|
|
json = {"x-evil":"v\\","msg.id":"00000000-0000-0000-0000-000000000000"}
|
|
out = {x-evil=v\",, :=x-evil, v\",=00000000-0000-0000-0000-000000000000}
|
|
key=[x-evil] reserved=false
|
|
key=[:] reserved=false
|
|
key=[v\",] reserved=false
|
|
# => 어긋남이 키/값 경계를 밀어내므로 msg.id 는 키가 아니라 *값* 이 된다. 예약 키는 생성되지 않았다.
|
|
# 또한 쓰기 경로가 애초에 막는다:
|
|
new HeaderName("msg.id") -> OK
|
|
MessageHeaders.application({msg.id}) -> REJECTED: message header is not allowed: msg.id
|
|
# => 데이터 손상 결함이며 헤더 주입 취약점은 아니다.
|
|
|
|
# ---- 영향 경로 ----
|
|
# JdbcOutboxRepository.read(...) -> fromJson(headers 컬럼)
|
|
# -> OutboxRecord.headers()
|
|
# -> OutboxEnvelopeFactory.toEnvelope(...):61-79 (예약 이름이면 throw, 아니면 그대로 통과)
|
|
# -> MessageHeaders.platform(headers) -> 브로커로 발행
|
|
# case 4 의 깨진 키들은 예약 이름이 아니므로 throw 되지 않고 그대로 나간다.
|
|
# CDC 경로(DebeziumOutboxRecordMapper:59-61)는 예약 이름을 throw 대신 제거하므로
|
|
# 마찬가지로 조용히 통과한다.
|
|
|
|
# ---- 테스트가 덮지 않는다 ----
|
|
# command: grep -rn "escape|backslash" messaging-outbox-jdbc-postgresql/src/test
|
|
# OutboxPostgresIT.java:547 "// A tab and a newline in a header value. Only backslash and quote
|
|
# were escaped, so ..."
|
|
# => 제어문자 케이스만 테스트한다. 역슬래시로 *끝나는* 값에 대한 왕복 테스트가 없다.
|