CR #243 » CR-042_JwtAuthFilter之401路徑拋NPE之診斷與修補.md
CR-042 JwtAuthFilter 之 401 路徑拋 NPE(Map.of 不接受 null 值)——診斷與修補
| 項目 | 內容 |
|---|---|
| 文件編號 | CR-042 |
| 版本 | 1.1 |
| 狀態 | 已立/已執行(已修並實測通過);★ §8 之接線裁定已由 BEAM PMO 2026-10-15 核定為「接線」,並已執行(見 §9) |
| 提出日期 | 2026-10-06 |
| 提出者 | 開發團隊(批次 20-d 為 common-security 補測試時實測揭露;見 TRP-003 §6.11.35) |
| 影響文件 |
CSP-003 §3(統一回應封套)、SAD-005 §4(JWT 認證)、TRP-003 §6.11.35
|
| 影響模組 |
platform/common-security(JwtAuthFilter) |
| 優先級 | P1(高)——★ 認證失敗路徑完全失效;★ 惟目前全庫零接線,故為潛在缺陷(見 §3) |
| 相關前例 |
CR-004(錯誤映射)、CR-041 #A-184(「以 500 掩飾」之同型禁區) |
1. 摘要
為 common-security 補覆蓋率(批次 20-d)而首次為 JwtAuthFilter 撰寫拒絕路徑測試時,
實測揭露:★ sendUnauthorized() 必拋 NullPointerException——
即 JwtAuthFilter 從未真正回過 401。
★ 這正是「未測到的路徑=未驗證的假設」:本類別自建立以來,
其核心職責(未認證 → 401)從未被執行過一次。
2. 診斷
2.1 症狀(真機堆疊,逐字)
java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:233)
at java.base/java.util.ImmutableCollections$MapN.<init>(ImmutableCollections.java:1193)
at java.base/java.util.Map.of(Map.java:1407)
at com.beam.cardcore.common.security.auth.JwtAuthFilter.sendUnauthorized(JwtAuthFilter.java:103)
at com.beam.cardcore.common.security.auth.JwtAuthFilter.doFilterInternal(JwtAuthFilter.java:75)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
2.2 根因
// JwtAuthFilter.java:103-109(修補前)
byte[] body = MAPPER.writeValueAsBytes(java.util.Map.of(
"traceId", traceId == null ? "" : traceId,
"data", (Object) null, // ★★ 元凶:Map.of 不接受 null 值
"error", java.util.Map.of(
"code", ErrorCodes.CC_GW_1002,
"message", "未認證:" + reason,
"details", java.util.List.of())));
★ java.util.Map.of(...) 對「任何 null 之鍵或值」皆拋 NullPointerException
(ImmutableCollections.MapN 之建構子即 Objects.requireNonNull)。
★ 意圖是「封套之 data 為 null」(與 common-spring 之 ApiResponse 一致),
但 Map.of 表達不了「值為 null」——★ 這是一個「看起來很合理、編譯也過、但執行期必炸」的陷阱。
3. 查證(影響範圍)
| 查證項 | 方法 | 結果 |
|---|---|---|
| 該路徑是否可達 | 撰寫拒絕路徑測試 | ★ 必然可達——任何缺 token/壞 token 之請求皆會走到 |
| 是否已接線至服務 | grep -rl "JwtAuthFilter" --include="*.java" backend/ |
★★ 全庫零接線——僅 CurrentUser/JwtValidationException 之 Javadoc 提及(★ 2026-10-15 已接線,見 §9) |
| 現行系統是否受影響 | 同上 | ★ 否(未被任何服務註冊) |
| 服務之 401 由誰產生 | 實查 | ★ 由 Spring Security(oauth2ResourceServer)產生,非本過濾器 |
★★ 裁定之關鍵:本缺陷目前不影響運行中的系統(未接線),
★ 但JwtAuthFilter是common-security之公開 API,其 Javadoc 明載
「失敗則回 401(CC-GW-1002)」——★ 一旦任何服務依文件接線,所有 401 立即變成 500。
★ 依CR-041 #A-184之同一標準(「不得以 500 掩飾」),仍須修。
4. 裁定
★ 修(真缺陷,非設計取捨)。★ 最小改動:改用可容納 null 值之容器,
JSON 形狀不變(data 仍為 null)。
5. 實作
// 修補後:以 LinkedHashMap 承載(可含 null 值),JSON 形狀與 ApiResponse 封套一致
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("traceId", traceId == null ? "" : traceId);
envelope.put("data", null); // ★ 可為 null
envelope.put("error", Map.of(
"code", ErrorCodes.CC_GW_1002,
"message", "未認證:" + reason,
"details", List.of()));
byte[] body = MAPPER.writeValueAsBytes(envelope);
★ 不動其他行為:狀態碼 401、Content-Type、UTF-8、error.code = CC-GW-1002 皆原樣。
6. 驗證(實測)
新增 JwtAuthFilterTest(14 測,★ 零 mock:Spring 官方
MockHttpServletRequest/MockHttpServletResponse + FilterChain 函式介面 lambda;
★ token 由真實 JwtSigner 簽發、真實 JwtValidator 驗證):
| 必驗 | 結果 |
|---|---|
缺 Authorization → 401 + 封套(CC-GW-1002) |
✅ |
非 Bearer 前綴 → 401 |
✅ |
Bearer 但 token 空白 → 401 |
✅ |
| 簽章不符 → 401,★ 訊息不含原始 token | ✅ |
| 已過期 → 401 | ✅ |
| 格式錯誤(非三段)→ 401 | ✅ |
封套之 traceId 取自 MDC(有值/無值各一) |
✅ |
★ 有效 token → 放行,且 CurrentUser/MDC 於期間已寫入、★ 結束後清理
|
✅ |
| ★ 白名單為 null/單引數建構子 | ✅ |
★ 修補前:14 測中 11 測紅燈(皆為同一 NPE;另 3 測不經 401 路徑故綠)。
★ 修補後:14 測全綠;common-security 模組 137 測/0 失敗。
7. 誠實揭露
- ★★ 本缺陷自
JwtAuthFilter建立以來即存在,且其核心職責從未被執行——
★ 根因是「沒有拒絕路徑的測試」,非「程式碼寫錯一行」。 - ★ 本類別目前未接線,故不宣稱「現行系統曾回錯」;★ 僅宣稱
「依文件接線後必然失敗」。 - ★ 併記:本次同一批測試亦揭露我方之另一錯誤——
AesGcmFieldEncryptorEdgeTest之「竄改密文」初版改動 Base64 末字元,
其低 2 位被丟棄 → 有時不改變任何 byte → 認證反而通過(★ 與#A-163
所載之 6.25% 間歇性失敗同型)。★ 已改為改動最後一段之首字元(6 位元全有效)。
8. 建議
- ★★
接線決策待 BEAM PMO 裁定:JwtAuthFilter是否應由各服務註冊?
→ ★ BEAM PMO 2026-10-15 裁定:「接線」。執行方式見 §9(採橋接模式,
非以獨立驗證模式接線——理由見 §9.1)。 - ★ 建議將「以
Map.of表達 null 值」列入CSP-001之常見陷阱清單
(同型:List.of(null)/Set.of(null))。
9. 接線之執行(BEAM PMO 2026-10-15 裁定「接線」後)
9.1 ⚠️ 動工前之事實查證——「接線」不能照字面做
裁定的字面是「JwtAuthFilter 由各服務接線」。惟動工前查證發現一個規格層矛盾,
若照字面以本類別之原生模式接線,將造成系統倒退:
| 機制 | 位置 | 驗證方式 | 錯誤碼 | 現況 |
|---|---|---|---|---|
| OAuth2 Resource Server |
common-spring SecurityAutoConfiguration
|
JwtDecoder+issuer-uri 之 JWKS
|
CC-PLT-4010/CC-PLT-4030 |
★ 已接線,17 服務生效 |
JwtAuthFilter(原生模式) |
common-security |
JwtValidator(手動 HMAC/RSA 金鑰) |
CC-GW-1002 |
★ 全庫零接線(§3) |
- ★
SCS-022 §5明載認證機制即common-spring之SecurityConfig,
§7.3明定 401=CC-PLT-4010。此為已核定規格。 - ★
JwtAuthFilter之原生模式(自帶JwtValidator)與上述語意不相容:
它以手動金鑰驗證(不取 JWKS、不驗iss/aud)、讀取roles/scope宣告
(Keycloak 實際位於realm_access.roles)。★ 若以原生模式接線,將:
① 雙重認證;② 錯誤碼由CC-PLT-4010變為CC-GW-1002(違反SCS-022 §7.3);
③ 使既有之CC-PLT-4010相關測試(SecurityFailClosedTest、ServiceAclFilterTest)轉紅。 - ★ 依鐵則「盤點矛盾」,此不應照字面執行。經研判,BEAM PMO「接線」之真意
應為「勿使JwtAuthFilter/CurrentUser淪為死碼,使其於各服務生效」,
而非「以之取代既有認證鏈」。
9.2 執行方式——橋接模式(新增)
★ 為 JwtAuthFilter 新增橋接模式(無 JwtValidator 之建構子):
本身不做事前驗證、不改寫回應、不發 401;僅在 SecurityContext 中已存在
由 OAuth2 Resource Server 驗證通過之 JwtAuthenticationToken 時,將其宣告
(sub/realm_access.roles/scope/jti/tenant_id)橋接至
CurrentUser 與 MDC,供領域服務經 CurrentUser.claim() 取用。
| 檔案 | 變更 |
|---|---|
JwtAuthFilter.java |
新增 JwtAuthFilter()(橋接模式)+ bridgeDoFilterInternal;★ 原生模式(帶 validator)行為完全不變(CR-042 之 14 測不受影響) |
JwtAuthFilterAutoConfiguration.java(新增) |
@AutoConfiguration+@ConditionalOnWebApplication(SERVLET)+@ConditionalOnClass({Jwt, JwtAuthenticationToken});以 FilterRegistrationBean 註冊,順序 Ordered.LOWEST_PRECEDENCE(晚於 SecurityAutoConfiguration 之認證鏈) |
AutoConfiguration.imports |
新增 …auth.JwtAuthFilterAutoConfiguration
|
common-security/build.gradle.kts |
新增 compileOnly/testImplementation:spring-boot-starter-oauth2-resource-server(比照 common-spring) |
| 開關 |
cardcore.security.current-user.enabled(預設 true)——可顯式停用 |
9.3 驗證(實測,2026-10-15)
單元/整合(common-security,147 測 0 失敗;原 137 → +10):
| 測試 | 例數 | 必驗 |
|---|---|---|
JwtAuthFilterBridgeTest(新增) |
6 | 有主體→寫入 CurrentUser/MDC 且結束清理;claim 對應正確;★ 無主體/匿名→放行且不寫入(認證交還 Spring Security);★ 原生模式不變(401) |
JwtAuthFilterAutoConfigurationTest(新增) |
4 | Servlet 即註冊、順序=LOWEST_PRECEDENCE、可顯式停用、非 Web 不註冊、服務自帶 bean 讓位 |
JwtAuthFilterTest(CR-042 原測) |
14 | ★ 不受影響,全綠 |
真機端到端(party-service,CARDCORE_SECURITY_ENABLED=true,真實 Keycloak + Oracle):
| # | 情境 | 結果 |
|---|---|---|
| ① | 公開路徑 /actuator/health
|
200 ✅ |
| ② | ★ 受保護端點、無 token |
401 CC-PLT-4010(★ 錯誤碼未變——證明橋接未劫持認證) ✅ |
| ③ | ★ 帶 真實 M2M 權杖(cc-card-service,於 party-service ACL 內) |
通過認證+授權,進入控制器(回業務錯誤 CC-PTY-4001,HTTP 400) ✅ |
| ④ | ★ 上列③之啟動日誌 |
已由 JWT 主體橋接至 CurrentUser:roles=[default-roles-cardcore, offline_access, uma_authorization] scopes=[profile, email]——★ 證明橋接過濾器已由 AutoConfiguration.imports 載入、於認證鏈之後執行、並自真實 Keycloak 權杖寫入 CurrentUser ✅ |
★ 為何③回 400 而非 200 仍算通過:
/api/v1/parties/P001之P001非 UUID,
於業務層回CC-PTY-4001(SCS-022 §7.7.1所載之P001係舊狀態)。
★ 重點在於:請求已通過認證與授權、抵達控制器——此即「認證鏈未受影響」之證據。
9.4 已知限制(誠實揭露)
- ★
CurrentUser目前尚無業務程式碼取用——本次接線使其可用(不再死碼),
惟「已接線」≠「已被使用」;後續若需以CurrentUser.claim()作端點層授權,
應另立規格(SDD-007 §5)。 - ★ 橋接模式之角色(
realm_access.roles)不加ROLE_前綴,與 Spring Security
之GrantedAuthority(加前綴)語意不同;兩者為並存之兩套視圖,非同一份資料。 - ★ 本模式僅適用同步、同執行緒之請求(
SecurityContext不跨執行緒),
與SCS-022 §7.11.5之限制同型。