Project

General

Profile

CR #243 » CR-042_JwtAuthFilter之401路徑拋NPE之診斷與修補.md

內容更新:內部稱謂去識別化(2026-10-18) - Redmine Admin, 10/18/2026 05:32 AM

 

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. 誠實揭露

  1. ★★ 本缺陷自 JwtAuthFilter 建立以來即存在,且其核心職責從未被執行——
    ★ 根因是「沒有拒絕路徑的測試」,非「程式碼寫錯一行」。
  2. ★ 本類別目前未接線,故不宣稱「現行系統曾回錯」;★ 僅宣稱
    「依文件接線後必然失敗」。
  3. ★ 併記:本次同一批測試亦揭露我方之另一錯誤——
    AesGcmFieldEncryptorEdgeTest 之「竄改密文」初版改動 Base64 末字元,
    其低 2 位被丟棄 → 有時不改變任何 byte → 認證反而通過(★ 與 #A-163
    所載之 6.25% 間歇性失敗同型)。★ 已改為改動最後一段之首字元(6 位元全有效)。

8. 建議

  1. ★★ 接線決策待 BEAM PMO 裁定:JwtAuthFilter 是否應由各服務註冊?
    → ★ BEAM PMO 2026-10-15 裁定:「接線」。執行方式見 §9(採橋接模式,
    非以獨立驗證模式接線——理由見 §9.1)。
  2. ★ 建議將「以 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 已知限制(誠實揭露)

  1. ★ CurrentUser 目前尚無業務程式碼取用——本次接線使其可用(不再死碼),
    惟「已接線」≠「已被使用」;後續若需以 CurrentUser.claim() 作端點層授權,
    應另立規格(SDD-007 §5)。
  2. ★ 橋接模式之角色(realm_access.roles)不加 ROLE_ 前綴,與 Spring Security
    之 GrantedAuthority(加前綴)語意不同;兩者為並存之兩套視圖,非同一份資料。
  3. ★ 本模式僅適用同步、同執行緒之請求(SecurityContext 不跨執行緒),
    與 SCS-022 §7.11.5 之限制同型。
    (1-1/1)