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:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,111 @@
---
kind: CASE
slug: a-cleanup-claim-without-fencing
title: claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
topic: fileserver-state-and-fencing
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-cleanup-claim-without-fencing
evidenceCapturedOn: 2026-09-02
assets:
- key: a-cleanup-claim-without-fencing
file: ../../../final/evidence/rendered/a-cleanup-claim-without-fencing.svg
evidence:
- ../../../final/evidence/raw/a-cleanup-claim-without-fencing.txt
source:
- 컬럼 정의와 두 결과의 사후 기록은 `V3__fileserver_fenced_cleanup_lease.sql` 의 헤더와 두 COMMENT 에 있다. 토큰 대조와 널 만료 제외의 이유는 `FileserverCleanupRepository` 의 두 메서드 javadoc 에 있다. 원본 분석은 `analysis/05` §79 가 이 스키마를 다룬다.
---
# claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다
정리 항목의 청구가 상태만 바꾸고 소유자도 토큰도 리스 만료도 기록하지 않았다. 하나의 누락에서 회수되지 않는 항목과 덮어쓰기라는 두 결과가 나왔다.
## 관계
- **fenced lease — 만료 시각만으로는 부족한 이유**
이 사례가 같은 문제의 파일서버 판이다.
- **리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다**
같은 형태가 메시징 어댑터에서 나타난 사례다.
- **cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다**
같은 저장소의 다음 마이그레이션이 다룬 문제다.
## 문제
정리 작업은 파일을 물리적으로 지운 뒤 데이터베이스를 정산한다. 그 사이에 작업자가 죽을 수 있고, 여러 작업자가 같은 항목을 두고 겹칠 수 있다.
청구가 그 두 상황을 구별할 정보를 남기지 않았다. 상태를 진행 중으로 옮기는 것이 전부였다.
## 결론
수정은 컬럼 넷과 질의 둘이다.
컬럼은 청구 소유자, 청구 토큰, 리스 만료, 그리고 청구 세대 계수기다. 질의는 완료 갱신이 토큰으로 행을 찾게 만든 것과, 회수기가 만료된 청구를 오래된 것부터 가져오되 널 만료는 제외하게 만든 것이다.
두 결과가 어떻게 생겼고 각 컬럼이 무엇을 맡는지는 본문이 다룬다.
## 검증 환경
데이터베이스 : PostgreSQL
마이그레이션 도구 : Flyway
확인 방식 : V3 마이그레이션의 컬럼 정의와 정리 저장소 질의 두 개 확인, claim_fence 참조 전수 검색
소스 수정 : x
## 재현 조건
1. fileserver 의 V3 마이그레이션 헤더를 읽는다. 두 결과가 나란히 적혀 있다.
2. 추가된 컬럼 넷과 각각의 널 허용 여부를 확인한다.
3. 완료 갱신 질의가 무엇으로 행을 찾는지 확인한다.
4. 회수기 질의가 널 만료를 어떻게 다루는지 확인한다.
5. claim_fence 를 읽는 코드가 있는지 검색한다.
## 본문
<!-- body:start -->
정리 항목의 청구가 상태를 `IN_PROGRESS` 로 옮기고 그 외에는 아무것도 기록하지 않았다. 마이그레이션 헤더가 그 누락에서 나온 결과를 둘로 적는다.
## 하나는 아무도 손대지 않아서, 하나는 두 손이 겹쳐서
첫째는 회수되지 않는 항목이었다. 물리 삭제를 수행하고 데이터베이스를 정산하기 전에 죽은 작업자가 행을 진행 중 상태로 영원히 남겼다. 어떤 질의도 그 항목을 살아 있는 작업자가 지금 지우고 있는 항목과 구별할 수 없었다. 파일은 이미 사라졌는데 쿼터와 수명주기는 정산되지 않은 채 남았다.
둘째는 덮어쓰기였다. 완료 갱신이 정리 식별자만으로 행을 찾았다. 어떤 합리적 리스보다 오래 멈춰 있던 작업자가 깨어나, 그 사이 다른 작업자가 청구해 반쯤 진행한 항목 위에 완료를 쓸 수 있었다.
같은 누락에서 나왔지만 방향이 반대다.
## 컬럼 넷과 질의 둘
:::evidence key="a-cleanup-claim-without-fencing" alt="코드베이스에서 V3 마이그레이션의 컬럼 넷과 정리 저장소의 질의, 그리고 claim_fence 참조를 뽑은 출력 21줄. 완료 갱신이 토큰으로 행을 찾고 회수기가 널 만료를 제외한다는 것, claimFence 는 증가만 하고 그 엔티티의 getter 열셋 안에 없다는 것이 그 출력에 그대로 보인다." caption="V3 컬럼 넷 · 토큰 대조 · 널 만료 제외 · claimFence 참조 — 21줄 · exit 0" zoom="true"
:::
컬럼은 `claim_owner` · `claim_token` · `lease_until` · `claim_fence` 다. 앞의 셋은 널을 허용하고 넷째만 `NOT NULL DEFAULT 0` 이다. 헤더가 그 이유를 하나로 적는다 — 이 마이그레이션 이전에 청구된 항목이 계속 동작해야 하기 때문이다.
스키마만 바뀐 것이 아니다. 저장소의 질의 둘이 그 컬럼을 실제로 쓴다.
## 토큰이 덮어쓰기를 막는다
완료 갱신의 `where` 절에 `and c.claimToken = :token` 이 붙었다. 그 메서드의 javadoc 이 이전 상태를 적는다 — 예전에는 정리 식별자만으로 대조했고, 그래서 오래 멈춰 있던 작업자가 다른 작업자의 진행 중 항목 위에 완료를 쓸 수 있었다.
반환 타입이 `int` 다. 교체된 작업자는 예외를 받는 것이 아니라 **갱신 행 수 0** 을 받는다. 행을 못 찾는 것이 실패가 아니라 답이 되는 형태다.
## 널 만료를 회수기가 건너뛴다
회수기 질의는 진행 중이면서 만료가 지난 항목을 만료 순으로 가져온다. 조건에 `and c.leaseUntil is not null` 이 들어 있다.
그 메서드의 javadoc 이 이유를 적는다 — 널 리스는 펜싱이 생기기 전에 청구됐다는 뜻이고, 자동으로 넘겨받는 것은 아무도 상태를 기록하지 않은 작업에 대해 추측하는 일이다. 그 항목이 물리 삭제를 마쳤는지 시작도 안 했는지 알 방법이 없다. 그래서 운영자를 필요로 한다.
부분 인덱스가 그 질의 모양 그대로 만들어져 있다 — `lease_until` 에 걸리고 조건이 `status = 'IN_PROGRESS'` 다.
## 넷째 컬럼은 아직 소비자가 없다
`claim_fence` 는 청구할 때마다 1 씩 오른다. 저장소에서 그 이름이 나오는 곳은 그 증가 한 줄과 엔티티의 필드 선언뿐이다.
엔티티는 getter 열셋을 갖는다. `getClaimToken``getLeaseUntil` 은 있고 `claimFence` 의 getter 는 없다. 즉 값이 올라가기만 하고 어디에서도 읽히거나 비교되지 않는다.
마이그레이션은 이 컬럼에만 COMMENT 를 붙이지 않았다. 다른 둘에는 무엇을 위한 값인지 적혀 있다.
## 확인하지 못한 것
작업자를 죽여 진행 중 항목이 남는 것을 재현하지 않았다. 컨테이너 레인을 돌리지 않았다.
<!-- body:end -->
@@ -0,0 +1,114 @@
---
kind: CASE
slug: a-read-then-delete-race-on-the-upload-lease
title: cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
topic: fileserver-state-and-fencing
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-read-then-delete-race-on-the-upload-lease
evidenceCapturedOn: 2026-09-02
assets:
- key: a-read-then-delete-race-on-the-upload-lease
file: ../../../final/evidence/rendered/a-read-then-delete-race-on-the-upload-lease.svg
evidence:
- ../../../final/evidence/raw/a-read-then-delete-race-on-the-upload-lease.txt
source:
- 이 경합의 1차 기록은 `V4__fileserver_upload_terminal_state.sql` 의 헤더다. 분석 문서는 두 곳에서 이 사건을 다룬다. application-core 편 §11.2 가 취소와 정리의 경합을 청구가 만드는 경계로 정리하고, persistence-jpa 편 §83.3 이 이전 리뷰가 올린 소유자·토큰·최종 상태 부재를 V3·V4 와 현재 저장소 코드가 해소했다고 적는다. 같은 편 §79 의 마이그레이션 목록도 이 컬럼을 언급한다.
---
# cleanup이 읽은 lease와 삭제 사이에 writer가 그 lease를 얻을 수 있었다
정리 작업이 쓰기 리스를 읽어 없음을 확인하고 스테이징 바이트를 지웠다. 읽기와 삭제 사이에 쓰기 작업자가 바로 그 리스를 얻을 수 있었고, 정리가 지운 것은 업로드가 이어 쓰고 있던 객체였다.
## 관계
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
이 사례에서 끌어낸 규칙이다.
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
같은 형태를 다른 저장소에서 다룬 규칙이다.
- **claim이 소유자·토큰·만료를 기록하지 않아 죽은 worker의 항목이 영영 남았다**
같은 어댑터의 앞선 마이그레이션이 다룬 문제다.
## 문제
정리 작업은 스테이징 바이트를 지우기 전에 그 업로드에 쓰기 리스가 걸려 있는지 확인했다. 리스가 없으면 아무도 쓰고 있지 않다고 판단하고 지웠다.
읽기와 삭제 사이에 쓰기 작업자가 그 리스를 얻을 수 있었다. 업로드가 끝났다는 표시가 데이터베이스에 없었기 때문이다.
쓰기 작업자의 획득 문장도 그것을 막지 못했다. 그 문장은 업로드의 만료와 리스만 확인했고, 취소되었는지 검증에 실패했는지는 확인하지 않았다.
## 결론
수정은 두 쪽이 함께 볼 상태 컬럼을 만드는 것이었다. 취소와 검증 실패 마무리가 정리를 큐에 넣는 같은 트랜잭션 안에서 세션을 최종 상태로 옮기고, 획득과 갱신과 오프셋 커밋은 활성 상태를 요구한다.
정리는 조금 전에 읽은 값으로 판단하는 대신 조건부 갱신으로 행을 청구한다. 0행이 돌아오면 삭제를 다음 주기로 넘긴다.
기본값이 활성으로 잡혀 있어 이 컬럼이 생기기 전 세션도 영향을 받지 않는다.
리스 반납 문장에는 이 조건이 없다. 클래스 javadoc 은 모든 쓰기 문장이 활성을 요구한다고 적는데, 그 문장 하나는 그렇지 않다.
## 검증 환경
데이터베이스 : PostgreSQL
마이그레이션 도구 : Flyway
확인 방식 : 마이그레이션 헤더의 사후 기록과 현재 구현의 조건절·트랜잭션 경계 확인
소스 수정 : x
## 재현 조건
1. 업로드 최종 상태 마이그레이션의 헤더를 읽는다. 경합 순서와 양쪽의 판단 근거가 적혀 있다.
2. 추가된 상태 컬럼과 CHECK 제약, 부분 인덱스를 확인한다.
3. 저장소의 쓰기·전이 문장을 전부 세고, 그중 몇 개가 활성 상태를 조건으로 거는지 확인한다.
4. 클래스 javadoc 의 주장과 각 문장의 실제 조건을 대조한다.
5. 최종화와 정리 큐잉을 감싸는 트랜잭션 람다를 두 경로에서 시작부터 끝까지 읽는다.
6. 정리가 청구 전에 무엇을 하는지, 청구에 실패하면 무엇을 하는지 확인한다.
## 본문
<!-- body:start -->
마이그레이션 헤더가 이 경합을 사후에 기록한다. 정리는 리스의 부재를 봤고, 쓰기의 획득 문장은 업로드의 만료와 리스를 봤다. 취소되었는지 검증에 실패했는지를 묻는 쪽은 없었다.
그래서 이미 삭제 예정인 바이트에 대해 획득 문장이 리스를 내주었다. 그 요청을 거를 근거를 갖고 있지 않았다.
## 상태 하나를 두 쪽이 같이 본다
:::evidence key="a-read-then-delete-race-on-the-upload-lease" alt="코드베이스에서 V4 마이그레이션이 더한 상태 컬럼과 CHECK 제약과 부분 인덱스, 이 저장소의 쓰기·전이 문장 여섯과 그중 활성 상태를 조건으로 거는 네 줄, 모든 쓰기 문장이 활성을 요구한다고 적은 클래스 javadoc 과 그 조건이 없는 리스 반납 문장, 최종화와 정리 청구가 요구하는 상태, 취소 경로와 검증 실패 경로의 트랜잭션 람다 전체, 그리고 정리가 세션을 먼저 읽고 있을 때만 청구하는 조건을 뽑은 출력. javadoc 의 주장과 리스 반납 문장의 조건이 어긋난다는 것이 그 출력에 나란히 보인다." caption="상태 컬럼과 제약 · 문장 여섯과 활성 조건 넷 · javadoc 과 어긋나는 반납 문장 · 두 경로의 트랜잭션 전체 · 정리의 읽기와 청구" zoom="true"
:::
V4 가 세션 테이블에 상태 컬럼을 더한다. 값은 활성과 최종 둘이고 CHECK 로 묶여 있다. 기본값이 활성이라 이 컬럼 이전의 세션은 그대로 동작한다.
같은 마이그레이션이 부분 인덱스도 만든다. 최종 상태인 행만 담고 리스 만료로 정렬한다. 청구 문장의 술어와 같은 조건이지만, 청구 자체는 업로드 아이디 단건 갱신이라 이 인덱스를 타지 않는다. 최종 상태 집합을 만료 순으로 훑을 때를 위한 모양이다.
## 쓰기 문장 넷 중 셋이 활성을 요구한다
리스 획득과 갱신과 오프셋 커밋의 조건절에 활성 상태가 들어갔다. 최종 상태로 옮겨진 세션에는 이 셋이 걸리지 않는다.
리스 반납 문장에는 없다. 업로드 아이디와 리스 토큰만 본다. 최종 상태로 옮겨진 뒤에도 진행 중이던 쓰기 작업자가 자기 리스를 놓을 수 있어야 하고, 반납은 리스 컬럼을 비울 뿐 바이트를 건드리지 않는다.
클래스 javadoc 은 모든 쓰기 문장이 활성을 요구한다고 적는다. 넷 중 셋이다.
최종화 문장 자체도 활성일 때만 성립한다. 이미 최종인 행을 다시 최종으로 옮기는 시도는 0행을 갱신한다.
## 최종화와 정리 큐잉이 한 트랜잭션 안에 있다
취소 경로와 검증 실패 경로가 각각 하나의 트랜잭션 안에서 세 가지를 함께 한다. 기록을 도달 불가로 옮기고, 세션을 최종 상태로 옮기고, 정리 요청을 큐에 넣는다.
셋이 함께 커밋되므로 큐에 항목이 있는데 세션은 아직 활성인 순간이 없다. 취소 경로의 주석이 그 이유를 적는다 — 큐잉만 하면 세션이 활성으로 남아, 정리가 지우려는 바로 그 바이트에 쓰기가 리스를 얻을 수 있었다는 것이다.
첫 번째 쓰기에도 이유가 붙어 있다. 기록이 먼저 도달 불가가 되어야 물리 작업이 예약되고, 둘이 함께 커밋되므로 파일이 도달 불가인데 회수할 것이 큐에 없는 상태가 남지 않는다.
## 정리는 읽은 값 대신 청구로 판단한다
정리 청구 문장은 최종 상태이고 리스가 없거나 만료된 행만 갱신한다. 그 한 문장이 리스가 걸려 있지 않다는 것을 증명하는 동시에 리스 컬럼을 비운다.
청구가 0행이면 지우지 않는다. 항목을 실패로 표시하고 사유를 남기고 결과를 건너뜀으로 돌려준다.
다만 청구는 세션 행이 있을 때만 걸린다. 조건이 존재 확인과 청구의 논리곱이라, 세션이 이미 사라진 항목은 청구를 거치지 않고 바로 지운다 — 지킬 리스를 가진 쓰기 작업자가 있을 수 없는 경우다.
## 확인하지 못한 것
읽기와 삭제 사이에서 리스를 가로채는 경합을 재현해 보지는 않았다. 경합 순서는 마이그레이션 헤더의 사후 기록이고, 수정의 현재 형태는 코드로 확인했다.
<!-- body:end -->
@@ -0,0 +1,88 @@
---
kind: CASE
slug: readme-said-no-beans-there-are-eight
title: README가 "노출된 setting도 bean도 없다"고 적은 능력에 production bean 여덟이 있다
topic: fileserver-state-and-fencing
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:readme-said-no-beans-there-are-eight
evidenceCapturedOn: 2026-09-01
assets:
- key: readme-said-no-beans-there-are-eight
file: ../../../final/evidence/rendered/readme-said-no-beans-there-are-eight.svg
evidence:
- ../../../final/evidence/raw/readme-said-no-beans-there-are-eight.txt
source:
- 원본 분석 절은 final/document.md#4-4 · analysis/08 §4, §39 이다.
---
# README가 "노출된 setting도 bean도 없다"고 적은 능력에 production bean 여덟이 있다
리프의 README 가 어떤 능력에 대해 노출된 설정도 빈도 없다고 적는다. 그 능력의 포트 구현 여덟이 프로덕션 빈으로 정의되어 있다.
## 관계
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례는 과소 진술 방향이다.
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
반대 방향의 확인이 필요한 사례다.
- **문서의 수치는 세지 말고 파생하거나 게이트로 붙든다**
같은 계열의 예방 규칙이다.
## 문제
리프의 README 는 각 능력이 무엇을 노출하는지 서술한다.
한 능력에 대해 노출된 설정도 빈도 없다고 적는다.
## 결론
그 능력의 포트 구현 여덟이 프로덕션 빈으로 정의되어 있다.
드리프트의 방향이 과소 진술이다. 문서가 실제보다 적게 말한다.
과대 진술보다 덜 위험하다. 문서를 믿고 자기 방어를 생략하는 종류의 손해가 없기 때문이다.
그러나 비용이 없지는 않다.
그 능력의 동작을 조정하려는 사람이 조정할 것이 없다고 이해한다
빈이 있는 줄 모르므로 그 빈들이 무엇에 의존하는지 확인하지 않는다
능력을 끄려 할 때 무엇을 꺼야 하는지 문서가 답하지 않는다
이 리프의 다른 문서 항목들과 비교하면 이 서술만 뒤처져 있다. 여덟 개가 한꺼번에 빠진 것이므로 서술 시점 이후에 추가된 것으로 보인다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : README 문장과 빈 정의 대조
소스 수정 : x
## 재현 조건
1. 리프의 README 에서 해당 능력의 서술을 읽는다.
2. 그 능력의 포트 인터페이스를 찾는다.
3. 그 포트의 구현과 빈 정의를 센다.
## 본문
<!-- body:start -->
README가 특정 능력들에 대해 "노출된 setting도 bean도 없다"고 적는데, 여덟 개의 port 구현과 여덟 개의 bean이 있다.
## README 의 문장과 실제 bean 수
:::evidence key="readme-said-no-beans-there-are-eight" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 방향이 과소 진술이다
위험이 과대보다 낮지만, 이 문서를 읽고 "그 능력은 아직 없다"고 판단한 팀이 같은 것을 다시 만들 수 있다.
## 확인하지 못한 것
여덟 빈이 실제로 컨텍스트에 들어가는지 조건을 따라 확인하지 않았다. 이 기록은 빈 정의의 존재와 문서 서술의 대조다.
없음
<!-- body:end -->
@@ -0,0 +1,98 @@
---
kind: CASE
slug: scriptable-detection-bypassed-by-a-bom
title: scriptable 콘텐츠 탐지가 BOM·NUL·주석으로 우회된다
topic: fileserver-state-and-fencing
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:scriptable-detection-bypassed-by-a-bom
evidenceCapturedOn: 2026-09-01
assets:
- key: scriptable-detection-bypassed-by-a-bom
file: ../../../final/evidence/rendered/scriptable-detection-bypassed-by-a-bom.svg
evidence:
- ../../../final/evidence/raw/scriptable-detection-bypassed-by-a-bom.txt
source:
- 원본 분석 절은 final/document.md#4-4 · analysis/08 §40 이다.
---
# scriptable 콘텐츠 탐지가 BOM·NUL·주석으로 우회된다
브라우저가 실행할 수 있는 콘텐츠를 탐지하는 정책이 접두사 시작 매칭을 쓴다. 마커 앞에 바이트가 하나라도 있으면 탐지되지 않고, 브라우저는 그런 파일도 실행한다.
## 관계
- **접두사 시작 매칭은 시그니처에는 맞고 스니핑 패턴에는 맞지 않는다**
이 사례에서 끌어낸 규칙이다.
- **sanitize가 아니라 reject가 기본이다**
이 정책이 따르는 기본 방침이다.
## 문제
정책의 의도는 명확하고 옳다. javadoc 이 그것을 적는다.
브라우저가 인라인으로 제공될 경우 실행할 콘텐츠를 막는다. 탐지는 주장된 타입이나 확장자가 아니라 콘텐츠에 대해 한다. 둘 다 공격자가 통제하기 때문이다. 명시적 안전 프로파일이 켜져 있지 않으면 실행 가능 콘텐츠는 게시되지 않고 격리된다.
마커 목록도 합리적이다. HTML 선언과 여는 태그들과 XML 선언과 엔티티 선언이다.
## 결론
매칭 방식이 접두사 시작이다.
앞의 1024 바이트를 읽고 그 안에서 마커를 찾는데, 마커가 콘텐츠의 시작에 있어야 한다.
브라우저는 그렇게 엄격하지 않다. 앞에 바이트가 있어도 콘텐츠를 스니핑해 실행한다.
그래서 우회가 여럿이다.
바이트 순서 표시를 앞에 붙이면 마커가 시작이 아니다
널 바이트를 앞에 넣어도 같다
주석이나 공백을 앞에 두어도 같다
실행 탐침이 이 우회들을 확인했다.
시그니처 검사와 스니핑 패턴 검사는 다른 문제다. 시그니처는 파일 형식이 정의상 특정 바이트로 시작하므로 시작 매칭이 맞다. 브라우저 스니핑은 형식 정의가 아니라 관용적 해석이므로 시작 매칭이 맞지 않는다.
같은 함수가 두 목적에 쓰이면 한쪽이 틀린다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 실행 탐침으로 우회 입력 확인
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/146-fileserver-verification-security-audit-probes.txt 의 실행 탐침 블록에 있다.
1. 정책 클래스의 마커 목록과 접두사 길이를 확인한다.
2. 매칭이 시작 기준인지 포함 기준인지 확인한다.
3. 마커 앞에 바이트를 붙인 입력으로 탐지 결과를 확인한다.
## 본문
<!-- body:start -->
javadoc이 목적을 "Detection is on content, not on the claimed type or the extension, because both are attacker controlled"로 적는데, 구현은 1,024바이트 접두사를 소문자화·`stripLeading()`한 뒤 여섯 마커로 **시작하는지**만 본다.
## javadoc 의 목적과 구현의 판정
:::evidence key="scriptable-detection-bypassed-by-a-bom" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## hermetic probe 가 통과시킨 셋
UTF-8 BOM + `<html>` · 선행 HTML 주석 후 `<script>` · NUL 바이트 후 `<html>`. `String.stripLeading()``Character.isWhitespace`만 제거하므로 BOM(U+FEFF)도 NUL도 지우지 않는다. 셋 다 브라우저는 HTML로 렌더링하고, BOM 접두 HTML은 여러 편집기의 기본 출력이다.
## 형제 검증기와의 대비가 판정을 굳힌다
`MediaTypeVerifier`의 매직바이트 선두 매칭은 시그니처의 정의가 파일 선두이므로 옳지만, scriptable 마커는 시그니처가 아니라 브라우저가 스니핑하는 패턴이다.
## 확인하지 못한 것
실제 브라우저가 각 우회 입력을 실행하는지 확인하지 않았다. 브라우저의 스니핑 동작은 명세와 구현이 모두 관여하므로 별도 확인이 필요하다.
안전 프로파일이 켜진 배포에서의 동작을 확인하지 않았다.
<!-- body:end -->