docs(keycloak-session-store): import the session-storage lab as a new project
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+272
@@ -0,0 +1,272 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a13-f002-authentication-failed-resumehealthy
|
||||
title: 두 호출이 상태와 함께 경보를 끈다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a13-f002-authentication-failed-resumehealthy
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a13-f002-authentication-failed-resumehealthy.body.md
|
||||
assets:
|
||||
- key: a13-f002-authentication-failed-resumehealthy
|
||||
file: ../../../final/evidence/rendered/a13-f002-authentication-failed-resumehealthy.svg
|
||||
- key: a13-f002-authentication-failed-resumehealthy-sequence
|
||||
file: ../../../final/evidence/rendered/a13-f002-authentication-failed-resumehealthy-sequence.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a13-f002-authentication-failed-resumehealthy.txt
|
||||
- ../../../final/evidence/raw/a13-f002-authentication-failed-resumehealthy-sequence.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L320 이다. 등급은 P2 이다. 정상 복귀 전이의 자바독 인용, 배수 표시가 현재 상태를 읽지 않는다는 지적, 두 전이가 관리자 포트에 함께 노출되어 있다는 대조, 그래서 인증 실패를 배수로 지운 뒤 정상 복귀를 부르는 두 단계 시퀀스가 보장을 우회한다는 판정, 테스트가 세 전이를 각각 새 런타임에서 확인한다는 관찰, 그리고 전이별로 현재 값을 읽는지 정리한 표가 그 절에 있다. 같은 문서의 21.2 절이 이 우회가 헬스 신호도 함께 끈다는 파급을 기록으로 남긴다.
|
||||
- 이 기록이 더한 것은 셋이다. 네 단계를 실제로 불러 각 단계의 반환값과 상태와 사유를 받았고, 같은 실행에서 헬스 판정을 나란히 찍어 21.2 절이 서술한 파급이 어느 단계에서 일어나는지 보였다. 비활성 경로는 상태 기계가 같지만 헬스 지시등이 내려간 채로 있다는 차이를 확인했다. 그리고 사라지는 사유 코드를 읽는 운영 표면이 없다는 것 — 남는 것은 설명이지 기록이 아니다.
|
||||
- 성능 저하로 3 단계가 막힌다는 것은 `degradingAFailedRuntimeIsRefused` 가 이미 단언한다. 이 기록이 더한 것은 그 뒤의 정상 요청까지 거짓이라는 4" 단계다.
|
||||
---
|
||||
|
||||
# 두 호출이 상태와 함께 경보를 끈다
|
||||
|
||||
정상 복귀 전이가 인증 실패 상태를 거부한다고 자바독이 적는다. 같은 원자 참조를 쓰는 일곱 전이 중 여섯이 관리자 포트에 있고, 그중 둘은 현재 값을 매개변수로 받고도 읽지 않는다. 그 둘 중 하나를 거친 뒤 정상을 요청하면 상태가 통과하고, 헬스 지시등도 그 자리에서 함께 올라온다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **nonce replay 경계가 결과를 읽고 버린다**
|
||||
두 사례 모두 경계가 판정은 하는데 그 결과가 다음 단계로 가지 않는다.
|
||||
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
|
||||
이 조합만 런타임 셋을 따로 만들어 확인한다.
|
||||
- **아직 쓰이지 않은 연산에도 같은 불변식이 성립하게 만든 순서**
|
||||
형제 전이가 같은 불변식을 지키는지 묻는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
제공자 런타임의 정상 복귀 전이가 자신이 지키는 성질을 자바독에 명시한다.
|
||||
|
||||
인증 실패는 여전히 거부한다. 제공자를 정상이라 선언한다고 해서 제공자가 받아들일 자격증명이 생기지는 않으므로, 첫 시도에서 실패할 런타임을 건네는 대신 호출자에게 그 사실을 알린다.
|
||||
|
||||
## 결론
|
||||
|
||||
여섯 중 셋은 현재 상태를 읽는다. 정상 복귀와 성능 저하와 스로틀이 조건을 건다.
|
||||
|
||||
나머지 셋은 읽지 않고 덮어쓴다. 인증 실패 표시는 그것이 하는 일이라 set 으로 쓰고, 배수 표시와 비활성 표시 둘만 원자 갱신의 람다로 현재 값을 받아 놓고 쓰지 않는다.
|
||||
|
||||
실행해서 확인했다. 인증 실패 상태에서 정상을 요청하면 거짓이 돌아오고 상태와 사유가 유지된다. 배수를 요청하면 참이 돌아오고 상태가 배수가 되며, 사유가 배수로 바뀌면서 원래 사유가 사라진다. 다시 정상을 요청하면 참이 돌아오고 상태가 정상이 된다.
|
||||
|
||||
같은 자리에서 헬스 지시등이 올라온다. 배수가 보고기의 비정상 목록에 들어 있지 않아서다. 3 단계에서 이미 정상으로 돌아오고, 자격증명은 그대로 거부된 그것이다. 보고기 자신의 자바독이 강조하는 조건이 자격증명 거부이고, 그것이 프로세스 수준 헬스에서는 보이지 않으므로 운영자를 호출해야 하는 조건이라고 적는다. 그 호출이 꺼진다.
|
||||
|
||||
비활성으로 가도 상태 기계는 같다. 다만 보고기는 비활성을 비정상으로 보므로 그 경로에서는 지시등이 내려간 채로 있다. 성능 저하로 가면 상태부터 다르다. 성능 저하 경로는 정상일 때만 상태를 바꾸므로 인증 실패 상태에서 거짓이 돌아오고, 이어지는 정상 요청도 거짓이다.
|
||||
|
||||
포트가 여섯을 그대로 넘기는 것은 문제가 아니다. 받는 쪽 둘에 조건이 없는 것이 문제다.
|
||||
|
||||
이 순서를 밟으려면 제공자 제어 권한이 필요하고, 성공한 두 걸음은 각각 감사 행을 남긴다. 거부된 2 단계는 응용 서비스에서 예외로 바뀌어 운영자에게 도착한다. 막히지는 않지만 흔적 없이 되는 일도 아니다.
|
||||
|
||||
상태 자체는 자가 복구된다. 첫 시도가 실패하면 다시 인증 실패로 돌아간다.
|
||||
|
||||
그 사이에 시도 하나가 제공자를 향해 나간다. 그리고 클래스 자바독이 막으려 한 창이 매 재설정마다 다시 열린다. 다만 실제로 새는 시도는 첫 실패가 상태를 되돌리기 전까지 동시에 떠 있던 발송 수만큼이고, 그 뒤의 발송은 시도 획득이 먼저 막는다.
|
||||
|
||||
런타임이 상태와 짝지어 들고 있던 원인 코드도 배수 단계에서 사라진다. 다만 그 값을 읽는 운영 표면이 없다. 헬스 엔드포인트는 자격증명을 이름 짓는다는 이유로 사유를 싣지 않는다. 없어지는 것은 설명이지 기록이 아니다.
|
||||
|
||||
테스트가 이것을 잡지 못하는 자리는 좁다. 같은 파일이 인증 실패 위에 성능 저하를, 정상 복귀를, 성공 복귀를 한 런타임에서 겹쳐 본다. 겹쳐 보지 않는 조합이 하나 있다. 가드가 없는 두 운영자 전이 다음의 정상 복귀다. 그것을 확인하는 테스트만 런타임 셋을 따로 만든다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 일곱 전이의 구현과 관리자 포트 분배 확인, 헬스 보고기 판정 확인, 실행 탐침
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 같은 원자 참조를 쓰는 전이를 전수로 세고, 각각이 현재 값을 읽는지 확인한다.
|
||||
2. 정상 복귀 전이의 자바독과 구현을 읽는다.
|
||||
3. 관리자 포트가 여섯 전이를 어떻게 분배하는지 확인한다.
|
||||
4. 헬스 보고기가 비정상으로 보는 상태 목록과 그 클래스 자바독을 읽는다.
|
||||
5. 런타임의 사유 필드를 읽는 곳을 저장소 전체에서 세고, 헬스 스냅숏이 사유를 싣는지 본다.
|
||||
6. 상태 테스트가 어떤 조합을 한 런타임에서 겹쳐 보는지 확인한다.
|
||||
7. 런타임 하나를 인증 실패로 두고 정상·배수·정상을 순서대로 부르며, 각 단계의 반환값과 상태와 사유와 헬스 판정을 함께 기록한다.
|
||||
8. 비활성 경로와 성능 저하 경로로 같은 순서를 반복한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
자바독이 지키겠다고 적은 성질이 하나 있다.
|
||||
|
||||
## 적어 둔 보장
|
||||
|
||||
:::evidence key="a13-f002-authentication-failed-resumehealthy" alt="같은 원자 참조를 쓰는 전이 일곱의 선언, 정상 복귀 전이의 자바독과 구현, 현재 값을 받아 놓고 쓰지 않는 둘과 set 으로 쓰는 하나의 구현, 관리자 포트가 여섯을 분배하는 스위치 전체, 헬스 보고기의 클래스 자바독과 비정상으로 보는 상태 목록, 런타임의 사유 필드를 읽는 곳과 헬스 스냅숏의 필드 목록, 그리고 이 보장을 확인하는 test 둘의 본문을 출력한 터미널 기록." caption="같은 참조를 쓰는 전이는 일곱이고 여섯이 관리자 포트에 있다 · markDraining 과 markDisabled 만 current 를 받아 놓고 쓰지 않는다 · 헬스 보고기가 비정상으로 보는 상태는 인증 실패와 비활성 둘 · 사유 필드를 읽는 프로덕션 코드는 없고 스냅숏도 사유를 싣지 않는다 — 109줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
198: * <p>This is the admin counterpart of {@link #markHealthy()}: it resumes a runtime that an
|
||||
199: * operator drained or disabled. It still refuses {@code AUTHENTICATION_FAILED}, because declaring
|
||||
200: * a provider healthy does not give it a credential the provider will accept — the caller is told
|
||||
201: * so rather than being handed a runtime that will fail on its first attempt.
|
||||
202: *
|
||||
203: * @return whether the runtime is now healthy
|
||||
204: */
|
||||
205: public boolean resumeHealthy() {
|
||||
206: return health
|
||||
207: .updateAndGet(
|
||||
208: current ->
|
||||
209: current.state() == ProviderRuntimeState.AUTHENTICATION_FAILED
|
||||
210: ? current
|
||||
211: : new RuntimeHealth(ProviderRuntimeState.HEALTHY, Optional.empty()))
|
||||
```
|
||||
|
||||
같은 참조를 쓰는 전이는 일곱이고, 그중 여섯이 관리자 포트에 있다. 남는 하나는 성공한 시도가 부르는 `markHealthy` 다.
|
||||
|
||||
## 현재 값을 받아 놓고 쓰지 않는 둘
|
||||
|
||||
```java
|
||||
221: public boolean markDraining() {
|
||||
222: return health
|
||||
223: .updateAndGet(
|
||||
224: current ->
|
||||
225: new RuntimeHealth(ProviderRuntimeState.DRAINING, Optional.of("DRAINING")))
|
||||
226: .state()
|
||||
227: == ProviderRuntimeState.DRAINING;
|
||||
228: }
|
||||
```
|
||||
|
||||
비활성 표시도 같은 조건에서 같은 값을 돌려준다. 인증 실패 표시는 아예 받지 않는다.
|
||||
|
||||
```java
|
||||
127: public void markAuthenticationFailed(String reasonCode) {
|
||||
128: Objects.requireNonNull(reasonCode, "reasonCode");
|
||||
129: // One write, so the state and the reason it carries are never observed apart.
|
||||
130: health.set(
|
||||
131: new RuntimeHealth(ProviderRuntimeState.AUTHENTICATION_FAILED, Optional.of(reasonCode)));
|
||||
132: }
|
||||
```
|
||||
|
||||
여섯 중 읽는 것이 셋, 읽지 않는 것이 셋이다.
|
||||
|
||||
## 여섯이 한 포트에 있다
|
||||
|
||||
```java
|
||||
30: return switch (desiredState) {
|
||||
31: case DISABLED -> runtime.markDisabled();
|
||||
32: case DRAINING -> runtime.markDraining();
|
||||
33: case HEALTHY -> runtime.resumeHealthy();
|
||||
34: case DEGRADED -> runtime.markDegraded(reason);
|
||||
35: case THROTTLED -> runtime.markThrottled();
|
||||
36: case AUTHENTICATION_FAILED -> {
|
||||
37: runtime.markAuthenticationFailed(reason);
|
||||
38: yield true;
|
||||
39: }
|
||||
```
|
||||
|
||||
운영자가 요청한 상태가 그대로 메서드 하나로 간다. 인증 실패만 반환값을 만들지 않고 `true` 를 낸다.
|
||||
|
||||
## 순서대로 불러 보면
|
||||
|
||||
:::evidence key="a13-f002-authentication-failed-resumehealthy-sequence" alt="관리자 포트가 부르는 여섯 전이가 현재 상태를 읽는지 하나씩 적은 표, 런타임 하나를 인증 실패로 두고 정상·배수·정상을 순서대로 불러 각 단계의 반환값과 상태와 사유와 헬스 판정을 함께 기록한 결과, 비활성 경로로 같은 순서를 반복한 결과, 그리고 가드가 있는 성능 저하 경로로 같은 순서를 반복한 결과를 출력한 터미널 기록." caption="배수를 거치면 사유가 지워지고 다음 정상 요청이 참으로 돌아온다 · 그 배수 단계에서 헬스 판정이 이미 정상으로 올라온다 · 비활성 경로는 헬스가 내려간 채로 있고, 가드가 있는 성능 저하로는 상태부터 막힌다 — 24줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
[네 단계 시퀀스]
|
||||
0. 시작 반환 - 상태 HEALTHY 사유 (없음) 헬스 true
|
||||
1. 제공자가 자격증명 거부 반환 - 상태 AUTHENTICATION_FAILED 사유 INVALID_CREDENTIAL 헬스 false
|
||||
2. 운영자가 HEALTHY 요청 반환 false 상태 AUTHENTICATION_FAILED 사유 INVALID_CREDENTIAL 헬스 false
|
||||
3. 운영자가 DRAINING 요청 반환 true 상태 DRAINING 사유 DRAINING 헬스 true
|
||||
4. 운영자가 다시 HEALTHY 요청 반환 true 상태 HEALTHY 사유 (없음) 헬스 true
|
||||
```
|
||||
|
||||
4 단계에는 읽을 인증 실패가 없다. 3 단계가 그것을 지웠기 때문이다.
|
||||
|
||||
## 경보가 먼저 꺼진다
|
||||
|
||||
헬스 열이 3 단계에서 이미 참이다. 보고기가 비정상으로 보는 상태 목록에 배수가 없다.
|
||||
|
||||
```java
|
||||
63: ProviderRuntimeState state = runtime.get().state();
|
||||
64: if (state == ProviderRuntimeState.AUTHENTICATION_FAILED
|
||||
65: || state == ProviderRuntimeState.DISABLED) {
|
||||
66: healthy = false;
|
||||
67: }
|
||||
```
|
||||
|
||||
같은 클래스의 자바독이 무엇을 지키려 했는지 적는다.
|
||||
|
||||
```java
|
||||
18: * <p>A provider whose credentials were rejected reports unhealthy even though the process is fine:
|
||||
19: * that is exactly the condition an operator needs paged on, and it is invisible from process-level
|
||||
20: * health.
|
||||
```
|
||||
|
||||
3 단계에서 그 호출이 꺼진다. 자격증명은 그대로 거부된 그것이다.
|
||||
|
||||
비활성 경로는 다르다.
|
||||
|
||||
```text
|
||||
[DISABLED 로도 같은지]
|
||||
3'. 운영자가 DISABLED 요청 반환 true 상태 DISABLED 사유 DISABLED 헬스 false
|
||||
4'. 운영자가 HEALTHY 요청 반환 true 상태 HEALTHY 사유 (없음) 헬스 true
|
||||
```
|
||||
|
||||
상태 기계는 같지만 3' 단계에서는 지시등이 내려간 채로 있다. 두 경로가 같은 것은 상태까지다.
|
||||
|
||||
```text
|
||||
[비교] 가드가 있는 전이로는 통과하지 못한다
|
||||
3". 운영자가 DEGRADED 요청 반환 false 상태 AUTHENTICATION_FAILED 사유 INVALID_CREDENTIAL 헬스 false
|
||||
4". 운영자가 HEALTHY 요청 반환 false 상태 AUTHENTICATION_FAILED 사유 INVALID_CREDENTIAL 헬스 false
|
||||
```
|
||||
|
||||
우회를 만드는 것은 관리자 포트가 아니라 가드가 없는 두 전이다.
|
||||
|
||||
## 원인 코드는 누가 읽는가
|
||||
|
||||
3 단계에서 `INVALID_CREDENTIAL` 이 사라진다. 그런데 그 값을 읽는 프로덕션 코드가 없다.
|
||||
|
||||
```text
|
||||
ProviderRuntime.java:84: * <p>Prefer this to calling {@link #state()} and {@link #unhealthyReason()} in turn: two reads
|
||||
ProviderRuntime.java:97: public Optional<String> unhealthyReason() {
|
||||
```
|
||||
|
||||
선언과 자기 자바독뿐이다. 헬스 스냅숏도 사유를 싣지 않는다.
|
||||
|
||||
```java
|
||||
public record ProviderHealth(
|
||||
String profileId, String state, long credentialGeneration, int activeAttempts) {
|
||||
```
|
||||
|
||||
사라지는 것은 상태와 짝을 이루던 런타임 자신의 설명이고, 원인 코드 자체는 시도 기록에 남는다.
|
||||
|
||||
## test 가 못 보는 조합
|
||||
|
||||
```java
|
||||
124: @DisplayName("an operator can resume a drained or disabled runtime but not a failed credential")
|
||||
125: void resumeHealthyClearsOperatorStatesOnly() {
|
||||
126: ProviderRuntime drained = runtime(1, 1);
|
||||
127: drained.markDraining();
|
||||
128: assertThat(drained.resumeHealthy()).isTrue();
|
||||
129:
|
||||
130: ProviderRuntime disabled = runtime(1, 1);
|
||||
131: disabled.markDisabled();
|
||||
132: assertThat(disabled.resumeHealthy()).isTrue();
|
||||
133:
|
||||
134: ProviderRuntime failed = runtime(1, 1);
|
||||
135: failed.markAuthenticationFailed("INVALID_CREDENTIAL");
|
||||
136: assertThat(failed.resumeHealthy())
|
||||
```
|
||||
|
||||
런타임 셋을 각각 새로 만든다. 세 객체가 만나지 않으므로 인증 실패한 런타임을 배수로 보내는 순서가 생기지 않는다.
|
||||
|
||||
합성을 아예 안 보는 것은 아니다.
|
||||
|
||||
```java
|
||||
143: @DisplayName("degrading an already failed runtime is refused rather than silently ignored")
|
||||
144: void degradingAFailedRuntimeIsRefused() {
|
||||
145: ProviderRuntime runtime = runtime(1, 1);
|
||||
146: runtime.markAuthenticationFailed("INVALID_CREDENTIAL");
|
||||
```
|
||||
|
||||
가드가 있는 전이 위에서는 한 런타임에 겹쳐 본다. 겹쳐 보지 않는 것은 가드가 없는 두 전이 쪽이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
관리자 응용 서비스를 통과시키지 않았다. 탐침은 런타임을 직접 부르므로 권한 확인과 멱등 재생과 감사 기록, 그리고 거부를 예외로 바꾸는 단계가 빠져 있다.
|
||||
|
||||
정상이 된 런타임에 실제 알림을 흘려 시도가 제공자를 향해 나가는지 재현하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a13-f003-accesscontext
|
||||
title: 정책을 담은 필드가 참이 된 적도 읽힌 적도 없다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a13-f003-accesscontext
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a13-f003-accesscontext.body.md
|
||||
assets:
|
||||
- key: a13-f003-accesscontext
|
||||
file: ../../../final/evidence/rendered/a13-f003-accesscontext.svg
|
||||
- key: a13-f003-accesscontext-reveal
|
||||
file: ../../../final/evidence/rendered/a13-f003-accesscontext-reveal.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a13-f003-accesscontext.txt
|
||||
- ../../../final/evidence/raw/a13-f003-accesscontext-reveal.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L430 이다. 등급은 P2 이다. 레코드와 포트가 성질을 두 번 선언한다는 인용, 유일한 구현이 문맥을 널 검사만 하고 버린다는 관찰, 세 필드 접근자 계수와 동명 접근자가 다른 타입의 것이라는 확인, 감사 설비는 있는데 이 경로에만 연결되지 않았다는 판정, 프로덕션 reveal 호출자 여섯 곳이 전부 그 팩토리를 넘기고 그것이 감사 필요를 거짓으로 고정한다는 관찰, 참을 넘겨도 결과가 같다는 단정, 그리고 수정 방향이 그 절에 있다.
|
||||
- 이 기록이 더한 것은 둘이다. 원본 절은 프로덕션 호출자만 셌지만, 이 레코드를 만드는 표현은 저장소 전체를 통틀어 팩토리 안의 것 하나뿐이고 테스트를 포함한 나머지 아홉 건이 전부 그 팩토리 호출이다 — 참을 넣는 표현이 어디에도 없다. 그리고 원본이 코드 읽기로 단정한 참을 넘겨도 결과가 같다는 것을, 문맥 셋으로 실제 복호화해 실행으로 확인했다.
|
||||
---
|
||||
|
||||
# 정책을 담은 필드가 참이 된 적도 읽힌 적도 없다
|
||||
|
||||
접근 문맥 레코드와 그것을 받는 포트가 모든 평문 노출이 감사 가능하다고 두 번 선언한다. 저장소에서 그 레코드를 만드는 길은 감사 필요 여부를 거짓으로 고정한 팩토리 하나뿐이고, 세 필드 중 어느 것도 읽는 코드가 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **소비자가 없는 fixture 셋**
|
||||
이쪽도 선언된 타입의 프로덕션 호출자를 세어 0 을 확인했다.
|
||||
- **타입이 문서화한 불변식은 타입이 강제한다**
|
||||
이 타입도 javadoc 으로 불변식을 적어 두고 그것을 강제하지 않는다.
|
||||
- **실패 사유가 저널에도 감사 이벤트에도 남지 않는다**
|
||||
기록할 설비는 있는데 이 경로에서만 아무것도 남지 않는다.
|
||||
|
||||
## 문제
|
||||
|
||||
복호화 포트가 값과 함께 접근 문맥을 받는다.
|
||||
|
||||
문맥 레코드의 자바독은 평문 연락처가 왜 노출되는지를 담으며 모든 노출이 감사 가능하다고 적는다. 포트의 자바독은 감사되는 목적을 위해 값을 복호화한다고 적는다.
|
||||
|
||||
## 결론
|
||||
|
||||
유일한 구현은 문맥을 널 검사만 하고 버린다.
|
||||
|
||||
복호화 메서드에서 문맥을 언급하는 줄은 서명과 널 검사 두 개다. 나머지는 키를 찾고 복호화하고 파싱한다.
|
||||
|
||||
문맥을 바꿔 가며 같은 값을 복호화해 봤다. 감사 필요를 참으로 두고 목적 코드를 바꿔도 돌아오는 것이 같다. 복호화 메서드 본문에 감사를 부르는 호출이 하나도 없고, 복호화기가 들고 있는 것도 키 제공자와 난수뿐이다.
|
||||
|
||||
접근자 계수는 다른 각도에서 같은 자리를 짚는다. 목적 코드와 감사 필요 여부는 접근자 호출이 각각 0 이다. 행위자 참조 접근자는 일곱 건 잡히지만 전부 다른 타입의 동명 접근자다. 관리자 행위자 쪽과 감사 사건 쪽이 각각 자기 필드를 읽은 것이다.
|
||||
|
||||
읽히지 않는 것만이 아니다. 이 레코드를 만드는 길이 하나뿐이고, 그 팩토리가 감사 필요 여부를 거짓으로 고정한다. 프로덕션의 여섯 제공자 어댑터가 전부 그 팩토리를 부른다. 참을 넣는 코드가 저장소에 없다.
|
||||
|
||||
사고 조사가 누가 어떤 목적으로 어떤 접촉점의 평문을 열었는지 물으면, 이메일 주소와 전화번호와 디바이스 토큰의 복호화는 아무 흔적도 내놓지 못한다. 타입 서명만 흔적이 남는 것처럼 읽힌다.
|
||||
|
||||
감사 설비가 없어서가 아니다. 포트도 기록 구현도 저장소에 있고, 같은 리프의 회전기가 그 포트를 주입받아 회전 사건을 남긴다. 관리자 평면도 쓴다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
수정은 복호화 구현에 감사 포트를 주입하고 노출 시점에 기록하는 것이다. 사건에는 목적 코드와 행위자 참조와 키 식별자와 유형만 넣고 평문과 지문은 넣지 않는다. 감사 포트 자바독이 원시 주소를 절대 싣지 않는다고 적어 둔 그대로다. 감사 필요가 거짓인 배달 경로까지 표본으로 남길지는 그다음 결정이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 구현 본문 확인, 생성 지점과 접근자 전수 계수, 실행 탐침
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 접근 문맥 레코드와 포트의 자바독을 읽는다.
|
||||
2. 유일한 구현의 복호화 메서드를 읽고, 문맥을 언급하는 줄을 센다.
|
||||
3. 저장소 전체에서 이 레코드를 만드는 곳을 센다.
|
||||
4. 그 팩토리가 세 필드에 무엇을 넣는지 확인한다.
|
||||
5. 세 필드 접근자를 저장소 전체에서 검색하고, 동명 접근자가 다른 타입의 것인지 확인한다.
|
||||
6. 복호화기를 만들어 문맥을 바꿔 가며 같은 값을 복호화한다.
|
||||
7. 복호화기가 어떤 협력자를 들고 있는지 반사로 확인한다.
|
||||
8. 감사 포트와 그 구현이 저장소에 있는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
평문 연락처를 복호화하는 포트가 접근 문맥을 함께 받는다.
|
||||
|
||||
## 두 번 선언되는 성질
|
||||
|
||||
:::evidence key="a13-f003-accesscontext" alt="접근 문맥 레코드 전체와 그것을 받는 포트의 자바독, 유일한 구현의 복호화 메서드 전체, 저장소 전체에서 이 레코드를 만드는 곳, 세 필드 접근자의 호출 수와 그중 잡히는 곳의 목록, 그리고 감사 포트와 그 구현이 저장소에 있다는 것을 출력한 터미널 기록." caption="레코드 자바독이 모든 노출은 감사 가능하다고 적고 포트 자바독도 같은 말을 한다 · 복호화 메서드에서 문맥은 널 검사에만 나온다 · 레코드를 만드는 길은 감사 필요를 거짓으로 고정한 팩토리 하나뿐 · 목적 코드와 감사 필요 접근자 호출은 0 — 63줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
5:/** Why a plaintext contact point is being revealed. Every reveal is auditable. */
|
||||
6:public record AccessContext(String purposeCode, String actorRef, boolean auditRequired) {
|
||||
```
|
||||
|
||||
```java
|
||||
11: /** Decrypt a value for an audited purpose. */
|
||||
12: ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context);
|
||||
```
|
||||
|
||||
## 유일한 구현이 그것을 버린다
|
||||
|
||||
```java
|
||||
86: public ContactPointValue reveal(ProtectedContactPoint protectedValue, AccessContext context) {
|
||||
87: Objects.requireNonNull(protectedValue, "protectedValue");
|
||||
88: Objects.requireNonNull(context, "context");
|
||||
89: SecretKeyMaterial key = keys.keyById(protectedValue.keyId());
|
||||
90: byte[] plaintext =
|
||||
91: decrypt(
|
||||
92: key,
|
||||
93: protectedValue.nonce(),
|
||||
94: associatedData(protectedValue.type()),
|
||||
95: protectedValue.ciphertext());
|
||||
96: return parse(protectedValue.type(), new String(plaintext, StandardCharsets.UTF_8));
|
||||
97: }
|
||||
```
|
||||
|
||||
문맥이 마지막으로 나오는 줄은 88 이다.
|
||||
|
||||
## 문맥을 바꿔 넣어 보면
|
||||
|
||||
:::evidence key="a13-f003-accesscontext-reveal" alt="복호화기를 만들어 접근 문맥을 셋으로 바꿔 가며 같은 값을 복호화한 결과, 복호화기의 생성자 인자와 필드를 반사로 뽑은 목록, 그리고 dispatch 팩토리가 세 필드에 넣는 값을 출력한 터미널 기록." caption="감사 필요를 참으로 두고 목적 코드를 바꿔도 돌아오는 값이 같다 · 복호화기가 들고 있는 것은 키 제공자와 난수뿐이다 · dispatch 팩토리는 감사 필요를 거짓으로 고정한다 — 17줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
[문맥을 바꿔 가며 같은 값을 복호화한다]
|
||||
purposeCode=DISPATCH auditRequired=false -> EmailAddress
|
||||
purposeCode=BREAK_GLASS auditRequired=true -> EmailAddress
|
||||
purposeCode=SUPPORT_LOOKUP auditRequired=true -> EmailAddress
|
||||
```
|
||||
|
||||
감사 필요를 참으로 두어도 달라지는 것이 없다. 보낼 곳이 없기 때문이다.
|
||||
|
||||
```text
|
||||
[복호화기가 들고 있는 협력자]
|
||||
생성자 인자 : [SecretMaterialProvider]
|
||||
생성자 인자 : [SecretMaterialProvider, SecureRandom]
|
||||
필드 CIPHER : String
|
||||
필드 HMAC : String
|
||||
필드 KEY_ALGORITHM : String
|
||||
필드 keys : SecretMaterialProvider
|
||||
필드 random : SecureRandom
|
||||
```
|
||||
|
||||
## 참을 넣는 코드가 없다
|
||||
|
||||
```text
|
||||
application/notification/platform/security/AccessContext.java:17: public static AccessContext dispatch(String providerProfileId) {
|
||||
application/notification/platform/security/AccessContext.java:18: return new AccessContext("DISPATCH", providerProfileId, false);
|
||||
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:27: assertThat(protector.reveal(first, AccessContext.dispatch("ses-primary"))).isEqualTo(value);
|
||||
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:65: assertThatThrownBy(() -> protector.reveal(moved, AccessContext.dispatch("ses-primary")))
|
||||
adapter/outbound/notification/platform/security/AesGcmContactPointProtectorTest.java:86: assertThatThrownBy(() -> protector.reveal(rotated, AccessContext.dispatch("ses-primary")))
|
||||
adapter/outbound/notification/platform/provider/ses/SesNotificationProviderAdapter.java:115: AccessContext.dispatch(submission.profile().profileId().value()));
|
||||
adapter/outbound/notification/platform/provider/webpush/WebPushNotificationProviderAdapter.java:90: AccessContext.dispatch(submission.profile().profileId().value()));
|
||||
adapter/outbound/notification/platform/provider/smtp/SmtpNotificationProviderAdapter.java:90: AccessContext.dispatch(submission.profile().profileId().value()));
|
||||
adapter/outbound/notification/platform/provider/twilio/TwilioSmsProviderAdapter.java:86: AccessContext.dispatch(submission.profile().profileId().value()));
|
||||
adapter/outbound/notification/platform/provider/fcm/FcmBatchCoordinator.java:70: AccessContext.dispatch(submission.profile().profileId().value()));
|
||||
adapter/outbound/notification/platform/provider/apns/ApnsNotificationProviderAdapter.java:84: AccessContext.dispatch(submission.profile().profileId().value()));
|
||||
```
|
||||
|
||||
이 레코드를 만드는 표현은 팩토리 안의 `new AccessContext(...)` 하나뿐이다. 나머지 아홉 건은 전부 그 팩토리 호출이고, 그중 셋은 테스트다.
|
||||
|
||||
```text
|
||||
[dispatch 팩토리가 정하는 값]
|
||||
purposeCode=DISPATCH actorRef=ses-primary auditRequired=false
|
||||
```
|
||||
|
||||
## 읽는 코드도 없다
|
||||
|
||||
```text
|
||||
purposeCode : 0
|
||||
actorRef : 7
|
||||
auditRequired : 0
|
||||
```
|
||||
|
||||
행위자 참조가 잡히는 일곱 건은 전부 다른 타입이다.
|
||||
|
||||
```text
|
||||
application/notification/platform/admin/NotificationAdminApplicationService.java:138: actor.actorRef(),
|
||||
application/notification/platform/admin/NotificationAdminApplicationService.java:189: actor.actorRef(),
|
||||
application/notification/platform/admin/NotificationAdminApplicationService.java:267: actor.actorRef(),
|
||||
application/notification/platform/admin/NotificationAdminApplicationService.java:321: actor.actorRef(),
|
||||
adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java:34: event.actorRef(),
|
||||
adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java:44: actor.actorRef(),
|
||||
adapter/outbound/persistence/notification/platform/JpaAdminOperationStore.java:101: actor.actorRef(),
|
||||
```
|
||||
|
||||
관리자 행위자와 알림 감사 사건이다. 접근 문맥의 것이 아니다.
|
||||
|
||||
## 감사 포트와 기록 구현은 이미 있다
|
||||
|
||||
```text
|
||||
application/notification/platform/observation/NotificationAuditPort.java:4:public interface NotificationAuditPort {
|
||||
adapter/outbound/notification/platform/dispatch/ProviderRuntimeRegistryTest.java:183: private static final class RecordingAudit implements NotificationAuditPort {
|
||||
adapter/outbound/notification/platform/observation/LoggingNotificationAudit.java:23: implements NotificationAuditPort, NotificationSecurityAuditPort {
|
||||
```
|
||||
|
||||
포트와 기록 구현이 있고, 같은 리프의 `ProviderRuntimeRotator` 가 그것을 주입받아 회전 사건을 남긴다. 빠진 것은 노출 경로의 연결 하나다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
과거에 그 문맥을 읽는 코드가 있었는지 이력에서 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a13-f004-thymeleaf
|
||||
title: Thymeleaf 메시지 삭제 가드가 프로덕션 경로에 없고, 있어도 값은 로그에 남는다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a13-f004-thymeleaf
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a13-f004-thymeleaf.body.md
|
||||
assets:
|
||||
- key: a13-f004-thymeleaf
|
||||
file: ../../../final/evidence/rendered/a13-f004-thymeleaf.svg
|
||||
- key: a13-f004-thymeleaf-messages
|
||||
file: ../../../final/evidence/rendered/a13-f004-thymeleaf-messages.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a13-f004-thymeleaf.txt
|
||||
- ../../../final/evidence/raw/a13-f004-thymeleaf-messages.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L476 이다.
|
||||
---
|
||||
|
||||
# Thymeleaf 메시지 삭제 가드가 프로덕션 경로에 없고, 있어도 값은 로그에 남는다
|
||||
|
||||
렌더 예외를 잡아 메시지를 버리는 catch 는 모드 없는 오버로드 안에만 있다. 프로덕션은 모드 있는 오버로드만 부른다. 그리고 그 catch 가 막으려던 값은 그보다 앞서 Thymeleaf 자신의 ERROR 로그에 적힌다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다**
|
||||
두 사례 모두 가드가 붙은 오버로드를 프로덕션에서 부르지 않는다.
|
||||
- **정책을 담은 필드가 참이 된 적도 읽힌 적도 없다**
|
||||
같은 리프의 다른 개인정보 사례다.
|
||||
- **두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
|
||||
## 문제
|
||||
|
||||
한 클래스에 렌더 메서드가 둘 있고 예외 처리가 서로 다르다. 그 차이가 실제로 무엇을 바꾸는지 재봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
모드 없는 쪽은 처리 호출을 감싸고 실행 예외를 잡는다. 잡은 뒤 메시지를 버리고 자체 예외를 던진다. 주석이 이유를 적어 뒀다. 템플릿 식은 실패한 변수를 담고 있고, 이 플랫폼에서 그 변수는 일회용 코드이거나 수신자 이름이라는 것이다.
|
||||
|
||||
모드 있는 쪽에는 그 catch 가 없다. 저장소 전체를 뒤져도 배포되는 코드가 엔진을 부르는 자리는 둘이고, 둘 다 모드를 넘긴다. 같은 검색이 테스트에서 잡은 것은 열다섯 자리다.
|
||||
|
||||
빠진 것은 catch 하나다. 변수 누락은 두 오버로드가 똑같이 거른다. 문맥을 만드는 자리가 그 검사를 부르기 때문이다.
|
||||
|
||||
catch 가 없으면 Thymeleaf 예외가 그대로 호출자에게 간다. 그 메시지는 템플릿 원문을 인용하고, 원인 사슬은 실패한 값을 담을 수 있다.
|
||||
|
||||
값이 절에 적힌 자리는 넷이었다. 메서드의 수신 객체, 함수의 인자, 속성의 선택자, 산술의 피연산자다.
|
||||
|
||||
무엇이 적히는지는 자리가 아니라 예외 종류가 정한다. 메서드 실패 예외는 원인을 가리지 않고 수신 객체를 적는다. 없는 메서드를 부르든 있는 메서드가 범위를 벗어나 던지든 같다. 변환기는 변환하지 못한 인자를 적고, 속성 예외는 선택자를 적으며, 수로 바꾸지 못한 피연산자는 형식 예외가 적는다. 변환이 끝난 뒤의 실패가 조용한 것은 피연산자 자리뿐이고, 0 으로 나눈 예외가 피연산자를 적지 않기 때문이다.
|
||||
|
||||
변수 자체가 아니어도 된다. 거기서 한 단계 타고 들어간 필드나 원소면 충분하다. 수신자 레코드의 전자우편을 자르려다 실패한 식이 그 주소를 남겼다.
|
||||
|
||||
적히는 단위가 값이 아니라 객체다. 레코드를 통째로 넘긴 식에서는 그 안의 전자우편까지 절에 들어갔다.
|
||||
|
||||
조용한 쪽은 넷이었다. 셋은 그 자리에 호출자 값이 아예 없었거나 0 으로 나눈 예외가 피연산자를 적지 않은 경우였고, 나머지 하나는 변수를 주지 않아 OGNL 에 닿기 전에 걸렸다.
|
||||
|
||||
두 자리가 서로 이어져 있지 않아 예외의 행선지도 갈린다. 제공자 시도 어댑터에서는 실패가 그 자리 밖으로 나가지 않는다. 배차 서비스는 감싸지 않는다. 거기서 나간 예외는 두 프레임 위 일정 작업자의 포괄 잡기에 닿고, 작업자는 타입만 적는다. 작업자 주석이 이유를 적어 뒀다. 라이브러리 예외 문구가 수신자 주소를 담을 수 있다는 것이다.
|
||||
|
||||
즉 예외를 타고 나가는 쪽은 두 자리에서 모두 막힌다. 막히지 않은 것은 반대쪽이다.
|
||||
|
||||
값이 로그에 닿는 경로는 이 어댑터가 아니라 라이브러리다. 던지기에 앞서 ERROR 사건이 하나 먼저 나가고, 그 사건은 두 오버로드 모두에서 남았다. 원본 분석은 예외가 상위 로거에 기록된다고 적었는데, 예외는 타입만 기록되고 문구를 남기는 것은 라이브러리 자신의 로그다.
|
||||
|
||||
마스킹 카탈로그도 이 문구를 알아보지 못했다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 열한 가지 식을 두 오버로드에 넣는 프로브와 호출 경로 전수 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 하위 범위 04 의 근거 파일은 final/evidence/raw/184-notification-template-security-probes.txt 다.
|
||||
|
||||
1. 고정 리비전 산출물과 잠금 파일로 클래스패스를 만든다.
|
||||
2. 루트 로거에서 콘솔 어펜더를 떼고 수집용 어펜더를 붙인다.
|
||||
3. 열한 가지 식을 두 오버로드에 넣고 호출자가 받는 예외 사슬을 잇는다.
|
||||
4. 수집한 로그 사건을 메시지와 예외 사슬로 펼친다.
|
||||
5. 두 문자열에 변수 값이 있는지, 마스킹 카탈로그를 지난 뒤에도 있는지 센다.
|
||||
6. 실패가 OGNL 까지 간 열 식에서 첫 절을 따로 뽑는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`ThymeleafStringTemplateEngine`은 같은 클래스에 `render` 두 개를 갖는다. 처리 호출을 감싸는 것은 그중 하나뿐이다.
|
||||
|
||||
```java
|
||||
// ThymeleafStringTemplateEngine.java:84-100 (mode-less overload)
|
||||
public String render(String source, Map<String, Object> variables) {
|
||||
...
|
||||
try {
|
||||
return engine.process(source, contextFor(source, variables));
|
||||
} catch (RuntimeException failure) {
|
||||
// The message is dropped on purpose. Thymeleaf reports the offending expression, and a
|
||||
// template expression contains the variable it failed on — which for this platform is a
|
||||
// one-time code or a recipient name.
|
||||
throw new TemplateRenderingException(NotificationFailureDescriptor.preDispatch(...));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`:67-82`의 mode-aware overload에는 이 블록이 없다. `engineFor(mode).process(...)`를 그대로 부르고 결과를 `TemplateSlotPolicy.verifyRendered`에 넘긴다.
|
||||
|
||||
## 배포되는 코드가 부르는 것은 감싸지 않은 쪽이다
|
||||
|
||||
:::evidence key="a13-f004-thymeleaf" alt="코드베이스 정적 검색 출력 29줄. 저장소 전체 엔진 호출과 테스트 건수, 두 오버로드의 검사 위치, 렌더를 부르는 두 자리와 그것이 닿는 작업자, 로거 설정 줄 수가 차례로 보인다." caption="오버로드와 렌더 예외가 지나는 자리 — 29줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`CanonicalNotificationRenderer:125`와 `:133`이 `src/main` 전체에서 유일한 호출이고, 첫 인자가 `modeOf(slot)`이다. `NotificationTemplateEngine:29-34`가 이 오버로드를 `default` 가 아니라 추상으로 둔 경위를 적어 두었고, 다른 구현인 `PlaceholderTemplateEngine`은 `:28-30`에서 모드 없는 쪽이 모드 있는 쪽으로 넘어간다. 어느 쪽으로도 `:92`에 닿지 않는다.
|
||||
|
||||
## 빠진 것은 catch 하나다
|
||||
|
||||
두 오버로드가 갈리는 지점을 catch 로 좁혀야 한다. 누락 변수 검사는 갈리지 않는다.
|
||||
|
||||
```java
|
||||
// ThymeleafStringTemplateEngine.java:154-161
|
||||
private Context contextFor(String source, Map<String, Object> variables) {
|
||||
...
|
||||
requireEveryReferencedVariable(source, variables);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`:88`이 같은 검사를 한 번 더 부르지만, `contextFor`가 이미 부르므로 모드 있는 쪽도 이 검사를 지난다. 아래 자료의 `[[${code}]]` 줄이 그 결과다.
|
||||
|
||||
## 열한 가지 식을 두 오버로드에 넣었다
|
||||
|
||||
:::evidence key="a13-f004-thymeleaf-messages" alt="JVM 프로브 출력 42줄. 열한 가지 식을 두 오버로드에 넣고 호출자와 로그와 마스킹 뒤에서 변수 값이 보이는지를 있음 없음으로 적은 뒤, 각 식에서 OGNL 이나 JDK 가 만든 절을 열 줄로 나열한다. 로그와 마스킹 칸은 두 오버로드가 같을 때만 한 값으로 적힌다. 유틸리티 객체의 주소는 실행마다 달라지므로 고정 표시로 바꿔 적었다." caption="열한 가지 식을 두 오버로드에 넣은 프로브 — 42줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
값이 나온 식이 일곱, 나오지 않은 식이 넷이다. 가르는 것은 식의 생김새가 아니라 실패한 자리에 무엇이 놓여 있었느냐다. 자료 마지막 열 줄이 각 식에서 OGNL 이나 JDK 가 만든 첫 절을 보여 준다.
|
||||
|
||||
```
|
||||
[[${recipient.email.substring(99)}]] MethodFailedException: Method "substring" failed for object alice@example.com
|
||||
[[${#numbers.formatDecimal(code, 1, 2)}]] IllegalArgumentException: Unable to convert type java.lang.String of 483920 to type of java.lang.Number
|
||||
[[${code[recipient]}]] NoSuchPropertyException: java.lang.String.Recipient[email=alice@example.com, code=483920]
|
||||
[[${recipient * 2}]] NumberFormatException: For input string: "Recipient[email=alice@example.com, code=483920]"
|
||||
```
|
||||
|
||||
수신 객체, 변환하지 못한 인자, 속성 이름으로 쓰인 선택자, 수로 바꾸지 못한 피연산자다. 네 자리에서 모두 같은 일이 났다. 다만 절에 무엇이 실리느냐는 예외 클래스마다 다르다. 첫 줄이 보여 주듯 그 자리에 놓인 것이 변수 자신일 필요도 없다. `recipient`는 수신 객체가 아니지만 거기서 도달한 필드가 수신 객체가 되면서 그 값이 절에 적혔다.
|
||||
|
||||
나오지 않은 넷 가운데 셋은 이렇게 끝났다.
|
||||
|
||||
```
|
||||
[[${#strings.substring(code, 99, 200)}]] MethodFailedException: Method "substring" failed for object org.thymeleaf.expression.Strings@<주소>
|
||||
[[${code.noSuchProperty}]] NoSuchPropertyException: java.lang.String.noSuchProperty
|
||||
[[${code.length() / 0}]] ArithmeticException: / by zero
|
||||
```
|
||||
|
||||
첫 줄은 수신 객체가 유틸리티이고, 변수는 인자 자리에 있는데 그 변환이 성공했다. 둘째는 선택자가 리터럴 이름이다. 셋째는 두 피연산자가 모두 수로 바뀐 뒤 나눗셈 자체가 실패한 경우다. 변환이 끝난 다음이면 `ArithmeticException` 은 피연산자를 적지 않는다. `[[${code[recipient]}]]`와 `[[${code.noSuchProperty}]]`가 같은 예외 타입에서 갈리는 것이 이 규칙을 가장 짧게 보여 준다.
|
||||
|
||||
`:93-95`의 주석은 노출 범위를 "일회용 코드이거나 수신자 이름"으로 적었다. 실제로 나가는 것은 그 자리에 놓인 객체의 `toString()` 전체이므로, 값 객체를 넘기면 그것이 담은 필드까지 따라 나온다.
|
||||
|
||||
넷째인 `[[${code}]]` 는 OGNL 까지 가지도 않는다. 누락 변수 검사가 먼저 거르므로 절 목록에도 없다. 열한 식이 남긴 절이 열 줄인 이유가 그것이다.
|
||||
|
||||
원본 분석 `:532`가 든 `${amount.formatted('%.2f')}`는 첫 번째 자리에 걸린다. `amount`가 `BigDecimal`이나 `Double`이면 `formatted`가 없는 메서드가 되고, 문자열이면 아예 실패하지 않는다.
|
||||
|
||||
## 렌더를 부르는 두 자리는 서로 독립이다
|
||||
|
||||
`NotificationProviderAttemptAdapter:163`은 자기 호출을 감싸고 `:164`에서 실행 예외를 잡아 `TEMPLATE_RENDERING_FAILED` 코드만 남긴다. 예외 객체는 버려진다.
|
||||
|
||||
`NotificationDispatchService:146`에는 그런 감싸기가 없다. 거기서 던져진 것은 `dispatch(...)` 밖으로 나가 두 프레임 위에서 잡힌다.
|
||||
|
||||
```java
|
||||
// NotificationSchedulerWorker.java:95-106 (주석 여섯 줄 중 앞 세 줄 생략)
|
||||
} catch (RuntimeException failure) {
|
||||
// The cause chain by type, never by message. ... a library's exception text
|
||||
// can carry a recipient address, so the types are named and the messages are not.
|
||||
log.warn("notification dispatch failed worker={} reason={} causes={}",
|
||||
workerId, failure.getClass().getSimpleName(), causeChain(failure));
|
||||
}
|
||||
```
|
||||
|
||||
`causeChain`은 `:121-132`에서 원인마다 `getSimpleName()`만 이어 붙인다. 그래서 여기 남는 것은 `reason=TemplateInputException causes=TextParseException<...` 같은 타입 나열이고 문구는 없다.
|
||||
|
||||
## 값은 그 앞에서 로그에 적힌다
|
||||
|
||||
Thymeleaf 는 예외를 올려보내기 전에 `org.thymeleaf.TemplateEngine` 로거로 같은 사슬을 ERROR 에 남긴다. `app-bootstrap`의 yml·xml·properties 어디에도 이 로거를 따로 설정한 줄이 없다. `application.yml:347`의 루트 준위 기본값은 `INFO`이고 환경 변수로만 바뀌며, `logback-spring.xml`은 네 조합 모두에서 `<root level="INFO">`를 직접 적는다. 샘플링도 이 사건을 지우지 못한다.
|
||||
|
||||
```java
|
||||
// SamplingTurboFilter.java:46-48
|
||||
if (level.toInt() >= Level.WARN_INT) {
|
||||
return FilterReply.NEUTRAL;
|
||||
}
|
||||
```
|
||||
|
||||
프로브가 수집한 로그 사건에서 값이 보인 칸은 두 오버로드 모두 `있음`이다. 마스킹도 이것을 잡지 못한다. `SecretMaskingMessageConverter`는 `%maskedMsg` 자리에서 `super.convert(event)`만 훑고, 그 값은 본문이 아니라 예외 쪽에 있다. JSON 어펜더 쪽은 스택까지 훑지만 같은 카탈로그를 지난다.
|
||||
|
||||
```java
|
||||
// LogMaskingPatterns.java:20-21
|
||||
private static final String VALUE = "[^\\s\"',&}]+";
|
||||
private static final String SEP = "[\"']?\\s*[:=]\\s*[\"']?";
|
||||
```
|
||||
|
||||
규칙마다 키 이름과 `:` 또는 `=`를 요구한다. 위의 어느 절에도 그 둘이 없다. 프로브가 이 `mask`를 그대로 불러 확인한 칸이 `마스킹 뒤 있음`이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
운영 템플릿 가운데 이런 식을 쓰는 것이 몇 개인지 세지 않았다. 절을 만드는 예외 종류는 이 열한 식이 만든 것만 봤고, 라이브러리 자신이 던지는 예외가 인자를 문구에 담는 경우는 세지 않았다. 마스킹은 카탈로그 함수로만 확인했고, JSON 어펜더를 붙여 실제 출력 문자열을 만들지는 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a13-f005-retry-after-illegalargumentexception
|
||||
title: 음수 Retry-After 를 거르지 않는 파서 셋이 조립되지 않아 결함이 잠재로 남는다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a13-f005-retry-after-illegalargumentexception
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a13-f005-retry-after-illegalargumentexception.body.md
|
||||
assets:
|
||||
- key: a13-f005-retry-after-illegalargumentexception
|
||||
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception.svg
|
||||
- key: a13-f005-retry-after-illegalargumentexception-negative
|
||||
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-negative.svg
|
||||
- key: a13-f005-retry-after-illegalargumentexception-unassembled
|
||||
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-unassembled.svg
|
||||
- key: a13-f005-retry-after-illegalargumentexception-ambiguous
|
||||
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-ambiguous.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception.txt
|
||||
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception-negative.txt
|
||||
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception-unassembled.txt
|
||||
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception-ambiguous.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L636 이다.
|
||||
---
|
||||
|
||||
# 음수 Retry-After 를 거르지 않는 파서 셋이 조립되지 않아 결함이 잠재로 남는다
|
||||
|
||||
헤더 파서는 숫자 형식만 방어하고 부호는 방어하지 않는다. 실패 값 타입의 생성자는 음수를 거부한다. 그 조합이 인자 예외를 만드는데, 그 예외를 만들 수 있는 세 분류기는 이 빌드에서 조립되지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **검증기가 불리지 않는 지금 실제로 도는 게이트는 생성자다**
|
||||
검사가 생산자가 아니라 소비자 생성자에 있다는 점이 같다.
|
||||
- **"상한을 두고 읽는다"는 본문 핸들러가 전부 읽은 뒤에 자른다**
|
||||
같은 리프의 다른 원격 입력 사례다.
|
||||
- **바이트가 프로세스를 떠났는가가 REJECTED와 AMBIGUOUS를 가른다**
|
||||
응답을 다 받은 실패가 AMBIGUOUS 로 적히므로 그 기준으로 설명되지 않는다.
|
||||
|
||||
## 문제
|
||||
|
||||
원격이 조절 응답과 함께 재시도 대기 헤더를 보낸다. 파서가 그 값을 기간으로 바꾸고 실패 값 타입이 그것을 담는다. 두 쪽의 입력 계약이 같은지, 그리고 그 차이가 실제로 도달 가능한지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
계약은 다르다. 파서는 정수 파싱이 받아들이는 값을 그대로 초 단위 기간으로 만들고 부호는 보지 않는다. 받는 쪽 생성자는 음수를 인자 예외로 거절한다.
|
||||
|
||||
같은 파서가 저장소에 넷 있다. 부호를 보는 줄은 웹훅 사본 하나뿐이고, 그 술어는 표준이 허용하는 0 까지 걸러 낸다.
|
||||
|
||||
고쳐야 할 술어도 리프마다 다르다. 알림 리프의 소비자는 힌트에 상한을 걸므로 음수만 거르면 되지만, httpclient 사본의 소비자는 힌트에 기간을 더하므로 아주 큰 양수에서 넘친다.
|
||||
|
||||
넷 가운데 하나는 죽어 있다. FCM 분류기가 자기 사본을 공개 메서드로 들고 있는데, 그 분류기를 쓰는 세 자리가 부르는 것은 다른 메서드다.
|
||||
|
||||
문제가 실제로 나는 조합은 공용 파서 쪽이다. 세 분류기가 다섯 자리에서 그것을 부르고, 상태 변환기는 429 뿐 아니라 500 이상에도 같은 값을 넘긴다. 음수 헤더를 단 429 응답을 넣으면 세 분류기가 모두 인자 예외로 끝난다. APNs 만 정상 분류를 돌려주는데 애초에 이 헤더를 읽지 않기 때문이다.
|
||||
|
||||
그런데 그 셋은 이 빌드에서 돌 수 없다.
|
||||
|
||||
세 어댑터를 생성하는 코드가 시험 소스에만 있다. main 에 있는 조립기는 SMTP 하나뿐이다. 그 밖의 계열을 켜면 해당 어댑터를 만드는 조립 코드가 없어 애플리케이션이 기동하지 않는다. 거부 문구가 이유를 적어 뒀다. 해당 전송은 인터페이스만 제공하고 실제 구현은 포함하지 않는다는 것이다.
|
||||
|
||||
조립되면 P2 다. 원격이 정하는 값 하나가 확정 거절을 영구 실패로 바꾸고 재시도와 대체를 막는다. 이 빌드에서는 그 셋이 조립되지 않으므로 한 단계 내려 P3 으로 둔다. 배선되지 않은 발견을 도달 가능한 등급에서 한 단계 내리는 것은 이 저장소가 websocket 리프에서 쓴 방식이다. 같은 저장소가 P3 을 매긴 다른 자리는 닿지 않는다는 것에 더해 결과가 무해하다는 조건을 하나 더 달았는데, 여기 결과는 무해하지 않다. 등급이 심각도를 바꾸되 발견을 없애지는 않는다.
|
||||
|
||||
원문도 P3 이지만 근거가 다르다. 원문은 표준을 지키는 제공자가 음수를 보내지 않는다는 것을 들었다. 그 근거는 계속 유효하지만, 조립되지 않았다는 근거는 조립기가 생기면 사라진다.
|
||||
|
||||
조립기가 생기면 이 결함이 도달 가능해지므로, 그때 무슨 일이 나는지까지 정적으로 따라갔다.
|
||||
|
||||
세 어댑터의 제출 메서드는 미래를 만들기 전에 본체를 먼저 실행한다. 예외는 게이트웨이가 미래를 기다리기 전에 던져지므로 완료 예외가 되지 않고, 게이트웨이의 완료 예외 잡기를 지나친다. 미래를 나중에 채우는 어댑터는 하나뿐이고, 그 계열 분류기는 이 파서와 무관하다.
|
||||
|
||||
배차 서비스의 잡기 셋 가운데 앞의 둘은 타입이 맞지 않는다. 마지막 포괄 잡기가 받아 응답 소실과 모호한 제출로 적는다. 그 가지의 주석은 바이트가 제공자에 닿았을 수 있다는 전제를 적어 뒀는데, 예외 시점에 응답은 이미 읽혀 있었다.
|
||||
|
||||
그다음이 원문과 갈리는 자리다. 원문은 예외가 일정 작업자의 포괄 잡기에 닿아 리스가 만료된다고 적었으나, 배차 서비스가 먼저 잡으므로 그 잡기에는 예외가 도달하지 않는다.
|
||||
|
||||
한 회차의 순서도 짚어야 한다. 라우팅은 이 실패를 볼 수 없다. 그 결정이 제출보다 먼저 나기 때문이다. 그 회차를 끝내는 것은 재시도 정책이다. 정책이 읽는 두 능력 값의 출처는 어댑터가 아니다. 어댑터의 능력 선언 메서드는 프로덕션 호출자가 없다.
|
||||
|
||||
그 두 값의 조합이 셋인데 결과는 둘로 모인다. 상태 조회를 지원하면 바로 조정으로 간다. 둘 다 지원하지 않으면 중지한다. 멱등성만 지원하면 재전송을 예약하지만 그 회차는 제출에 닿지 못한다. 수신자의 ambiguousAttemptExists 가 이미 참이라, 라우팅이 제출 대신 조정으로 보내기 때문이다.
|
||||
|
||||
한 회차에 수신자 상태를 쓰는 자리도 둘이다. 기록기가 먼저 모호한 결과를 조정 필요로 적고 ambiguousAttemptExists 를 참으로 만든다. 그다음 후속 조치가 조건마다 그 상태를 덮는다. 중지에서는 실패로, 재전송 예약에서는 재시도 대기로 덮는다. 둘 중 실패만 다음 회차의 청구 대상이 아니다. 그 시도까지 모호한 시도가 없었다면, 조절은 그 값을 참으로 만들지 않으므로 다시 집혔을 것이다.
|
||||
|
||||
아무도 걸리지 않은 이유도 확인했다. 알림 리프에서 이 헤더를 넣는 시험이 넷인데 값이 10, 7, 3, 7 이다. 다른 리프의 픽스처가 넣는 값도 1 이다. 적대적 값은 한 자리도 없다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 파서 넷과 생성자 대조, 분류기 넷에 음수 헤더를 넣는 프로브, 조립 가능성과 예외 경로 정적 추적
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 §21.1 절이다.
|
||||
|
||||
1. 파싱 구문으로 저장소를 훑어 초 단위 변환 자리를 찾는다.
|
||||
2. 각 파서가 부호를 검사하는지 읽는다.
|
||||
3. 받는 값 타입의 정규 생성자 검증을 읽는다.
|
||||
4. 고정 리비전 산출물로 클래스패스를 만들고 파서 셋에 같은 헤더 넷을 넣는다.
|
||||
5. 음수 헤더를 단 429 응답을 분류기 넷에 넣는다.
|
||||
6. 그 분류기를 쓰는 어댑터가 main 에서 생성되는지, 그 계열의 조립기가 있는지 센다.
|
||||
7. 제출 메서드가 동기인지 확인하고 게이트웨이와 배차 서비스의 잡기를 읽는다.
|
||||
8. 한 회차 안의 호출 순서와 중지가 남기는 상태, 그리고 다음 회차의 청구 조건을 읽는다.
|
||||
9. 이 헤더를 넣는 시험이 넣는 값을 전부 나열한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
파서는 `Long.parseLong`이 받아들이는 값을 그대로 `Duration`으로 만든다.
|
||||
|
||||
```java
|
||||
// ProviderResults.java:90-99
|
||||
public static Optional<Duration> retryAfter(Optional<String> headerValue) {
|
||||
return headerValue.flatMap(value -> {
|
||||
try { return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); }
|
||||
catch (NumberFormatException notSeconds) { return Optional.empty(); }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
받는 쪽은 그 값을 거부한다.
|
||||
|
||||
```java
|
||||
// ProviderFailure.java:29-34 (canonical constructor)
|
||||
retryAfter.ifPresent(delay -> {
|
||||
if (delay.isNegative()) { throw new IllegalArgumentException("retryAfter"); }
|
||||
});
|
||||
```
|
||||
|
||||
## 같은 파서가 넷이고 술어는 하나도 맞지 않는다
|
||||
|
||||
:::evidence key="a13-f005-retry-after-illegalargumentexception" alt="코드베이스 정적 검색 출력 49줄. 파싱 구문 검색을 retry·seconds·header 로 좁힌 결과와, 네 파서 안에서 부호를 보는 줄, 받는 생성자의 검증, 공용 파서를 쓰는 다섯 자리, FCM 분류기가 나오는 자리 전부, 이 헤더를 넣는 시험 값이 차례로 보인다." caption="Retry-After 파서 넷과 그 소비자 — 49줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
첫 묶음은 `parseLong`·`parseInt`·`Duration.parse` 로 저장소를 훑은 뒤 `retry`·`seconds`·`header` 가 든 줄만 남긴 것이다. Retry-After 를 초로 바꾸는 것은 그중 넷이다. `ProviderResults:94`, `FcmFailureClassifier:70`, `WebhookNotificationProviderAdapter:243`, `BlockingAttemptExecutor:238`. 목록의 `WebThrottleHttpContract:49`도 이 헤더를 정수로 바꾸지만 파서가 아니라 시험 단언이다.
|
||||
|
||||
그 네 파일에서 부호를 보는 줄은 하나다.
|
||||
|
||||
```java
|
||||
// WebhookNotificationProviderAdapter.java:243-244
|
||||
long seconds = Long.parseLong(value.trim());
|
||||
return seconds > 0 ? Optional.of(Duration.ofSeconds(seconds)) : Optional.empty();
|
||||
```
|
||||
|
||||
`seconds > 0`은 음수와 함께 0 도 버린다. RFC 9110 §10.2.3 의 `delta-seconds` 는 `1*DIGIT` 이라 0 을 허용한다. 다만 소비자 쪽에서는 차이가 없다. `RetryBackoff.delay:44`가 힌트를 계산된 후퇴보다 클 때만 쓰므로 `PT0S`와 빈 값이 구분되지 않는다.
|
||||
|
||||
넷에 같은 술어를 처방할 수도 없다. 알림 리프는 `RetryBackoff.delay:45`가 상한을 걸어 주므로 음수만 거르면 되지만, `BlockingAttemptExecutor:238`이 채우는 값은 `DefaultRetryEligibilityEngine:127`에서 `Duration.plus`로 들어간다. 아주 큰 양수는 거기서 넘친다.
|
||||
|
||||
`FcmFailureClassifier`가 나오는 자리는 여섯이다. `FcmBatchCoordinator`는 `classify`만, `FcmContactPointUpdater`는 `invalidatesContactPoint`만 부른다. `:66-75`의 사본에는 호출자가 없다.
|
||||
|
||||
값이 흘러 들어가는 상태도 429 하나가 아니다. `ProviderResults.fromStatus`는 `:54`의 429 가지와 `:78`의 500 이상 가지에 같은 인자를 넘긴다.
|
||||
|
||||
## 음수 헤더를 실제로 넣었다
|
||||
|
||||
:::evidence key="a13-f005-retry-after-illegalargumentexception-negative" alt="JVM 프로브 출력 29줄. 헤더 네 값을 파서 셋에 넣은 결과와, 음수 헤더를 단 429 응답을 분류기 넷에 넣은 결과가 나온다." caption="음수 Retry-After 를 파서 셋과 분류기 넷에 넣은 프로브 — 29줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`-30`을 넣으면 공용 파서와 FCM 사본이 `PT-30S`를 만들고 웹훅 사본은 빈 값을 준다. 그 `PT-30S`가 생성자에 닿으면 SES·Twilio·WebPush 셋이 모두 `IllegalArgumentException: retryAfter`로 끝난다. APNs 만 `retryAfter=Optional.empty`인 정상 실패를 돌려준다. `ApnsFailureClassifier:55`가 헤더 대신 빈 값을 넘기기 때문이다.
|
||||
|
||||
`0`에서 두 계열이 갈린다. 공용 파서는 `PT0S`를 만들고 그 값은 음수가 아니므로 생성자를 통과한다.
|
||||
|
||||
HTTP-date 형식은 프로브에 넣은 셋 모두 빈 값이 되어 계산된 후퇴로 떨어진다. 프로브에 없는 네 번째도 `BlockingAttemptExecutor:239-243`에서 같은 `NumberFormatException` 가지로 빈 값을 준다.
|
||||
|
||||
## 그 셋은 이 빌드에서 조립되지 않는다
|
||||
|
||||
:::evidence key="a13-f005-retry-after-illegalargumentexception-unassembled" alt="코드베이스 정적 검색 출력 49줄. 재시도 정책이 보는 두 값의 출처, 알림 경로에서 capabilities 를 선언하고 부르는 자리, 프로파일 스냅샷을 만드는 자리, 조립기 구현 하나, 세 어댑터를 생성하는 자리, 조립기 없는 계열의 기동 거부 문구가 차례로 보인다." caption="세 어댑터의 조립 가능성과 능력 선언의 출처 — 49줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
자료의 각 줄 앞에 붙은 `main`·`test` 가 소스셋이다. 세 어댑터를 생성하는 자리는 여섯 줄 모두 `test` 다. `implements ProviderRuntimeAssembler` 도 main 에는 `SmtpProviderRuntimeAssembler:48` 하나뿐이고, `new ProviderProfileSnapshot(` 을 main 에서 부르는 자리도 그 클래스의 `:127` 하나다.
|
||||
|
||||
조립기가 없는 계열의 프로파일을 켜면 기동이 거부된다.
|
||||
|
||||
```java
|
||||
// NotificationProviderAssembly.java:96-104
|
||||
if (assembler == null) {
|
||||
throw new IllegalStateException(
|
||||
"notification provider profile '"
|
||||
+ profileId
|
||||
+ "' is of type "
|
||||
+ type
|
||||
+ ", which has no assembler in this build. The transport is a seam, not an"
|
||||
+ " implementation; remove the profile or supply a ProviderRuntimeAssembler for"
|
||||
+ " that family.");
|
||||
```
|
||||
|
||||
그래서 이 결함은 잠재다. 원문이 P3 을 매긴 근거는 표준을 지키는 제공자가 음수를 보내지 않는다는 것인데, 그보다 앞서 이 코드에 닿을 방법이 없다.
|
||||
|
||||
## 조립기가 생기면 무슨 일이 나는가
|
||||
|
||||
:::evidence key="a13-f005-retry-after-illegalargumentexception-ambiguous" alt="코드베이스 정적 검색 출력 46줄. 일곱 어댑터의 제출 반환 줄, 게이트웨이가 던지고 잡는 자리, 배차 서비스의 세 잡기와 마지막 가지가 만드는 값, 한 회차의 호출 순서, 중지가 남기는 상태, 다음 회차의 청구 조건이 차례로 보인다." caption="인자 예외가 지나는 길과 한 회차의 끝 — 46줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
먼저 어디를 지나지 않는지가 중요하다. 일곱 어댑터의 반환 줄이 자료에 있다. 여섯은 본체를 먼저 실행하고 그 결과로 완료된 미래를 만들며, `FcmNotificationProviderAdapter:65`만 조정자에게 넘긴다. 그 조정자도 `FcmBatchCoordinator:96`에서 완료된 미래를 돌려주므로 던지는 시점은 같다.
|
||||
|
||||
```java
|
||||
// SesNotificationProviderAdapter.java:105-108
|
||||
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
|
||||
Objects.requireNonNull(submission, "submission");
|
||||
return CompletableFuture.completedFuture(send(submission));
|
||||
}
|
||||
```
|
||||
|
||||
`send(...)`가 먼저 평가되므로 인자 예외는 `submit` 호출 자체에서 동기로 던져진다.
|
||||
|
||||
```java
|
||||
// RegistryProviderDispatchGateway.java:45-50
|
||||
try (AttemptPermit permit = runtime.acquireAttempt()) {
|
||||
ProviderSubmissionResult result =
|
||||
runtime.adapter().submit(submission).toCompletableFuture().join();
|
||||
applyHealth(runtime, result);
|
||||
return result;
|
||||
} catch (CompletionException failure) {
|
||||
```
|
||||
|
||||
`:47`에서 던져지므로 `join()`에 닿지 않고 `CompletionException`도 되지 않는다. `:50`의 잡기는 이 경로에 없다. 자료에서 `supplyAsync` 를 쓰는 줄은 `SmtpNotificationProviderAdapter:82` 하나이고, 그 계열 분류기에는 `ProviderResults` 참조가 없다.
|
||||
|
||||
예외는 try-with-resources 를 그대로 통과해 배차 서비스로 간다. 잡기가 셋인데 `:221`은 `ProviderCallNotStartedException`, `:227`은 `NotificationException` 이라 타입이 맞지 않는다. 남는 것이 마지막이다.
|
||||
|
||||
```java
|
||||
// NotificationDispatchService.java:243-253
|
||||
} catch (RuntimeException transportFailure) {
|
||||
// Anything the adapter did not classify. The bytes may have reached the provider, so the only
|
||||
// honest record is an ambiguous one — this branch is the residue, not the default.
|
||||
return ProviderSubmissionResult.ambiguous(
|
||||
ProviderFailure.of(
|
||||
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
|
||||
FailureCategory.AMBIGUOUS_SUBMISSION,
|
||||
false),
|
||||
ProviderExecutionEvidence.responseLost(),
|
||||
Duration.ZERO);
|
||||
}
|
||||
```
|
||||
|
||||
주석의 전제는 바이트가 제공자에 닿았을 수 있다는 것이다. 이 실패에는 그 전제가 없다. 예외가 난 시점에 429 응답은 이미 읽혀 있었고, 429 는 제공자가 받지 않았다는 확정이다.
|
||||
|
||||
## 그 회차의 끝
|
||||
|
||||
한 회차 안의 순서가 정해져 있다. `:121`의 라우팅 결정은 제출보다 앞이고 이전 시도 상태로 돈다. 이 실패를 같은 회차에 보는 것은 `:175`의 재시도 정책이다.
|
||||
|
||||
정책이 보는 두 값은 어댑터가 아니라 프로파일 스냅샷에서 온다.
|
||||
|
||||
```java
|
||||
// NotificationDispatchService.java:372-373
|
||||
profile.capabilities().providerIdempotency(),
|
||||
profile.capabilities().statusQuery(),
|
||||
```
|
||||
|
||||
알림 경로의 프로덕션 코드 가운데 `NotificationProviderAdapter.capabilities()` 를 부르는 것은 없다. 스냅샷을 만드는 자리가 조립기이므로, 이 두 값은 그날 쓰인 조립기가 정한다.
|
||||
|
||||
```java
|
||||
// DefaultNotificationRetryPolicy.java:54-59
|
||||
if (context.confirmation() == AttemptConfirmation.AMBIGUOUS) {
|
||||
if (context.statusQuerySupported()) {
|
||||
return new RetryDecision.Reconcile(context.now().plus(reconcileDelay));
|
||||
}
|
||||
if (!context.providerIdempotency()) {
|
||||
return new RetryDecision.Stop("AMBIGUOUS_UNSAFE_TO_RETRY");
|
||||
```
|
||||
|
||||
갈래는 셋이다. 상태 조회가 참이면 `:56`의 조정, 둘 다 거짓이면 `:59`의 중지, 상태 조회만 거짓이고 멱등성이 참이면 블록을 빠져나간다. 셋째 갈래도 `:69`·`:74`·`:77`의 중지 셋을 지나야 `:80`의 재전송에 닿는다.
|
||||
|
||||
그 재전송은 제공자에 닿지 않는다. 이 회차에서 상태를 먼저 쓰는 것은 `:173`의 기록기이고, 그 기록기가 `:108`에서 결과의 확인 상태를 보고 `ambiguous` 를 정한다.
|
||||
|
||||
```java
|
||||
// DispatchOutcomeRecorder.java:119-126
|
||||
ambiguous
|
||||
? RecipientDeliveryState.RECONCILIATION_REQUIRED
|
||||
: RecipientDeliveryState.DISPATCHING,
|
||||
...
|
||||
recipient.ambiguousAttemptExists() || ambiguous,
|
||||
recipient.duplicateRisk() || ambiguous,
|
||||
```
|
||||
|
||||
`ambiguousAttemptExists` 가 세워진 채 커밋되고, `transitionHeldBy` 는 상태와 다음 시각만 쓰므로 그 표시가 남는다. 그사이 요청이 만료되면 애초에 다시 집히지도 않는다. 집힌다면 `RoutingDecisionEngine:24`가 실패 스위치에 가기 전에 조정을 돌려주고, `NotificationDispatchService:122-126`이 제출 앞에서 되돌아온다. `applyRoutingStop:389-393`이 수신자를 조정 필요로 옮긴다.
|
||||
|
||||
중지 갈래에서는 `:435-440`이 확정 수락인지 보고 아니면 실패로 덮는다.
|
||||
|
||||
```java
|
||||
// NotificationDispatchService.java:435-440
|
||||
case RetryDecision.Stop ignored ->
|
||||
recipients.transitionHeldBy(
|
||||
work.recipient().id(),
|
||||
attempt.submissionOutcome() == SubmissionOutcome.CONFIRMED_ACCEPTED
|
||||
? RecipientDeliveryState.COMPLETED
|
||||
: RecipientDeliveryState.FAILED,
|
||||
```
|
||||
|
||||
실패는 `RecipientClaimSql:30`의 청구 대상 셋에 없다. `RoutingDecisionEngine:45`의 `AMBIGUOUS_SUBMISSION` 가지는 어차피 닿지 않는다. 모호한 시도 뒤에는 `:24`가 스위치 앞에서 먼저 돌려주기 때문이다. 조절이었다면 그 표시가 서지 않아 `:24`를 지나고 `:38-41`의 같은 채널 재시도에 닿았을 것이다.
|
||||
|
||||
건강 반영도 사라진다. `applyHealth`는 게이트웨이 `:48`에서 불리고 그 줄은 `:47`이 던지면 닿지 않는다.
|
||||
|
||||
## 원문과 어긋나는 지점
|
||||
|
||||
원문 `:659`는 이 예외가 `NotificationSchedulerWorker`의 포괄 잡기에 닿아 리스가 만료되고 회수가 재대사로 처리한다고 적었다. 배차 서비스의 `:243`이 먼저 잡으므로 `dispatch()`는 정상으로 돌아오고, 작업자의 잡기는 발화하지 않으며, 리스는 만료가 아니라 `:177`에서 반납된다.
|
||||
|
||||
## 아무도 걸리지 않은 이유
|
||||
|
||||
자료 마지막 두 묶음이 이 헤더를 넣는 자리다. 알림 리프의 시험 넷이 `10`, `7`, `3`, `7`을 쓰고, httpclient 리프의 픽스처 `StatefulUpstream:62`가 `1`을 쓴다. 적대적 값은 없다.
|
||||
|
||||
`NotificationChaosSecurityTest:120`의 `3`은 그 시험이 헤더를 공격해서 나온 값이 아니다. 그 클래스의 javadoc 이 주제를 둘로 적는다. 커밋 후 응답 손실과 텔레메트리 비밀 누출이다. `3`은 비밀 누출 검사가 쓰는 오류 응답 목록에 들어 있을 뿐이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
조립기를 써서 세 어댑터를 실제로 띄워 보지는 않았다. 6번 이후는 분기 조건을 코드로 확인한 정적 추적이다. httpclient 사본의 넘침도 소비자 코드를 읽어 확인했을 뿐 실행으로 재현하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a13-f008-host
|
||||
title: 포트를 뺀 호스트를 서명하는 자리가 둘이고, 같은 저장소의 네 자리는 그 포트를 지킨다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a13-f008-host
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a13-f008-host.body.md
|
||||
assets:
|
||||
- key: a13-f008-host
|
||||
file: ../../../final/evidence/rendered/a13-f008-host.svg
|
||||
- key: a13-f008-host-wire
|
||||
file: ../../../final/evidence/rendered/a13-f008-host-wire.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a13-f008-host.txt
|
||||
- ../../../final/evidence/raw/a13-f008-host-wire.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L831 이다.
|
||||
---
|
||||
|
||||
# 포트를 뺀 호스트를 서명하는 자리가 둘이고, 같은 저장소의 네 자리는 그 포트를 지킨다
|
||||
|
||||
SES 요청 매퍼는 서명할 때 엔드포인트의 포트를 버린다. 전선에 나가는 값은 표준 라이브러리가 URI 에서 채우므로 기본이 아닌 포트가 거기 붙는다. 웹푸시 서명기도 같은 식을 쓰고, 그 포트를 지키는 코드는 같은 저장소에 네 벌 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다**
|
||||
같은 SigV4 경로의 다른 자리다.
|
||||
- **로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다**
|
||||
어느 결함이 어느 환경에서만 보이는지 적어 둔다는 규칙이 같다.
|
||||
- **음수 Retry-After 를 거르지 않는 파서 셋이 조립되지 않아 결함이 잠재로 남는다**
|
||||
같은 리프에서 조립 여부가 등급을 정한 사례다.
|
||||
|
||||
## 문제
|
||||
|
||||
서명 방식은 정규 요청에 실제로 전송되는 호스트 헤더 값을 담도록 요구하고, 기본이 아닌 포트는 그 값에 든다. 매퍼가 무엇을 서명하는지, 그리고 전선에 무엇이 나가는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
매퍼는 엔드포인트에서 호스트만 꺼내 서명 대상 헤더 맵에 넣는다.
|
||||
|
||||
요청 자체가 싣는 헤더는 넷이고 그 안에 호스트가 없다. 매퍼가 넣지 않기 때문이다. 게이트웨이의 제한 목록이 막는 것은 다른 경우다. 공급된 헤더를 막는다.
|
||||
|
||||
왕복을 한 번 쟀다. 로컬 서버를 띄우고 같은 게이트웨이로 세 가지 헤더 모양을 보냈다. 매퍼가 실제로 보내는 넷만 넣었을 때도, 거기에 호스트를 더했을 때도, 대문자 키로 더했을 때도 서버가 받은 값은 같았고 서명 대상과 달랐다.
|
||||
|
||||
어긋나는 경우를 가르는 것은 권한부가 아니다. 권한부는 기본 포트를 적기만 해도, 사용자 정보가 붙기만 해도 호스트와 달라진다. 엔드포인트 여섯에 규칙을 적용해 보면 권한부와 갈리는 것이 다섯인데 전선의 값과 갈리는 것은 둘뿐이다. 적힌 포트가 스킴의 기본이 아닐 때에만 어긋난다. 이 표는 실행이 아니라 계산이다.
|
||||
|
||||
두 번째 자리가 있다. 웹푸시 서명기가 대상 클레임을 스킴과 호스트로만 조립한다. 그 클레임은 규격상 출처이고 출처에는 기본이 아닌 포트가 들어간다. 원문에는 이 자리가 없다.
|
||||
|
||||
지켜야 할 판정은 이미 저장소에 네 벌 쓰여 있다. 인바운드 웹의 콜백 URL 재구성과 출처 조립 둘, 그리고 아웃바운드 httpclient 의 정규 대상 하나다. 방향으로 갈리는 문제가 아니다. 그중 콜백 쪽 javadoc 은 이것을 틀리면 정상 웹훅이 전부 서명 실패로 뒤집힌다고 적는다.
|
||||
|
||||
엔드포인트 가드에는 포트를 읽는 줄이 하나도 없다. 비표준 포트를 단 엔드포인트가 어디서도 걸리지 않는다는 뜻이다. SES 속성이 루프백 허용을 상수 참으로 넘기므로 프로파일로 좁혀지지도 않는다.
|
||||
|
||||
이미 벌어지고 있는 일이기도 하다. 제공자 시험 대역이 임시 포트로 주소를 만들고, SES 와 웹푸시 계약 시험이 그 주소를 실제 매퍼에 그대로 먹인다. 검증하는 쪽이 없어 시험은 통과한다. 서명한 호스트 값을 단언하는 시험도 없다.
|
||||
|
||||
수정은 두 자리 모두에 필요하다.
|
||||
|
||||
판정은 P3 다. 근거가 둘이다. 하나는 SES 어댑터가 이 빌드에서 조립되지 않는다는 것이다. main 의 조립기는 SMTP 하나뿐이다. 다른 하나는 조립되더라도 실제 SES 가 443 이라 두 값이 같다는 것이다.
|
||||
|
||||
조립되고, 엔드포인트가 비기본 포트이며, 그 엔드포인트가 서명을 검증하면 그 SES 프로파일로 나가는 발송이 전부 거절된다. 403 은 인가 실패로 분류되어 재시도도 대체 경로도 붙지 않는다. 그 조건에서는 P2 다. 첫째가 없으므로 나머지 둘은 이 빌드에서 물을 수조차 없다. 그래서 한 단계 내린다. 배선되지 않은 발견을 도달 가능한 등급에서 한 단계 내리는 것은 이 저장소가 websocket 리프에서 쓴 방식이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 서명 대상 식과 전송 값 대조, 로컬 서버로 헤더 세 모양 왕복, 기본 포트만 생략하는 조립 자리 전수 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 #L831 절이다.
|
||||
|
||||
1. 매퍼가 호스트를 꺼내는 식과 그 값이 들어가는 헤더 맵을 읽는다.
|
||||
2. 요청이 실제로 싣는 헤더 목록을 읽는다.
|
||||
3. 게이트웨이의 제한 목록에 호스트가 있는지 확인한다.
|
||||
4. 로컬 서버를 띄우고 헤더 세 모양으로 요청을 보내 서버가 받은 값을 읽는다.
|
||||
5. 엔드포인트마다 전선의 값과 권한부가 각각 서명 대상과 갈리는지 계산한다.
|
||||
6. 엔드포인트 가드가 포트를 읽는 줄이 있는지 센다.
|
||||
7. 임시 포트 주소를 실제 매퍼에 먹이는 시험 자리를 찾는다.
|
||||
8. 출처나 호스트를 문자열로 조립하면서 기본 포트만 생략하는 자리를 전수로 찾는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
SigV4 의 정규 요청은 실제로 전송되는 `Host` 헤더 값을 서명해야 한다. 매퍼가 서명하는 것은 다른 값이다.
|
||||
|
||||
```java
|
||||
// SesRequestMapper.java:103-110
|
||||
String host = properties.endpoint().getHost();
|
||||
|
||||
var signed =
|
||||
signer.sign(
|
||||
"POST",
|
||||
PATH,
|
||||
"",
|
||||
Map.of("host", host, "content-type", "application/json"),
|
||||
```
|
||||
|
||||
## 요청은 그 헤더를 싣지 않는다
|
||||
|
||||
:::evidence key="a13-f008-host" alt="코드베이스 정적 검색 출력 46줄. 매퍼가 서명 대상에 넣는 값과 요청에 싣는 헤더 넷, 게이트웨이의 제한 목록, 엔드포인트 가드에서 포트를 읽는 줄을 찾은 결과와 SES 속성이 그 가드를 부르는 줄, 임시 포트 주소를 만드는 대역과 그것을 먹이는 시험 자리 일곱, 같은 식의 다른 서명기, 기본 포트만 생략하는 조립 자리를 두 문자열로 검색한 결과가 차례로 보인다." caption="서명 대상을 정하는 자리와 그 값이 나가지 못하는 자리 — 46줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`SesRequestMapper:122-126`이 요청에 넣는 헤더는 `content-type`, `x-amz-date`, `x-amz-content-sha256`, `authorization` 넷이다. 서명한 `host` 는 거기 없다. 매퍼가 넣지 않아서이지 무엇이 걸러서가 아니다.
|
||||
|
||||
이것은 매퍼가 넣지 않는다는 사실과 별개다. 누가 그 헤더를 공급하더라도 전선에 닿지 못한다.
|
||||
|
||||
```java
|
||||
// JdkNotificationHttpGateway.java:26-27, :54
|
||||
Set.of("connection", "content-length", "expect", "host", "upgrade");
|
||||
if (!RESTRICTED.contains(name)) {
|
||||
```
|
||||
|
||||
## 세 가지 헤더 모양으로 보내 봤다
|
||||
|
||||
:::evidence key="a13-f008-host-wire" alt="JVM 프로브 출력 18줄. 첫 줄은 자바 판이고, 로컬 서버에 헤더 세 모양으로 요청을 보내 서버가 받은 Host 를 적은 결과와, 엔드포인트 여섯에 대해 전선의 값과 권한부가 각각 서명 대상과 갈리는지 표시한 표가 나온다. 포트는 실행마다 달라 자리표시로 바꿨다." caption="서버가 받은 Host 와 갈리는 조건 — 18줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
앞 묶음이 왕복이다. 로컬 서버를 띄우고 같은 `JdkNotificationHttpGateway` 로 `POST` 를 세 번 보냈다. 첫 번째는 매퍼가 실제로 넣는 헤더 넷만, 두 번째는 거기에 `host` 를 더해서, 세 번째는 대문자 `Host` 로 더해서 보냈다. 셋 다 서버가 받은 값이 `127.0.0.1:<포트>` 였고 서명 대상 `127.0.0.1` 과 달랐다.
|
||||
|
||||
둘째와 셋째는 매퍼가 하지 않는 일이다. 제한 목록이 실제로 거르는지, 그리고 대문자 키에도 걸리는지를 함께 재려고 일부러 더 넣었다. `NotificationHttpRequest` 가 정규 생성자에서 키를 소문자로 바꾸므로 대문자도 같은 자리에 걸린다.
|
||||
|
||||
## 갈리는 조건은 권한부가 아니다
|
||||
|
||||
뒤 표가 그것이다. 전선의 값과 갈리는 엔드포인트는 `:8443` 과 `localhost:4566` 둘뿐인데, 권한부와 갈리는 것은 다섯이다. 이 표는 왕복이 아니라 `ExternalRequestUrlResolver` 의 판정을 엔드포인트마다 적용한 계산이다.
|
||||
|
||||
`https://email.eu-central-1.amazonaws.com:443` 은 기본 포트를 적었을 뿐이라 전선의 값은 호스트 그대로다. `http://127.0.0.1:80` 도 같다. `https://user@email.example.com` 은 포트가 아예 없는데 사용자 정보 때문에 권한부가 달라진다. 세 경우 모두 서명은 어긋나지 않는다.
|
||||
|
||||
어긋나는 것은 포트가 적혀 있고 그 포트가 스킴의 기본이 아닌 엔드포인트뿐이다.
|
||||
|
||||
## 옳은 식은 이미 저장소에 있다
|
||||
|
||||
```java
|
||||
// ExternalRequestUrlResolver.java:57-63
|
||||
boolean defaultPort =
|
||||
port < 0
|
||||
|| ("https".equalsIgnoreCase(scheme) && port == 443)
|
||||
|| ("http".equalsIgnoreCase(scheme) && port == 80);
|
||||
if (!defaultPort) {
|
||||
```
|
||||
|
||||
그 클래스의 javadoc 이 이유를 적어 뒀다.
|
||||
|
||||
> Several providers sign the request URL, so getting this wrong turns every valid webhook into a signature failure.
|
||||
|
||||
같은 판정이 `ExternalOrigin:43`, `ExternalRequestContext:57`, 그리고 아웃바운드 httpclient 의 `CanonicalTarget:39` 에도 있다. 자료의 마지막 묶음은 `port == 443` 과 `port == 80` 두 문자열로 훑은 것이다. 네 자리가 모두 포트 비교를 `port == 443` 과 `port == 80` 으로 적어서 이 검색에 다 잡힌다. 스킴 비교는 셋이 서로 다른 형태다. 삼항으로 기본값을 고르는 형태는 걸리지 않으므로, 이 넷은 기본 포트를 판정하는 자리 전부가 아니라 출처를 문자열로 조립하면서 기본 포트만 생략하는 자리 전부다. 인바운드와 아웃바운드로 갈리지 않는다. 규칙을 안 쓰는 것이 이 알림 리프의 두 서명 자리다.
|
||||
|
||||
## 같은 어긋남이 한 자리 더 있다
|
||||
|
||||
```java
|
||||
// VapidJwtSigner.java:50
|
||||
String audience = endpoint.getScheme() + "://" + endpoint.getHost();
|
||||
```
|
||||
|
||||
VAPID 의 `aud` 는 푸시 서비스의 출처이고, 출처는 기본이 아닌 포트를 포함한다. 원문은 SES 한 자리를 셌다.
|
||||
|
||||
## 가드는 포트를 보지 않는다
|
||||
|
||||
자료의 네 번째 묶음이 그것이다. `NotificationEndpoints` 에는 `getPort` 를 읽는 줄이 없다. 그래서 라우팅 가능한 https 엔드포인트에 `:8443` 을 달아도 어느 프로파일에서든 통과한다.
|
||||
|
||||
```java
|
||||
// SesProviderProperties.java:27
|
||||
NotificationEndpoints.requireExternallyRoutable(endpoint, "SES endpoint", true);
|
||||
```
|
||||
|
||||
세 번째 인자는 상수 참이다. `NotificationEndpoints:61`의 javadoc 이 그 인자를 로컬과 계약 프로파일용이라고 적지만, 그 범위를 강제하는 코드는 없다.
|
||||
|
||||
## 이 어긋남은 오늘 이미 일어난다
|
||||
|
||||
```java
|
||||
// ProviderFaultHarness.java:52
|
||||
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
```
|
||||
|
||||
`SesNotificationProviderAdapterTest:254` 와 `ContractAdapters:102` 가 그 주소를 실제 `SesRequestMapper` 에 넘긴다. 웹푸시 쪽은 `WebPushProviderAdapterTest:173` 과 `ContractAdapters:264` 가 같은 주소로 `aud` 를 만든다. 자료의 나머지 줄은 호스트를 서명하지 않는 어댑터들이다. 대역이 서명을 검증하지 않으므로 초록으로 지나갈 뿐이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
서명을 검증하는 대역을 띄워 거절 응답까지 받아 보지는 않았다. 잰 것은 왕복 하나이고, 엔드포인트별 갈림은 계산이다. 웹푸시 쪽은 식과 규격만 읽었고 실제 토큰을 만들어 대조하지는 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a13-f009-sigv4-string
|
||||
title: 지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a13-f009-sigv4-string
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a13-f009-sigv4-string.body.md
|
||||
assets:
|
||||
- key: a13-f009-sigv4-string
|
||||
file: ../../../final/evidence/rendered/a13-f009-sigv4-string.svg
|
||||
- key: a13-f009-sigv4-string-heap
|
||||
file: ../../../final/evidence/rendered/a13-f009-sigv4-string-heap.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a13-f009-sigv4-string.txt
|
||||
- ../../../final/evidence/raw/a13-f009-sigv4-string-heap.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L844 이다.
|
||||
---
|
||||
|
||||
# 지운다고 약속한 사본은 아무도 받지 않고, 서명 자리가 받는 사본은 지울 수 없다
|
||||
|
||||
이 리프에는 비밀의 모양이 둘이다. 닫으면 자신을 0 으로 덮는 핸들과, 닫기가 없는 키 자재 복제다. 핸들을 받는 인터페이스의 구현은 시험 소스에만 있고, 서명 자리가 실제로 받는 것은 복제 쪽이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **포트를 뺀 호스트를 서명하는 자리가 둘이고, 같은 저장소의 네 자리는 그 포트를 지킨다**
|
||||
같은 SigV4 경로의 다른 자리다.
|
||||
- **비밀을 힙에서 지우는 마지막 단계가 종료 경로에 연결되지 않았다**
|
||||
지우는 장치가 있는데 그 손이 닿지 않는다는 점이 같다.
|
||||
- **정책을 담은 필드가 참이 된 적도 읽힌 적도 없다**
|
||||
선언한 보호 장치가 실제 경로에 없다는 점이 같다.
|
||||
|
||||
## 문제
|
||||
|
||||
원문은 서명 키 파생 한 줄이 지울 수 있는 비밀 사본을 불변 문자열로 올린다고 적었다. 그 사본이 정말 그 자리까지 오는지, 그리고 힙에 무엇이 남는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
오지 않는다. 두 모양이 서로 만나지 않는다.
|
||||
|
||||
지운다고 약속한 쪽은 핸들이다. 자바독이 닫을 때 자신을 지우는 연산 범위 사본이라고 적고, 닫기가 배열과 문자 배열을 각각 0 으로 채운다. 그 핸들을 받는 것은 제공자 시도 클라이언트 인터페이스다. 그 인터페이스를 구현하는 일곱 자리가 모두 시험 쪽에 있다. 핸들을 여는 두 어댑터도 main 에 있지만 main 에서 조립되지 않는다.
|
||||
|
||||
복제를 받는 길을 가르는 것은 목적이다. 제공자 계정 자격증명만 관리자를 거친다. 세대 범위가 붙은 유일한 비밀이라서다. 나머지 목적은 전부 제공자를 바로 조회하고, 웹훅 요청 서명과 VAPID 서명도 그 길을 탄다. 어느 길이든 돌아오는 것은 키 자재 레코드의 접근자가 만든 복제 배열이다. 그 레코드는 하나를 보관하고, 접근자가 불릴 때마다 한 벌씩 더 만든다. 지우는 메서드는 없다. 닫기가 사본에 닿지 못하는 것이 아니라 닫을 주체가 없다.
|
||||
|
||||
그 복제가 불변 문자열이 되는 자리는 셋이다. SES 서명 키 파생, Twilio 요청 매퍼, Twilio 조정 능력이다. 원문은 첫 자리만 셌다.
|
||||
|
||||
셋 모두 바이트를 문자열로 만들어 이어 붙인다. SES 는 그것을 다시 바이트로 되돌리고, Twilio 둘은 base64 문자열로 만든다.
|
||||
|
||||
힙에 무엇이 남는지 네 모드로 재봤다.
|
||||
|
||||
기준선은 둘과 하나다. 그 하나는 아무도 지울 수 없는 쪽이다. 다만 프로브는 받은 배열을 지운다. 실제 세 호출자는 그 배열을 인자 자리에 바로 넘기므로 지울 지역 변수조차 없고, 접근자는 부를 때마다 새 복제를 만든다.
|
||||
|
||||
서명을 지나면 거기서 넷이 늘었다.
|
||||
|
||||
바이트로만 이으면 그 넷 가운데 둘이 사라진다. 이은 배열까지 지우면 셋이다. 두 모드에 공통으로 살아남는 것이 키 명세가 만드는 복제다.
|
||||
|
||||
이웃 하나는 같은 자리에서 다른 선택을 했다. Twilio 서명 검증기는 인증 토큰을 바이트 그대로 키 명세에 넣는다. 다만 그 토큰은 다른 비밀이다. 콜백 서명 목적으로 조회되고 목적 검사까지 받는다.
|
||||
|
||||
SES 서명기 자신도 키 명세를 만든다. 거기 들어가는 배열이 바로 그 문자열에서 나온 것이다.
|
||||
|
||||
기능 쪽 위험도 하나 있다. 실제 AWS 키는 아스키라 왕복이 값을 바꾸지 않지만, 코드는 그것을 요구하지 않는다. 비아스키 바이트가 섞이면 파생 키가 조용히 달라진다.
|
||||
|
||||
판정은 P3 다. 근거가 둘이다. 힙에 닿을 수 있어야 회수된다는 것, 그리고 세 자리를 담은 클래스 가운데 둘은 시험 소스에서만 만들어지고 나머지 하나는 어디에서도 만들어지지 않는다는 것이다.
|
||||
|
||||
따르는 선례는 websocket 리프다. 거기서 도달 가능한 등급을 한 단계 내렸다. 배선되지 않았다는 이유로 P3 을 바로 매긴 grpc 리프는 따르지 않는다. grpc 리프는 런타임 소속이 비어 있고 이 리프는 app-bootstrap 에 올라 있다. 소속이 등급을 가른다는 규칙이 이 저장소에 따로 적혀 있다.
|
||||
|
||||
그래서 완화가 선례보다 약해 보인다. websocket 쪽은 기계가 강제하는 빌드 전용 등급을 근거로 한 단계를 낮췄는데, 여기서는 조립기가 없다는 것뿐이고 그 부재는 포크가 채우라고 기동 메시지가 직접 권한다.
|
||||
|
||||
그래도 한 단계를 낮추는 근거는 선례와 같다. websocket 선례가 실제로 물은 것은 소속이 아니라 출하되는 조합에서 그 시나리오가 일어날 수 있느냐였다. 조립기 없는 제공자 프로필을 설정하면 기동이 실패하므로, 여기서도 출하되는 어떤 조합에서도 이 실패는 일어나지 않는다. 조립되면 P2 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 비밀 두 모양의 호출 그래프 추적, 네 모드를 새 JVM 에서 각각 실행하고 힙 덤프 두 종
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 #L844 절이다.
|
||||
|
||||
1. 핸들의 자바독과 닫기 구현을 읽는다.
|
||||
2. 그 핸들을 받는 인터페이스의 구현을 이름 있는 것과 익명인 것 모두 센다.
|
||||
3. 서명 자리에 비밀을 넘기는 호출을 거꾸로 따라간다.
|
||||
4. 그 끝에 있는 키 자재 레코드가 사본을 어떻게 만드는지 읽는다.
|
||||
5. 그 사본을 문자열로 올리는 자리를 리프에서 찾는다.
|
||||
6. 레코드를 실제 경로와 같게 만들고 힙을 두 종류로 뜬다.
|
||||
7. 받기만 하는 모드, 바이트로만 잇는 모드, 이은 배열까지 지우는 모드, 서명하는 모드를 하나씩 돌린다.
|
||||
8. 서명 검증기가 쓰는 비밀의 목적이 무엇인지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
핸들의 존재 이유가 한 줄에 적혀 있다.
|
||||
|
||||
```java
|
||||
// NotificationSecretMaterialHandle.java:7, :60-70
|
||||
/** Versioned operation-scoped mutable secret copy that wipes itself on close. */
|
||||
public synchronized void close() {
|
||||
if (!closed) {
|
||||
if (bytes != null) {
|
||||
Arrays.fill(bytes, (byte) 0);
|
||||
}
|
||||
if (characters != null) {
|
||||
Arrays.fill(characters, '\0');
|
||||
}
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 그 사본은 서명 자리에 오지 않는다
|
||||
|
||||
:::evidence key="a13-f009-sigv4-string" alt="코드베이스 정적 검색 출력 62줄. 핸들의 약속과 지우는 줄, 그 핸들을 받는 인터페이스의 구현 일곱, 자격증명 관리자와 키 자재 레코드가 사본을 만드는 줄, 그 사본을 서명 자리로 넘기는 세 자리, 문자열로 올리는 세 자리, 그 문자열이 키 명세로 들어가는 줄, 제공자를 바로 조회하는 열세 줄과 그중 관리자 자신의 둘, 제공자 자격증명 목적을 푸는 두 줄, 서명 검증기가 쓰는 비밀의 목적이 차례로 보인다." caption="두 비밀 모양과 그 끝 — 62줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
핸들을 받는 것은 `NotificationProviderAttemptClient` 다. 그 인터페이스를 구현하는 자리는 저장소에 일곱이고 전부 시험 소스다. 이름 있는 것은 `NotificationReconciliationAdapterTest:164` 하나이고 나머지 여섯은 익명 클래스다. 메서드가 둘이라 `implements` 만 찾는 검색으로는 여섯이 보이지 않는다.
|
||||
|
||||
서명 자리가 받는 것은 다른 길에서 온다.
|
||||
|
||||
```java
|
||||
// ProviderCredentialManager.java:145-153
|
||||
public byte[] materialFor(ProviderProfileId profileId, long generation) {
|
||||
...
|
||||
if (active.generation() == generation) {
|
||||
return material(active).material();
|
||||
```
|
||||
|
||||
```java
|
||||
// SecretKeyMaterial.java:20, :23-26
|
||||
material = material.clone();
|
||||
@Override
|
||||
public byte[] material() {
|
||||
return material.clone();
|
||||
}
|
||||
```
|
||||
|
||||
레코드가 생성자에서 한 벌을 보관하고 접근자가 부를 때마다 한 벌을 더 만든다. 어느 쪽에도 닫기가 없다. `SesNotificationProviderAdapter:136`, `TwilioSmsProviderAdapter:98`, `TwilioReconciliationCapability:125` 가 그 배열을 인자 자리에 그대로 넘긴다. 지울 지역 변수를 두는 자리는 하나도 없다.
|
||||
|
||||
이 길을 타는 것은 제공자 계정 자격증명뿐이다. 목적을 `PROVIDER_CREDENTIAL` 로 확인하는 자리가 `ProviderCredentialManager:199` 와 `:224` 둘뿐이고 둘 다 관리자 안이다. 검사 없이 그 목적을 바로 조회하는 자리는 자료가 세어 본 대로 0 이다.
|
||||
|
||||
나머지 목적은 비밀 자재 제공자를 바로 조회한다. 자료의 그 묶음이 열세 줄인데 둘은 관리자 자신의 것이므로 밖에서 부르는 것은 열하나다. 웹훅 요청 서명(`WebhookNotificationProviderAdapter:220`)과 VAPID 서명(`VapidKeyRegistry:67`)도 제공자를 바로 조회하므로, 서명이냐 검증이냐로 갈리지 않는다. 아래에서 볼 검증기의 토큰도 여기서 온다.
|
||||
|
||||
## 그 배열을 문자열로 올리는 자리가 셋이다
|
||||
|
||||
```java
|
||||
// AwsSignatureV4Signer.java:117-119
|
||||
byte[] key =
|
||||
("AWS4" + new String(secretAccessKey, StandardCharsets.UTF_8))
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
```
|
||||
|
||||
```java
|
||||
// TwilioRequestMapper.java:41-45
|
||||
String credentials =
|
||||
Base64.getEncoder()
|
||||
.encodeToString(
|
||||
(properties.accountSid() + ":" + new String(authToken, StandardCharsets.UTF_8))
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
```
|
||||
|
||||
`TwilioReconciliationCapability:119-128` 이 같은 식을 한 번 더 쓴다. 다른 점은 바이트의 출처가 `credentials.materialFor(...)` 라고 그 자리에 직접 적혀 있다는 것뿐이다.
|
||||
|
||||
Twilio 두 자리는 SES 보다 넓다. 문자열이 되는 것이 토큰 하나가 아니라 `accountSid:token` 전체이고, 그것을 base64 로 부호화한 문자열도 함께 남는다.
|
||||
|
||||
## 힙에 몇 벌이 남는가
|
||||
|
||||
:::evidence key="a13-f009-sigv4-string-heap" alt="JVM 프로브 출력 7줄. 키 자재 레코드에서 받은 사본을 지운 뒤 힙 덤프 두 종에서 비밀 표식이 몇 번 나오는지를 네 모드에 대해 적었다. 표식은 아스키 코드로 조립한 합성 값이다." caption="네 모드에서 힙에 남은 자격증명 사본 — 7줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
실제 경로와 같은 모양으로 레코드를 만들고, 접근자가 준 배열을 쓴 뒤 지웠다. 네 모드를 각각 새 JVM 에서 돌렸다.
|
||||
|
||||
받아서 지우기만 한 모드가 기준선이다. 모든 객체 덤프에서 둘, 살아 있는 객체 덤프에서 하나다. 그 하나는 레코드가 계속 들고 있는 사본이다.
|
||||
|
||||
서명 키 파생을 지난 모드는 여섯이다. 기준선보다 넷이 많다.
|
||||
|
||||
문자열을 거치지 않고 바이트로만 이은 모드는 넷이다. 늘어난 넷 중 둘이 없어진 것이다. 원문이 제안하는 수정이 딱 여기까지다.
|
||||
|
||||
그 배열까지 지운 모드는 셋이다. 그 지우기는 제안에 없는 별도의 한 걸음이고, 지금 서명기도 파생 배열을 지우지 않는다. 어느 쪽이든 남는 하나는 `SecretKeySpec` 이 만드는 복제다. 두 모드 모두 HMAC 네 바퀴를 서명기와 똑같이 돈다.
|
||||
|
||||
프로브는 비밀을 문자열 리터럴로 적지 않는다. 아스키 코드 배열로 조립한다. 찾을 바이트열도 덤프를 뜬 뒤에 만든다. 둘 다 프로브 자신이 세어지는 것을 막기 위한 것이다.
|
||||
|
||||
## 이웃이 쓰는 것은 다른 비밀이다
|
||||
|
||||
```java
|
||||
// TwilioSignatureValidator.java:36
|
||||
mac.init(new SecretKeySpec(authToken, "HmacSHA1"));
|
||||
```
|
||||
|
||||
바이트를 그대로 넣는다. 다만 이 `authToken` 은 매퍼가 쓰는 것과 다른 비밀이다. `TwilioCallbackAdapter:78-79` 가 콜백 서명 키 참조로 조회하고 목적이 `CALLBACK_SIGNING` 인지 검사한다. 매퍼 쪽은 `PROVIDER_CREDENTIAL` 이다.
|
||||
|
||||
키 명세를 만드는 자리가 전부 바이트를 쓴다고도 말할 수 없다. `AwsSignatureV4Signer:129` 도 키 명세를 만드는데, 거기 들어가는 `key` 가 `:117-119` 의 문자열에서 나온 배열이다.
|
||||
|
||||
## 값이 상할 여지
|
||||
|
||||
AWS 가 발급하는 비밀 키는 아스키라 실제로는 UTF-8 왕복이 값을 바꾸지 않는다. 코드가 그것을 강제하지는 않는다. 자격증명은 base64 블롭에서 바인딩되고 길이만 검사받으므로, 0x80 이상 바이트가 들어오면 왕복이 그것을 대체 문자로 바꾸고 서명 키가 조용히 달라진다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
덤프에 남은 것들의 객체 정체는 코드를 읽어 짚었고 덤프 안에서 확인하지는 않았다. 프로브가 센 것은 바이트열의 출현 횟수다. Twilio 두 자리는 정적으로만 읽었고 힙으로 재현하지는 않았다. 0x80 이상 바이트가 실제로 서명을 깨는지도 재현하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a13-f007
|
||||
title: '"상한을 두고 읽는다"는 본문 핸들러가 전부 읽은 뒤에 자른다'
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a13-f007
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a13-f007.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a13-f007
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a13-f007.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a13-f007.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L795 이다.
|
||||
---
|
||||
|
||||
# "상한을 두고 읽는다"는 본문 핸들러가 전부 읽은 뒤에 자른다
|
||||
|
||||
자바독이 상한에서 읽기를 멈춘다고 적는다. 구현은 무제한 상류 구독자의 결과에 자르기 함수를 얹는다. 자르기는 전체가 힙에 모인 다음에 일어난다. 호출부 주석이 쓰지 말라고 명시한 바로 그 상류를 두 줄 아래 함수가 쓴다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **음수 Retry-After 가 확정 거절을 영구 중지되는 모호한 제출로 바꾼다**
|
||||
같은 리프의 다른 원격 입력 사례다.
|
||||
- **이름이 상한을 말한다고 상한이 걸리는 것은 아니다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **두 연산자가 이름만 있고 아무것도 하지 않는다**
|
||||
같은 계열의 서술과 코드 어긋남이다.
|
||||
|
||||
## 문제
|
||||
|
||||
응답 본문을 읽는 처리기가 있다. 자바독이 상한에서 읽기를 멈추는 본문 처리기라고 적는다.
|
||||
|
||||
호출부 주석도 이유를 길게 적는다.
|
||||
|
||||
제공자 응답은 진단이라는 것이다. 상태와 헤더 몇 개와 오류 문서라는 것이다. 상한 없이 읽으면 보내는 쪽의 힙이 저쪽이 보내기로 한 양의 함수가 된다는 것이다. 끝나지 않는 조각 응답은 요청 하나짜리 장애라는 것이다.
|
||||
|
||||
## 결론
|
||||
|
||||
구현이 그것을 하지 않는다.
|
||||
|
||||
바이트 배열 구독자를 상류로 두고 그 결과에 자르기 함수를 얹는다.
|
||||
|
||||
사상 구독자의 마무리 함수는 상류가 완료된 뒤 그 결과에 적용된다.
|
||||
|
||||
상류는 무제한으로 요청하여 본문 전체를 힙에 모은다. 자르기는 그다음이다.
|
||||
|
||||
호출부 주석이 막겠다고 선언한 것과 정확히 반대다.
|
||||
|
||||
주석은 그 상류를 쓰지 말라고 쓰여 있고, 두 줄 아래 함수가 그것을 상류로 쓴다.
|
||||
|
||||
실패 시나리오는 이렇다.
|
||||
|
||||
제공자나 그 자리에 들어온 무엇이든 응답으로 큰 본문을 빠르게 보낸다.
|
||||
|
||||
요청 시간 제한이 시간은 제한하므로 끝나지 않는 조각 응답은 시간 제한에서 끊긴다.
|
||||
|
||||
그러나 주석이 두 번째로 든 위험은 그대로다. 보내는 쪽의 힙이 저쪽이 보내기로 한 양의 함수가 되는 상황이다.
|
||||
|
||||
시간 제한 안에 수 기가바이트를 받을 수 있는 연결에서는 그만큼이 전부 배열로 쌓인 뒤 상한 크기로 잘린다.
|
||||
|
||||
동시 발송이 많을수록 배수로 늘어난다. 이 리프는 발송 작업자가 가상 스레드로 퍼져 나간다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 구현과 자바독, 호출부 주석 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/173 계열에 있다.
|
||||
|
||||
1. 본문 처리기의 자바독을 읽는다.
|
||||
2. 구현이 어떤 상류 구독자를 쓰는지 확인한다.
|
||||
3. 사상 구독자의 마무리 함수가 언제 적용되는지 확인한다.
|
||||
4. 호출부 주석을 읽는다.
|
||||
5. 주석이 배제한 상류와 구현의 상류를 대조한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
본문 핸들러는 다음이다.
|
||||
|
||||
```java
|
||||
// JdkNotificationHttpGateway.java:116-128
|
||||
/**
|
||||
* A body handler that stops reading at the cap.
|
||||
*/
|
||||
private static HttpResponse.BodyHandler<byte[]> boundedBody(int maxBytes) {
|
||||
return responseInfo ->
|
||||
HttpResponse.BodySubscribers.mapping(
|
||||
HttpResponse.BodySubscribers.ofByteArray(),
|
||||
body -> body.length <= maxBytes ? body : java.util.Arrays.copyOf(body, maxBytes));
|
||||
}
|
||||
```
|
||||
|
||||
## 본문 핸들러가 도는 순서
|
||||
|
||||
:::evidence key="analysis-finding-a13-f007" alt="분석 문서 analysis/13-adapter-outbound-notification.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/13-adapter-outbound-notification.md 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## finisher는 upstream이 끝난 뒤에 돈다
|
||||
|
||||
`BodySubscribers.mapping(upstream, finisher)`의 finisher는 upstream이 완료된 뒤 그 결과에 적용된다. upstream은 `ofByteArray()`이고, 그것은 무제한으로 요청하여 본문 **전체를 힙에 모은다**. 잘라내기는 그 다음이다.
|
||||
|
||||
## 호출부 주석이 선언한 것과 정확히 반대다
|
||||
|
||||
`:60-62` — "Bounded, not ofByteArray(). A provider response is diagnostic — a status, some headers, an…". P2.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
큰 응답을 보내는 가짜 제공자로 힙 증가를 측정하지 않았다. 구독자 규약상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a13-f010
|
||||
title: FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖에 있고, 그 FCM이 두 계약 집합 어디에도 없다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a13-f010
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a13-f010.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a13-f010
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a13-f010.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a13-f010.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L957 이다.
|
||||
---
|
||||
|
||||
# FCM만 "커밋 후 응답 손실 = ambiguous" 규칙 밖에 있고, 그 FCM이 두 계약 집합 어디에도 없다
|
||||
|
||||
공통 결과 변환기의 자바독이 모든 HTTP 제공자 어댑터가 전송 실패를 여기로 보낸다고 적는다. 한 제공자 경로는 그것을 지나지 않는다. 그리고 그 경로는 배치라 한 번의 손실이 최대 배치 크기만큼의 배달에 동시에 영향을 준다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **재시도 안전은 증거로 결정된다**
|
||||
이 규칙이 존재하는 이유다.
|
||||
- **상한을 두고 읽는다는 본문 handler가 전부 읽은 뒤에 자른다**
|
||||
같은 리프의 다른 P2 다.
|
||||
- **모든 어댑터가 지난다고 적힌 통로에 예외가 있으면 규칙은 한 번 쓰인 것이 아니다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
|
||||
## 문제
|
||||
|
||||
공통 결과 변환기의 존재 이유가 클래스 자바독에 쓰여 있다.
|
||||
|
||||
모든 HTTP 제공자 어댑터가 전송 실패를 여기로 보내므로, 본문이 커밋된 뒤 응답이 없으면 모호하다는 규칙이 제공자마다 다시 유도되지 않고 한 번만 쓰인다는 것이다.
|
||||
|
||||
모든 어댑터가 실제로 지나는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
한 경로가 지나지 않는다.
|
||||
|
||||
세 계층 전부에 번역도 방어도 없다.
|
||||
|
||||
게이트웨이 심은 실패 계약을 선언하지 않은 함수형 인터페이스다. 배치를 보내고 배치 결과를 돌려준다.
|
||||
|
||||
조정자의 제출 메서드에는 잡기도 없고 전송 변환 호출도 없다. 게이트웨이를 부르고 크기를 비교한 뒤 어긋나면 상태 예외를 던진다.
|
||||
|
||||
어댑터는 조정자 결과를 그대로 통과시킨다.
|
||||
|
||||
전송 예외 타입도 본문 커밋 비트도 이 경로에는 존재하지 않는다.
|
||||
|
||||
다른 여섯 어댑터가 모두 전송 예외를 잡아 공통 변환기로 보내고 모호 결과와 응답 손실 증거를 만드는 자리에서, 이 경로는 구현이 던지는 임의의 실행 예외를 그대로 올려보낸다.
|
||||
|
||||
실패 시나리오는 이렇다.
|
||||
|
||||
다중 수신 요청의 본문이 기록된 뒤 연결이 끊긴다.
|
||||
|
||||
배치가 최대 배치 크기만큼의 수신자를 담고 있으므로 한 번의 손실이 그만큼의 배달에 동시에 영향을 준다.
|
||||
|
||||
결과는 모호가 아니라 분류되지 않은 예외다.
|
||||
|
||||
일정 작업자의 포괄 잡기가 예외 타입을 사유로 로그를 남기고 임차를 만료시킨다.
|
||||
|
||||
임차 회수 서비스가 미완 시도를 보고, 증명 가능하게 시작되지 않았다가 거짓이므로 재대사로 넘긴다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 어댑터별 전송 실패 경로 전수 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/173 계열에 있다.
|
||||
|
||||
1. 공통 결과 변환기의 클래스 자바독을 읽는다.
|
||||
2. 어댑터 목록을 만들고 각각의 전송 실패 처리를 확인한다.
|
||||
3. 예외 경로에 잡기가 있는지 본다.
|
||||
4. 전송 예외 타입과 커밋 비트가 그 경로에 존재하는지 검색한다.
|
||||
5. 그 경로가 배치인지 단건인지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`ProviderResults`의 존재 이유가 클래스 javadoc에 쓰여 있다.
|
||||
|
||||
> "**Every HTTP provider adapter routes its transport failures through here**, so the rule that 'committed body plus no response equals ambiguous' is written once rather than re-derived per provider."
|
||||
|
||||
## ProviderResults 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a13-f010" alt="코드베이스에서 ProviderResults 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ProviderResults 코드베이스 검색 — 10줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## FCM 경로는 그것을 지나지 않는다
|
||||
|
||||
세 계층 전부에 번역도 방어도 없다.
|
||||
|
||||
```java
|
||||
// FcmGateway.java — 실패 계약이 선언되지 않은 심
|
||||
@FunctionalInterface
|
||||
public interface FcmGateway {
|
||||
FcmBatchResult sendBatch(List<Map<String, Object>> messages);
|
||||
}
|
||||
|
||||
// FcmBatchCoordinator.submit — try/catch 없음, fromTransport 없음
|
||||
FcmBatchResult batch = gateway.sendBatch(messages);
|
||||
if (batch.items().size() != submissions.size()) { throw new IllegalStateException(...); }
|
||||
|
||||
// FcmNotificationProviderAdapter — 그대로 통과
|
||||
return coordinator.submit(submissions);
|
||||
```
|
||||
|
||||
그리고 그 FCM이 두 계약 집합 어디에도 없다. P2.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
배치 전송 중 연결을 끊어 실제 흐름을 재현하지 않았다. 경로 구조상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a13-f011
|
||||
title: 공유 provider 계약이 8종 중 3종에서만 상속되고, 강제 장치가 없다
|
||||
topic: notification-and-delivery
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a13-f011
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: analysis-finding-a13-f011
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a13-f011.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a13-f011.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/13-adapter-outbound-notification.md#L990 이다.
|
||||
---
|
||||
|
||||
# 공유 provider 계약이 8종 중 3종에서만 상속되고, 강제 장치가 없다
|
||||
|
||||
계약 인터페이스의 자바독이 새 제공자는 같은 세 질문에 답하지 않고는 추가될 수 없다고 약속한다. 여덟 어댑터 중 셋만 그것을 상속하고, 그 약속을 지키는 장치가 없다. 같은 실패 양식에 대한 구조적 강제를 이 저장소는 이미 두 번 만들었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **한 제공자만 커밋 후 응답 손실은 모호하다 규칙 밖에 있고 그 제공자가 두 계약 집합 어디에도 없다**
|
||||
이 계수에서 유일하게 확인되지 않는 제공자다.
|
||||
- **문서가 선언한 경계는 코드가 닫아야 경계다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다**
|
||||
같은 계열의 강제 장치 부재다.
|
||||
|
||||
## 문제
|
||||
|
||||
제공자 어댑터 계약 인터페이스가 성질을 자바독으로 약속한다.
|
||||
|
||||
새 제공자는 같은 세 질문에 답하지 않고는 추가될 수 없다는 것이다.
|
||||
|
||||
여덟 어댑터가 그것을 상속하는지 셌다.
|
||||
|
||||
## 결론
|
||||
|
||||
셋만 상속한다.
|
||||
|
||||
그리고 그 약속을 지키는 장치가 없다. 상속하지 않아도 빌드가 통과한다.
|
||||
|
||||
이 저장소는 같은 실패 양식에 대해 구조적 강제를 이미 두 번 만들었다. 엔드포인트 가드 호출 지점 테스트와 알림 API 표면 검증 태스크다.
|
||||
|
||||
그러므로 형태는 이미 있다. 적용되지 않았을 뿐이다.
|
||||
|
||||
다만 실질 커버리지는 상속보다 넓다.
|
||||
|
||||
셋째 질문인 응답이 오지 않을 때에 대해서는 두 제공자가 자체 테스트에서 모호 결과를 확인하고, 하나는 교차 제공자 묶음이 확인한다.
|
||||
|
||||
확인되지 않는 유일한 제공자가 배치 경로의 그것이고, 그것이 별도 사례로 기록되어 있다.
|
||||
|
||||
판정은 P3 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : 계약 상속 계수와 강제 장치 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/173 계열에 있다.
|
||||
|
||||
1. 계약 인터페이스의 자바독 약속을 읽는다.
|
||||
2. 어댑터 목록을 만들고 상속 여부를 센다.
|
||||
3. 상속을 강제하는 테스트나 빌드 태스크가 있는지 검색한다.
|
||||
4. 이 저장소의 다른 구조적 강제 장치를 확인한다.
|
||||
5. 셋째 질문의 실질 커버리지를 제공자별로 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`ProviderAdapterContract`의 javadoc이 약속하는 성질("a new provider cannot be added without answering the same three questions")을 지키는 장치가 없다(§28.1·§28.3). 8종 중 3종에서만 상속된다.
|
||||
|
||||
## ProviderAdapterContract 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a13-f011" alt="코드베이스에서 ProviderAdapterContract 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ProviderAdapterContract 코드베이스 검색 — 4줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 형태는 이미 있다
|
||||
|
||||
이 저장소는 같은 실패 양식에 대해 `EndpointGuardCallSiteTest`와 `verifyNotificationApiSurface`라는 구조적 강제를 이미 두 번 만들었다 — 적용되지 않았을 뿐이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
상속하지 않는 제공자를 새로 추가해 빌드가 통과하는지 실행하지 않았다. 강제 장치 부재상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user