:過濾器+包裝器+CSP+輸出編碼)
我處理項目里那個搜索框變成按鈕的Bug才真正理解XSS不是文檔里的一句名詞而是能在幾分鐘內(nèi)把Cookie、頁面內(nèi)容、用戶操作全部劫走的入口。當(dāng)時用Spring Boot做的后臺管理系統(tǒng)前端同學(xué)隨手往標(biāo)題欄輸入了一段帶尖括號的內(nèi)容頁面上直接把標(biāo)簽渲染成了DOM那一刻所有人都意識到我們的接口對代碼注入基本是裸奔。這篇文章就記錄我當(dāng)時從零搭建的過濾器請求包裝器輸出編碼CSP響應(yīng)頭四層XSS防御方案全流程可復(fù)現(xiàn)相關(guān)代碼都直接貼在章節(jié)里適合正在用Spring Boot開發(fā)接口、后臺系統(tǒng)或者前后端分離項目的開發(fā)者參考。我記得很清楚最開始想得很簡單在Controller里對每個參數(shù)做replace把所有尖括號去掉不就行了結(jié)果寫完發(fā)現(xiàn)自己掉進了一個巨大的坑接口一多參數(shù)一雜漏掉一個字段就前功盡棄而且replace方案對大小寫混寫、HTML實體編碼、屬性內(nèi)聯(lián)事件根本防不住。后面逐步演化成一套組合方案核心思路就一句話不能把安全邏輯散落在業(yè)務(wù)代碼里要用統(tǒng)一的入口做清洗再在出口做編碼最后用瀏覽器安全策略兜底。1. 理解XSS攻擊的實際場景防御方案的設(shè)計起點1.1 三類XSS攻擊與真實業(yè)務(wù)映射在做方案之前我先花了一晚上把項目里可能出現(xiàn)XSS的位置全過了一遍這時候才發(fā)現(xiàn)哪里會被打比怎么防御更重要。存儲型XSS最容易出現(xiàn)在后臺系統(tǒng)的評論、公告、商品描述這類入口攻擊者把腳本寫進數(shù)據(jù)庫頁面渲染時自動執(zhí)行反射型XSS則集中在搜索框、URL參數(shù)、跳轉(zhuǎn)參數(shù)這類需要回顯的地方攻擊者構(gòu)造一個惡意鏈接引誘用戶點擊參數(shù)里的腳本跟著請求進入后端再原樣返回頁面DOM型XSS稍微隱蔽一點攻擊邏輯完全在前端JavaScript里運轉(zhuǎn)比如從location.hash里取參數(shù)拼進DOM后端甚至看不到完整攻擊載荷。從實際問題來看如果業(yè)務(wù)還在用服務(wù)端渲染模板Thymeleaf、JSP反射型和存儲型是重災(zāi)區(qū)如果業(yè)務(wù)已經(jīng)是前后端分離后端只提供JSON接口那么攻擊者主要盯的是JSON請求體里的字段這些數(shù)據(jù)被前端取走后如果直接插入頁面同樣形成存儲型XSS。Spring Boot本身沒有內(nèi)置一個一鍵防XSS開關(guān)加上很多團隊默認(rèn)我們只返回JSON不會渲染HTML這個認(rèn)知錯位就是漏洞的溫床。防御方案要從這個真實風(fēng)險出發(fā)而不是從我覺得別人應(yīng)該不會這么傳出發(fā)。1.2 兩個常被忽略的隱蔽攻擊入口第一個隱蔽入口是HTTP請求頭。服務(wù)端記錄日志、客服系統(tǒng)展示用戶設(shè)備信息、管理后臺展示訪客IP歸屬都可能把User-Agent、Referer、X-Forwarded-For原樣展示在頁面上。攻擊者只要把腳本放在UA里管理員一看日志就中招這種場景在小團隊里非常常見因為大家默認(rèn)請求頭是瀏覽器生成的不會有惡意內(nèi)容。第二個隱蔽入口是文件上傳后的內(nèi)容展示。很多系統(tǒng)上傳Excel導(dǎo)入數(shù)據(jù)之后把每一行的內(nèi)容回顯到頁面上這些內(nèi)容是團隊自己上傳的潛意識里覺得安全但Excel單元格里完全能寫入img srcx onerroralert(1)導(dǎo)入后再一展示就觸發(fā)。我在方案里把這些隱蔽入口也納入清洗范圍標(biāo)準(zhǔn)很簡單只要數(shù)據(jù)要從后端流向用戶瀏覽器就必須經(jīng)過統(tǒng)一的輸出編碼或輸入清洗沒有任何字段可以豁免。2. 用過濾器與請求包裝器構(gòu)建統(tǒng)一入口防線2.1 為什么選Filter而不是攔截器第一版方案我沒考慮攔截器因為Spring的HandlerInterceptor依賴Spring MVC的HandlerMapping這意味著靜態(tài)資源、非Controller路徑、部分異常處理路徑根本不會進入攔截器。而Servlet的Filter位于整個請求處理鏈的最前端Container加載它的時候請求還沒有被映射到任何Controller這個位置決定了它可以用統(tǒng)一的方式處理所有進來的請求無論是動態(tài)接口、靜態(tài)資源還是表單提交。我用的實現(xiàn)方式是自定義一個XssFilter在web應(yīng)用啟動時注入到過濾器鏈的最前面通過Order(Ordered.HIGHEST_PRECEDENCE)保證它先于其他過濾器執(zhí)行。Filter的核心作用不是自己完成所有清洗而是把原始請求包裝成一個安全請求對象后續(xù)的Controller、Service拿到的都是經(jīng)過處理后的數(shù)據(jù)。這樣業(yè)務(wù)代碼完全不用關(guān)心XSS防御新增接口也不會因為忘記過濾而留漏洞這是方案能長期維持的根本原因。2.2 請求包裝器的完整實現(xiàn)過濾器本身很薄真正干活的是HttpServletRequestWrapper。包裝器覆蓋了getParameter、getParameterValues、getParameterMap這幾個最常見的參數(shù)讀取入口做到無論代碼里用哪種方式取參數(shù)得到的都是清洗后的值。先看基礎(chǔ)版本public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { return XssCleanUtil.clean(super.getParameter(name)); } Override public String[] getParameterValues(String name) { String[] values super.getParameterValues(name); if (values null) { return null; } String[] cleanValues new String[values.length]; for (int i 0; i values.length; i) { cleanValues[i] XssCleanUtil.clean(values[i]); } return cleanValues; } Override public MapString, String[] getParameterMap() { MapString, String[] parameterMap super.getParameterMap(); MapString, String[] cleanMap new HashMap(parameterMap.size()); for (Map.EntryString, String[] entry : parameterMap.entrySet()) { String[] values entry.getValue(); String[] cleanValues new String[values.length]; for (int i 0; i values.length; i) { cleanValues[i] XssCleanUtil.clean(values[i]); } cleanMap.put(entry.getKey(), cleanValues); } return cleanMap; } }這里有一個在IndexPage描述不太清楚但實際非常關(guān)鍵的細(xì)節(jié)getParameterMap返回的Map通常是不可修改的內(nèi)部視圖直接修改會報異常所以必須復(fù)制一份新Map。我在第一版踩過這個坑后來才改成復(fù)制的方式。2.3 JSON請求體清洗的第三種方案上面包裝器解決的是查詢參數(shù)和表單字段但現(xiàn)代接口大量使用Content-Type: application/jsonRequestBody通過getInputStream()讀取這個時候Filter處理起來就麻煩很多。這個問題的根源在于請求流只能讀一次而Controller的RequestBody也需要讀流兩邊會沖突。在我的實際項目里我采用的方案是對getInputStream()做緩存包裝包裝器第一次初始化時把請求體完整讀出來清洗一遍后存到內(nèi)存里之后Controller再讀的時候讀的是清洗后的緩存流。這樣處理完整保留了原有的JSON結(jié)構(gòu)又保證了防腐邏輯的統(tǒng)一。示例代碼如下public class XssBodyRequestWrapper extends HttpServletRequestWrapper { private final String cleanBody; public XssBodyRequestWrapper(HttpServletRequest request) throws IOException { super(request); String rawBody request.getReader().lines() .collect(Collectors.joining(System.lineSeparator())); this.cleanBody XssCleanUtil.cleanBody(rawBody); } Override public BufferedReader getReader() throws IOException { return new BufferedReader(new StringReader(cleanBody)); } Override public ServletInputStream getInputStream() throws IOException { byte[] bytes cleanBody.getBytes(StandardCharsets.UTF_8); ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(bytes); return new ServletInputStream() { Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 保持Servlet InputStream接口完整性 } Override public int read() { return byteArrayInputStream.read(); } }; } }這里還有個容易忽略的要點當(dāng)請求體經(jīng)過清洗后如果包裹的接口調(diào)用方用了流式讀取那么必須確保返回的ServletInputStream不是null而且在getInputStream()被調(diào)用過之后能正常工作否則會出現(xiàn)IllegalStateException。我最后上線前把所有接口都回歸了一遍確保getInputStream情況正常。2.4 清洗引擎的設(shè)計與依賴選型清洗引擎是這套方案最重要的部分。我曾經(jīng)嘗試自己寫正則表達式來替換script、javascript:、onerror等關(guān)鍵詞實踐證明這是一條死胡同攻擊者只需使用scrscriptipt、大小寫混寫、HTML實體編碼、反斜杠變體就能繞過正則。最終我引入了Jsoup的clean方法它通過白名單機制只保留顯式允許的標(biāo)簽和屬性其它全部剔除這里重點說下常用配置。dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.16.2/version /dependencypublic class XssCleanUtil { private static final Whitelist WHITELIST Whitelist.relaxed() .addProtocols(a, href, http, https) .addProtocols(img, src, http, https); public static String clean(String value) { if (value null || value.isEmpty()) { return value; } return Jsoup.clean(value, WHITELIST); } public static String cleanBody(String body) { if (body null || body.isEmpty()) { return body; } // JSON字符串里嵌套的HTML標(biāo)簽、屬性都會被清洗同時保留JSON結(jié)構(gòu) return Jsoup.clean(body, WHITELIST); } }使用Jsoup需要注意的一個實際問題Whitelist.relaxed()會保留很多標(biāo)簽和屬性如果業(yè)務(wù)場景是純文本內(nèi)容我更建議直接使用Whitelist.none()這意味著所有HTML標(biāo)簽都會被剝除只留下純文本如果業(yè)務(wù)里確實需要富文本編輯器的內(nèi)容就需要配合addAttributes、addProtocols做細(xì)粒度配置比如只允許img的src使用http和https協(xié)議禁止javascript:偽協(xié)議。項目里的富文本場景我單獨走了一條豁免路徑后面在常見問題里細(xì)講。3. 出口防線與瀏覽器安全策略CSP與輸出編碼3.1 Content-Security-Policy響應(yīng)頭的配置入口清洗不是萬能的攻擊者還可能通過其它渠道把危險內(nèi)容投放到頁面所以必須在瀏覽器端劃定安全邊界。CSP內(nèi)容安全策略是一種由服務(wù)器下發(fā)的策略指令它明確告訴瀏覽器哪些來源的腳本可以執(zhí)行、哪些來源的資源可以加載。一旦策略生效即使頁面里被注入了script標(biāo)簽瀏覽器也會拒絕執(zhí)行。在Spring Boot里添加CSP響應(yīng)頭最常見的方式是擴展WebSecurityConfigurerAdapter或使用較新的SecurityFilterChain配置在響應(yīng)頭中加入Content-Security-Policy。一個敞開的初始化版本配置如下Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.headers(headers - headers .contentSecurityPolicy(csp - csp .policyDirectives(default-src self; script-src self; img-src self data:; style-src self unsafe-inline; frame-ancestors none) ) ); return http.build(); } }注意script-src self禁止了頁面里內(nèi)聯(lián)的script代碼這是最嚴(yán)格的狀態(tài)。實際項目里如果前端寫了內(nèi)聯(lián)腳本或者用了老舊的第三方組件會把所有腳本執(zhí)行卡死這時候可以把這個策略改成script-src self unsafe-inline但這就留下了內(nèi)聯(lián)注入的口子。更好的做法是配合隨機Nonce值每次響應(yīng)生成一個隨機的Nonce只有帶這個Nonce的內(nèi)聯(lián)腳本才被允許執(zhí)行。Spring Security支持直接用Nonce配置我在后續(xù)項目中也切換到了這個更高等級的模式。3.2 Thymeleaf的輸出編碼與th:text對比輸出編碼是最后一個重要環(huán)節(jié)。在Spring Boot里使用Thymeleaf做頁面渲染時th:text的默認(rèn)行為就是HTML轉(zhuǎn)義比如把script轉(zhuǎn)成lt;scriptgt;瀏覽器拿到只會當(dāng)普通文本展示。但總有開發(fā)為了方便直接使用th:utext或[[ ${content} ]]這兩個方式輸出的是未經(jīng)轉(zhuǎn)義的HTML一旦字段內(nèi)容來自用戶輸入就相當(dāng)于親手為XSS鋪路。我這里給團隊定了一條鐵律凡是展示用戶可編輯內(nèi)容的地方一律使用th:text禁止使用th:utext。如果確實需要讓富文本編輯器內(nèi)容正常渲染就必須配合入口清洗白名單保證經(jīng)過Jsoup清洗后只保留安全標(biāo)簽再使用th:utext渲染因為這個內(nèi)容已經(jīng)是消毒后的產(chǎn)物。還有一個容易被忽視的隱藏問題是前后端分離架構(gòu)下前端框架的插值語法默認(rèn)會轉(zhuǎn)義但v-html和dangerouslySetInnerHTML這類接口是明晃晃的例外需要前端同學(xué)在項目規(guī)范里明確禁止或同樣配套清洗。3.3 Cookie安全屬性不可忽略XSS攻擊的核心目的之一是竊取登錄態(tài)Cookie而HttpOnly屬性能從源頭阻斷腳本讀取Cookie。Java后端設(shè)置Cookie時加上HttpOnly瀏覽器端JavaScript就無法通過document.cookie獲得這個值這等于在XSS陷阱上面加了個保險鎖。Spring Boot里設(shè)置HttpOnly Cookie的方式很簡單在Spring Security配置里使用http.csrf()的同時給Session Cookie加上HttpOnly、Secure屬性即可。如果使用自定義的Cookie寫入邏輯需要主動設(shè)置cookie.setHttpOnly(true)、cookie.setSecure(true)以及合理的SameSite屬性。我在項目里驗證過一個場景攻擊者已經(jīng)通過某個輸入點注入了腳本但嘗試讀取會話Cookie時得到的只是一個空殼這個不起眼的屬性確實讓事故等級直接降了一檔。4. 從測試到落地驗證防御方案是否真的生效4.1 用MockMvc編寫自動化測試安全方案做完了不能靠直覺判斷應(yīng)該沒問題必須用測試把每個防御點釘死。我推薦用Spring Boot自帶的MockMvc它對Filter鏈的模擬非常真實可以同時驗證輸入清洗和對響應(yīng)頭的斷言。SpringBootTest AutoConfigureMockMvc class XssDefenseTests { Autowired private MockMvc mockMvc; Test void shouldCleanScriptTagInQueryParameters() throws Exception { mockMvc.perform(get(/search) .param(keyword, scriptalert(1)/script)) .andExpect(status().isOk()) .andExpect(content().string(not(containsString(script)))); } Test void shouldCleanBodyInJsonRequest() throws Exception { String json {\content\:\img srcx onerroralert(1)\}; mockMvc.perform(post(/api/comment) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath($.content).value(not(containsString(onerror)))); } Test void shouldAddContentSecurityPolicyHeader() throws Exception { mockMvc.perform(get(/index)) .andExpect(header().string(Content-Security-Policy, containsString(script-src))); } }第一版測試跑通后我又加了一批繞過嘗試用例覆蓋大小寫混寫、HTML實體編碼、屬性事件、外鏈腳本這些常見攻擊方式。這些用例的價值不僅僅是驗證功能更是給后續(xù)維護的人傳遞一個信息這些攻擊路徑是被明確防御過的改動這部分代碼要小心。4.2 用瀏覽器開發(fā)工具做手動驗證自動化測試之外強烈建議開發(fā)者用瀏覽器開發(fā)工具手動做一輪全鏈路驗證。打開頁面的開發(fā)者工具在Console里查看是否有CSP報錯在Network面板查看響應(yīng)頭里是否真的帶上了Content-Security-Policy再切換到Sources面板確認(rèn)頁面源碼里script標(biāo)簽是否被轉(zhuǎn)義。CSP如果配置生效即使頁面上出現(xiàn)了內(nèi)聯(lián)腳本Console中也會輸出CSP拒絕執(zhí)行的明確警告這種反饋比代碼里任何日志都直觀。我常用的一組手動測試載荷在搜索框輸入scriptalert(document.domain)/script如果頁面彈窗或者出現(xiàn)CSP報錯說明防御存在缺口輸入img/srcx/onerroralert(1)測試屬性事件輸入a hrefjavascript:alert(1)test/a測試偽協(xié)議。這組載荷基本能把三種主流繞過路徑都覆蓋到。4.3 清理存量臟數(shù)據(jù)防御方案上線后數(shù)據(jù)庫里已經(jīng)存在的臟數(shù)據(jù)不會自動變干凈。我習(xí)慣的做法是針對用戶可編輯的核心字段昵稱、個人簡介、評論內(nèi)容寫一個一次性清理腳本把歷史數(shù)據(jù)用Jsoup的白名單機制重新清洗一遍然后再把清洗后的數(shù)據(jù)更新回庫。這個步驟很笨但非常有必要否則新方案只保護新寫入的數(shù)據(jù)存量數(shù)據(jù)一旦被翻出來展示漏洞依然存在。另外線上接口的日志也要檢查一遍確保日志系統(tǒng)沒有把包含原始攻擊載荷的請求體原樣輸出到文件。有些監(jiān)控系統(tǒng)甚至?xí)褞_本的內(nèi)容通過郵件報警發(fā)出來技術(shù)同事收到電子郵件打開時又中一次招。安全防御必須形成閉環(huán)從請求、存儲、輸出、日志、監(jiān)控每一個環(huán)節(jié)都要覆蓋。5. 實戰(zhàn)中的常見坑與排查經(jīng)驗5.1 過濾器導(dǎo)致JSON請求全部失效這是這套方案上線那天最慘痛的教訓(xùn)。某跨平臺系統(tǒng)采用整體過濾方案時只加了查詢參數(shù)的包裝器沒有處理請求體緩存流結(jié)果所有POST接口的RequestBody都拿到空對象前端全線報錯。排查了半個多小時才發(fā)現(xiàn)問題包裝器默認(rèn)的getInputStream()直接透傳原始流Filter和Controller之間有個先后讀流順序誰先讀了后面的就是空的。這個問題的標(biāo)準(zhǔn)解法就是我在2.3節(jié)寫的XssBodyRequestWrapper把請求體緩存一次后續(xù)的讀取全部指向緩存。需要額外注意的一點是這個緩存方案會占用內(nèi)存對于超大文件上傳類的接口建議直接從過濾器中排除或者設(shè)定只處理Content-Type為application/json的請求避免無謂的性能損耗。5.2 富文本與XSS防御的沖突后臺系統(tǒng)幾乎都有富文本編輯器的需求這也是最容易和過濾器爆發(fā)沖突的地方。團隊的編輯器生成的HTML里有style、class、id甚至>