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