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>
192 lines
18 KiB
Markdown
192 lines
18 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a14-f005-publicpaths-restrictedpathrule
|
|
title: 공개 경로가 관리 경로를 덮으면 제한 규칙이 건너뛰어지고, 그 겹침을 보는 것이 없다
|
|
topic: web-inbound-and-http-surface
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
evidenceCapturedOn: 2026-09-02
|
|
rootTreeNode: case:a14-f005-publicpaths-restrictedpathrule
|
|
body: case-a14-f005-publicpaths-restrictedpathrule.body.md
|
|
assets:
|
|
- key: a14-f005-publicpaths-restrictedpathrule
|
|
file: ../../../final/evidence/rendered/a14-f005-publicpaths-restrictedpathrule.svg
|
|
- key: a14-f005-publicpaths-restrictedpathrule-chain
|
|
file: ../../../final/evidence/rendered/a14-f005-publicpaths-restrictedpathrule-chain.svg
|
|
- key: a14-f005-publicpaths-restrictedpathrule-mgmtport
|
|
file: ../../../final/evidence/rendered/a14-f005-publicpaths-restrictedpathrule-mgmtport.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a14-f005-publicpaths-restrictedpathrule.txt
|
|
- ../../../final/evidence/raw/a14-f005-publicpaths-restrictedpathrule-chain.txt
|
|
- ../../../final/evidence/raw/a14-f005-publicpaths-restrictedpathrule-mgmtport.txt
|
|
source:
|
|
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L582 이다.
|
|
---
|
|
|
|
# 공개 경로가 관리 경로를 덮으면 제한 규칙이 건너뛰어지고, 그 겹침을 보는 것이 없다
|
|
|
|
보안 사슬이 설정에서 온 공개 경로를 먼저 등록하고 관리 평면 규칙을 그다음에 등록한다. 앞의 등록이 뒤의 패턴을 삼키면 뒤는 아무 일도 하지 못한다. 실제 패턴과 권한으로 사슬을 세워 열한 번 왕복해 보니, 권한이 없는 인증 호출자가 403 을 받아야 할 자리에서 사슬 끝까지 갔다.
|
|
|
|
## 관계
|
|
|
|
- **publicPaths가 먼저 등록되어 제한 경로 규칙을 덮는다**
|
|
이 사례에서 확인한 등록 순서를 개념으로 정리한 문서다.
|
|
- **단일 admission point는 우회 경로를 세어야 성립한다**
|
|
우회 경로를 세지 않으면 단일 지점이 성립하지 않는다는 점이 같다.
|
|
- **production 판정이 두 개의 리터럴 프로파일 이름에 걸려 있다**
|
|
두 사례 모두 설정값 때문에 보안 규칙보다 먼저 요청이 통과할 수 있다.
|
|
- **runtime_memberships를 먼저 읽고 심각도를 정한다**
|
|
이 리프에 그 완화가 적용되지 않는다는 것을 확인할 때 쓴 규칙이다.
|
|
|
|
## 문제
|
|
|
|
보안 사슬에 두 종류의 등록이 있다. 설정으로 받은 공개 경로를 모두 허용하는 등록과, 관리 평면을 지키는 제한 경로 규칙이다. 등록 순서가 어떤 결과를 만드는지 확인했다.
|
|
|
|
## 결론
|
|
|
|
공개 경로가 먼저 등록된다.
|
|
|
|
제한 경로 규칙의 자바독은 이 규칙이 존재하는 이유를 적는다. 나중에 참조되는 응용 수준 정책은 이미 요청을 통과시킨 전송을 되돌릴 수 없다는 것이다. 그런데 모두 허용 등록이 그보다 먼저 와서 정확히 그 일을 한다.
|
|
|
|
순서만 읽고 결론을 내지 않았다. 프로덕션 패턴과 권한으로 사슬을 짓고, 호출자 셋과 공개 경로 값 다섯을 바꿔 가며 열한 번 왕복했다.
|
|
|
|
인증 없는 요청만 보아서는 판정이 갈리지 않는다. 캐치올도 같은 401 을 내기 때문이다. 그래서 권한만 없는 호출자를 함께 넣었다.
|
|
|
|
권한만 없는 호출자를 넣으면 결과가 갈린다. 덮이지 않은 요청은 403 을 받는다. 규칙이 요구하는 권한이 이 호출자에게 없어서다. 덮이는 순간 같은 호출자가 사슬 끝까지 간다. 규칙은 등록되어 있는데 아무 일도 하지 않는다.
|
|
|
|
두 대조군이 그 403 의 출처를 못박는다. 규칙 빈을 빼면 같은 요청이 200 이 되고, 권한을 가진 호출자를 넣어도 200 이 된다. 403 을 만든 것은 규칙이고 캐치올이나 진입점이 아니다.
|
|
|
|
원문이 예로 든 값은 이 일을 일으키지 않는다. 관리 경로가 /internal/ 아래에 있어서 /api/** 를 열어도 닿지 않고, 같은 호출자가 그 값에서는 403 을 받는다. 겹치게 하려면 운영자가 /internal/** 이나 /** 같은 값을 직접 넣어야 한다. 원문도 그 조건을 달아 두었다.
|
|
|
|
여기에 배포 형태라는 조건이 하나 더 있다. 관리 평면 라우트는 관리 컨텍스트에 실려 나가고, 출하 설정은 그 서버에 별도 포트를 준다. 포트가 갈려 있으면 그 경로를 받을 핸들러가 애플리케이션 쪽에 없다.
|
|
|
|
이 대목은 소스 주석이 먼저 말해 둔 것이라 받아 적지 않고 프레임워크에 질의했다. 출하되는 한 쌍만 갈린 쪽으로 나오고, 관리 포트를 애플리케이션과 맞추거나 비우거나 지운 세 경우는 모두 한 컨텍스트로 묶인다. 같다는 판정에서 켜지는 설정이 고르는 목록에 이 저장소의 관리 컨텍스트 설정이 있고, 그 설정의 등록 타입은 두 배치 모두를 받는다. 그 배포에서는 관리 라우트가 애플리케이션 쪽으로 넘어온다. 프로브는 그때 요청을 판정하는 사슬의 생성 메서드를 그대로 불렀다.
|
|
|
|
부팅에는 그 겹침을 검사하는 자리가 없다. 규칙 타입을 언급하는 파일이 셋이고 전부 main 인데, 두 집합을 함께 쥐는 자리는 등록하는 곳 하나뿐이고 거기서 하는 일은 비교가 아니다. 공개 경로 쪽은 표기 세 형태로 다시 훑었다. 그 키를 쥔 파일이 열아홉이고 그중 둘은 추적되지 않는데, 어느 쪽에도 제한 패턴이 함께 나오지 않는다.
|
|
|
|
가까운 통제가 하나 있기는 하다. 공개 경로에는 스냅숏 게이트가 걸려 있다. 다만 그것이 재는 것은 운영자 기계에 있는 파일 하나와 기준선의 차이다. 그 파일은 .gitignore 가 막아 저장소에 없고, 없으면 게이트는 비교에 들어가기 전에 던진다. 그 태스크를 부르는 자리가 워크플로 셋에 네 곳인데, 어느 곳도 그 파일을 만들지 않는다. 배포 시점 환경 변수는 애초에 이 경로 어디에도 나타나지 않는다.
|
|
|
|
규칙 생성자는 자기가 물러지는 것만 막는다. 빈 권한 목록을 거부하고 그 이유를 예외 문구에 적어 둔다. 그 이유가 규칙 전체가 건너뛰어지는 경우에도 그대로 적용된다는 것이 이 사례다.
|
|
|
|
권고는 둘 중 하나다. 제한 규칙을 공개 경로보다 먼저 등록하거나, 두 패턴 집합이 겹치면 부팅에서 거부하는 것이다.
|
|
|
|
판정은 P3 다. 이 결함이 오늘 도는 배포에 닿으려면 네 가지가 동시에 참이어야 한다. 파일서버 플랫폼 스위치가 켜져야 하고, 그 아래 관리 평면 스위치도 켜져야 하고, 운영자가 관리 경로를 덮는 공개 경로 값을 넣어야 하고, 관리 서버 포트를 애플리케이션 포트와 같게 두거나 비워야 한다. 넷 다 출하값이 반대쪽이다. 두 스위치는 거짓으로 나가고, 출하되는 공개 경로 목록에는 헬스체크만 있고, 두 포트는 9001 과 8080 으로 갈라져 있다.
|
|
|
|
심각도 규칙은 리프의 런타임 멤버십을 먼저 읽으라고 적는다. 이 리프의 멤버십은 비어 있지 않으므로 리프 단위 완화를 쓸 수 없고, 그 규칙이 등급을 낮춰 주지도 않는다. P3 인 이유는 위 네 조건이지 그 규칙이 아니다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 프로덕션 패턴과 권한으로 세운 필터 사슬에 열한 번 왕복, 관리 포트 판정과 컨텍스트 선택을 프레임워크에 직접 질의, 등록 순서와 출하 스위치 확인
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 #L582 절이다.
|
|
|
|
1. 사슬 등록 블록을 읽어 세 등록의 순서를 확인한다.
|
|
2. 제한 경로 규칙의 자바독과 생성자 검증을 읽는다.
|
|
3. 규칙 빈을 내놓는 설정과 그것을 켜는 두 스위치를 찾는다.
|
|
4. 관리 라우트를 등록하는 관리 컨텍스트 설정과 출하 포트를 읽고, 두 포트가 같을 때 그 설정이 어디로 가는지 프레임워크에 묻는다.
|
|
5. 공개 경로 기본값과 규칙 타입을 언급하는 파일을 전수로 찾는다.
|
|
6. 프로덕션 코드가 쓰는 생성 메서드로 필터 사슬을 세운다.
|
|
7. 무인증 호출자와 권한 없는 인증 호출자로 각각 공개 경로를 바꿔 가며 상태를 읽는다.
|
|
8. 권한을 가진 호출자와 규칙 빈을 뺀 사슬로 403 의 출처를 가른다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
등록 세 개가 한 블록 안에 순서대로 있다.
|
|
|
|
```java
|
|
// SecurityConfig.java:83-94
|
|
.authorizeHttpRequests(
|
|
auth -> {
|
|
if (publicPaths.length > 0) {
|
|
auth.requestMatchers(publicPaths).permitAll();
|
|
}
|
|
// Ordered before the authenticated catch-all: a management path must be refused at
|
|
// the transport, not by an application policy the request has already passed.
|
|
for (RestrictedPathRule rule : restricted) {
|
|
auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
|
|
}
|
|
auth.anyRequest().authenticated();
|
|
})
|
|
```
|
|
|
|
주석은 제한 규칙을 인증 캐치올보다 앞에 둔 것을 설명한다. 그보다 앞선 등록은 다루지 않는다.
|
|
|
|
## 규칙은 어디서 오고 무엇이 켜야 하는가
|
|
|
|
:::evidence key="a14-f005-publicpaths-restrictedpathrule" alt="코드베이스 정적 검색 출력 138줄. 사슬 등록 블록, 규칙 생성자의 빈 권한 거부, 규칙을 내놓는 자리, 프로덕션 패턴과 관리 평면 스위치, 그 설정 클래스를 부르는 자리 전부와 그것을 들이는 자동설정의 조건, 컴포넌트 스캔의 제외 정규식 전문, 관리 컨텍스트 등록과 두 포트, 설정 기본값, 공개 경로를 접근자로 읽는 자리, 그 키를 쥔 파일 열아홉과 그중 추적되지 않는 둘과 제한 패턴을 함께 언급하는 파일 수, 규칙 타입을 언급하는 파일 셋, 스냅숏 게이트의 두 입력과 값 쪽 입력의 무시 규칙과 없을 때의 중단과 그것을 부르는 네 자리와 워크플로의 .env 언급 전부, 마지막으로 게이트가 제한 패턴을 언급하는 횟수가 차례로 보인다." caption="등록 순서와 프로덕션이 등록하는 값 — 138줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
규칙을 내놓는 자리는 `FileserverAdminPlaneConfiguration:36` 하나이고, 패턴은 `/internal/fileserver/**`, 권한 기본값은 `ROLE_FILE_ADMIN` 이다.
|
|
|
|
그 설정 클래스를 이름으로 부르는 자리는 셋인데, 그중 등록에 해당하는 것은 `FileserverPlatformAutoConfiguration:71` 의 `@Import` 하나다. 나머지 둘은 클래스 선언과 자바독의 링크다. 그 자동설정에도 자기 스위치가 있고, 컴포넌트 스캔은 자동설정 패키지를 정규식으로 제외한다. 스위치를 우회해 이 설정에 닿는 길이 없다는 뜻이다. 두 스위치는 `false` 로 출하된다.
|
|
|
|
공개 경로 기본값은 헬스체크 하나다. `publicPaths()` 를 읽는 프로덕션 코드는 `SecurityConfig:75` 하나뿐이다.
|
|
|
|
## 열한 번 왕복해서 받은 상태
|
|
|
|
:::evidence key="a14-f005-publicpaths-restrictedpathrule-chain" alt="JVM 프로브 출력 22줄. 관리 라우트 하나에 요청을 보내며 호출자 세 종류와 공개 경로 값 다섯을 바꿔 가며 받은 상태 코드를 네 묶음으로 적었다. 마지막 묶음은 규칙 빈을 뺀 사슬이다." caption="호출자와 공개 경로 조합별 상태 — 22줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
`SecurityConfig` 의 사슬 생성 메서드를 그대로 불러 필터 사슬을 만들고 `/internal/fileserver/storage-health` 에 요청을 보냈다. 컨트롤러가 실제로 매핑하는 여덟 라우트 중 하나다. 사슬 끝에 200 을 적는 종단을 달았으므로 200 은 인가를 통과했다는 뜻이다.
|
|
|
|
무인증 호출자만으로는 규칙이 일했는지 알 수 없다. yml 기본값에서도 401 이고 `/api/**` 를 열어도 401 인데, 그 401 은 `anyRequest().authenticated()` 도 낼 수 있는 값이다.
|
|
|
|
권한 없는 인증 호출자를 넣으면 갈린다. 겹치지 않는 세 값에서는 403 인데 그중 하나가 `/api/healthcheck` 다. `local` 프로파일이 그 값을 리터럴로 박아 두고, 샘플 애플리케이션도 같은 값을 기본으로 쓴다. `/internal/**` 이나 `/**` 로 덮으면 같은 호출자가 200 을 받는다.
|
|
|
|
그 403 이 규칙에서 왔다는 것은 두 줄이 더 말한다. 권한을 `ROLE_FILE_ADMIN` 으로 바꾸면 같은 요청이 200 이고, 규칙 빈이 없는 사슬에 보내도 200 이다. 규칙이 있고 권한이 없을 때만 403 이다.
|
|
|
|
## 어느 배포에서 성립하는가
|
|
|
|
관리 라우트를 등록하는 것은 `@ManagementContextConfiguration` 이고, 그 컨트롤러 패키지는 컴포넌트 스캔의 제외 정규식에 이름이 올라 있다. 출하 설정은 관리 서버에 9001, 애플리케이션에 8080 을 준다.
|
|
|
|
포트가 갈린 배포에서는 관리 경로가 애플리케이션 커넥터에 오르지 않는다. 넓은 공개 경로가 덮는 것은 핸들러가 없는 경로다.
|
|
|
|
:::evidence key="a14-f005-publicpaths-restrictedpathrule-mgmtport" alt="JVM 프로브 출력 17줄. 포트 조합 넷에 대한 판정, 같다고 판정될 때 켜지는 설정과 그것이 고르는 타입, 그 설정이 고르는 클래스 넷이 차례로 보이고 그중 하나가 이 저장소의 관리 컨텍스트 설정이다." caption="관리 포트 판정과 컨텍스트 선택 — 17줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
이 대목은 관리 컨텍스트 설정의 자바독이 먼저 적어 둔 것이라, 옮겨 적는 대신 프레임워크에 물었다. `ManagementPortType` 은 출하값 한 쌍을 `DIFFERENT` 로, 두 포트가 같은 값과 관리 포트가 빈 값과 아예 없는 값을 `SAME` 으로 답한다. `SAME` 일 때 켜지는 설정 안의 중첩 설정이 `@EnableManagementContext(SAME)` 를 달고 있고, 그 선택자가 고르는 넷 중 하나가 `FileserverAdminManagementContextConfiguration` 이다. 그 설정의 등록 타입은 `ANY` 라 두 배치 어디에도 들어간다.
|
|
|
|
두 포트가 같으면 관리 라우트는 애플리케이션 컨텍스트로 들어온다. 그 요청을 받는 것은 `SecurityConfig` 가 만드는 필터 사슬이고, 이 프로브가 부른 것이 그것을 만드는 메서드다.
|
|
|
|
## 두 집합을 마주 놓는 코드
|
|
|
|
규칙 타입을 언급하는 파일은 셋이고 전부 main 이다. 두 집합을 함께 쥐는 자리는 `SecurityConfig` 하나인데 거기서 하는 일은 비교가 아니라 순서대로 등록하는 것이다.
|
|
|
|
공개 경로 쪽에서도 훑었다. 점 표기와 환경 변수와 중첩 YAML 세 형태로 빌드 산출물을 뺀 작업 트리를 뒤지면 그 키를 쥔 파일이 열아홉인데, 그중 둘은 저장소가 추적하지 않는다. `.env` 와 SDD 작업 폴더에 남은 리뷰 diff 다. 추적되는 열일곱은 스냅숏 기준선과 게이트 스크립트, 문서 셋, yml 다섯, 자바 일곱이고 자바는 전부 시험이다. 열아홉 중 제한 패턴을 함께 언급하는 파일은 없다.
|
|
|
|
스냅숏 게이트는 있다. `src/gradle/public-path-snapshot.gradle` 이 승인 없는 변경을 빌드에서 막는다고 되어 있다. 그런데 그것이 읽는 두 파일의 성격이 다르다. 기준선 `docs/security/public-paths-snapshot.txt` 는 저장소에 있지만, 값을 가져오는 `.env` 는 `.gitignore:7` 이 막는 운영자 입력이다.
|
|
|
|
그 파일이 없으면 게이트는 비교하지 않고 `missing public-path environment file` 로 던진다. 이 태스크를 부르는 자리는 워크플로 셋에 네 곳이다. `ci-quality-gates.yml` 이 서로 다른 두 잡에서 한 번씩, 나머지 둘이 한 번씩이다. 워크플로 전체를 훑어도 `.env` 가 나오는 곳은 트리거 경로 목록 둘뿐이고, 그 파일을 만드는 단계는 어느 워크플로에도 없다. 그러니 실제로 맞춰 보는 것은 운영자 기계의 값이다. 배포 시점 환경 변수는 그 경로에도 들어오지 않는다. 게이트가 제한 패턴을 언급하는 횟수도 0 이다.
|
|
|
|
## 생성자가 막는 것과 막지 못하는 것
|
|
|
|
```java
|
|
// RestrictedPathRule.java:30-34
|
|
if (requiredAuthorities.isEmpty()) {
|
|
throw new IllegalArgumentException(
|
|
"requiredAuthorities must not be empty: a rule that requires nothing is weaker than the "
|
|
+ "authenticated default it replaces");
|
|
}
|
|
```
|
|
|
|
빈 권한 목록은 거부하고 그 이유를 문구에 적는다. 아무것도 요구하지 않는 규칙은 그것이 대체하는 인증된 기본값보다 약하다는 것이다.
|
|
|
|
같은 논리를 규칙 전체에 적용하면 이 사례가 된다. 규칙 전체가 건너뛰어지면 그것도 기본값보다 약하다. 그 경우는 아무 데서도 걸리지 않는다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
세션 모드에서는 돌려 보지 않았다. 프로브가 세운 것은 JWT 모드의 사슬이고, 등록 블록은 두 모드가 갈리기 전에 있으므로 순서는 같다.
|
|
|
|
프로브는 애플리케이션 사슬 하나만 감쌌다. 프로덕션에는 액추에이터 사슬이 더 있고, 스위치가 켜지면 콜백 사슬도 붙는다. 둘 다 경로 매처가 관리 경로와 겹치지 않아 판정이 달라지지 않는다고 읽었을 뿐 돌려 보지는 않았다. 관리 컨텍스트 쪽은 포트 판정과 설정 선택까지만 물었고, 애플리케이션을 두 포트가 같은 설정으로 실제로 띄운 것은 아니다.
|
|
|
|
200 은 프로브가 사슬 끝에 단 종단이 적는 값이지 컨트롤러의 응답이 아니다. 인증된 호출자도 토큰을 제시해 만든 것이 아니라 사슬이 읽는 자리에 직접 넣은 것이라, 이 왕복은 디코더와 변환기를 지나지 않는다.
|
|
|
|
<!-- body:end -->
|