diff --git a/src/main/java/com/yunhai/platform/gateway/filter/SanitizeFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/SanitizeFilter.java new file mode 100644 index 0000000..552da89 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/SanitizeFilter.java @@ -0,0 +1,104 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.envelope.EnvelopeWriter; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 请求规整化(T1.1,安全链第一环):进转发链前对 URI 做解码归一化检查, + * 杜绝路径穿越/编码绕过。只拒绝不修复(不做自动纠错);query 部分不动 + * (canonical 计算按 F1 排序规则处理)。 + *

+ * 拒绝(裸 400)条件——对 percent-decode 后的 URI 判定: + * 含 {@code ..}(含编码形态 {@code %2e%2e})、路径含双斜杠 {@code //}、 + * 含控制字符(0x00-0x1f,含空字节)。 + *

+ * 通过后把原始 URI(逐字节不改写——T0.12 forwardUrl 与 T1.2 canonical 都消费原始形态) + * 与剥掉 target 首段后的路径存 request attribute,后续过滤器与转发器一律取此值。 + */ +@Component +@Order(1) +public class SanitizeFilter implements Filter { + + public static final String ATTR_SANITIZED_URI = "yh.gateway.sanitized.uri"; + public static final String ATTR_STRIPPED_PATH = "yh.gateway.stripped.path"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + String uri = req.getRequestURI(); + String decoded = percentDecode(uri); + if (isRejected(decoded)) { + EnvelopeWriter.writeBare(res, 400); + return; + } + req.setAttribute(ATTR_SANITIZED_URI, uri); + req.setAttribute(ATTR_STRIPPED_PATH, stripTargetSegment(uri)); + chain.doFilter(request, response); + } + + /** 解码后含 ..、双斜杠、控制字符(含空字节)即拒 */ + static boolean isRejected(String decodedUri) { + if (decodedUri == null || decodedUri.isEmpty()) { + return true; + } + if (decodedUri.contains("..")) { + return true; + } + if (decodedUri.contains("//")) { + return true; + } + for (int i = 0; i < decodedUri.length(); i++) { + if (decodedUri.charAt(i) <= '\u001f') { + return true; + } + } + return false; + } + + /** percent-decode(仅 %XX;'+' 保持字面——path 部分 + 是合法字符,不走 query 规则) */ + static String percentDecode(String s) { + if (s == null || s.indexOf('%') < 0) { + return s; + } + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '%' && i + 2 < s.length()) { + int hi = Character.digit(s.charAt(i + 1), 16); + int lo = Character.digit(s.charAt(i + 2), 16); + if (hi >= 0 && lo >= 0) { + sb.append((char) ((hi << 4) | lo)); + i += 2; + continue; + } + } + sb.append(c); + } + return sb.toString(); + } + + /** 剥 target 首段:/userbase/oapi/x → /oapi/x;无第二段原样返回(target 判定是 TargetRouter 职责) */ + static String stripTargetSegment(String uri) { + if (uri == null || uri.isEmpty()) { + return uri; + } + int second = uri.indexOf('/', 1); + return second < 0 ? uri : uri.substring(second); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/filter/SanitizeFilterTest.java b/src/test/java/com/yunhai/platform/gateway/filter/SanitizeFilterTest.java new file mode 100644 index 0000000..ae95d96 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/filter/SanitizeFilterTest.java @@ -0,0 +1,61 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.envelope.EnvelopeWriter; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * SanitizeFilter 五用例(T1.1 TDD):路径穿越(明文/编码)、双斜杠、控制字符均裸 400; + * 正常路径放行且两个 attribute 契约已设。 + */ +class SanitizeFilterTest { + + private MockHttpServletResponse run(String uri) throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", uri); + MockHttpServletResponse res = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + new SanitizeFilter().doFilter(req, res, chain); + return res; + } + + @Test + void 路径穿越_裸400() throws Exception { + assertThat(run("/userbase/oapi/a/../b").getStatus()).isEqualTo(400); + } + + @Test + void 编码形态路径穿越_裸400() throws Exception { + assertThat(run("/userbase/oapi/%2e%2e/b").getStatus()).isEqualTo(400); + assertThat(run("/userbase/oapi/%2E%2E%2Fetc").getStatus()).isEqualTo(400); + } + + @Test + void 路径双斜杠_裸400() throws Exception { + assertThat(run("/userbase//oapi/x").getStatus()).isEqualTo(400); + assertThat(run("/userbase/oapi//list").getStatus()).isEqualTo(400); + } + + @Test + void 控制字符与空字节_裸400() throws Exception { + assertThat(run("/userbase/oapi/a\u0000b").getStatus()).isEqualTo(400); + assertThat(run("/userbase/oapi/a\u001fb").getStatus()).isEqualTo(400); + } + + @Test + void 正常路径放行且attribute已设() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x"); + MockHttpServletResponse res = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + new SanitizeFilter().doFilter(req, res, chain); + assertThat(res.getStatus()).as("正常路径放行").isEqualTo(200); + assertThat(chain.getRequest()).as("链条继续").isNotNull(); + assertThat(req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI)) + .as("sanitized.uri = 原始 URI(逐字节不改写)").isEqualTo("/userbase/oapi/x"); + assertThat(req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH)) + .as("stripped.path = 剥 target 首段").isEqualTo("/oapi/x"); + } +}