T1.1: SanitizeFilter 请求规整化(解码检查 ..///控制字符裸 400,只拒不修;attribute 契约 sanitized.uri/stripped.path)
This commit is contained in:
@@ -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 做解码归一化检查,
|
||||
* 杜绝路径穿越/编码绕过。<b>只拒绝不修复</b>(不做自动纠错);query 部分不动
|
||||
* (canonical 计算按 F1 排序规则处理)。
|
||||
* <p>
|
||||
* 拒绝(裸 400)条件——对 percent-decode 后的 URI 判定:
|
||||
* 含 {@code ..}(含编码形态 {@code %2e%2e})、路径含双斜杠 {@code //}、
|
||||
* 含控制字符(0x00-0x1f,含空字节)。
|
||||
* <p>
|
||||
* 通过后把<b>原始 URI</b>(逐字节不改写——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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user