Spring Security Web : StrictHttpFirewall HTTP防火墙(严格模式)

概述

功能介绍

StrictHttpFirewallSpring Security Web提供的一个HTTP防火墙(对应概念模型接口HttpFirewall)实现,该实现采用了严格模式,遇到任何可疑的请求,会通过抛出异常RequestRejectedException拒绝该请求。StrictHttpFirewall也是Spring Security Web在安全过滤器代理FilterChainProxy内置缺省使用的HTTP防火墙机制。

该防火墙实现使用如下规则决定是否拒绝一个请求:

  • 不在HTTP method许可清单的请求会被拒绝。

    HTTP method许可清单通过方法setAllowedHttpMethods(Collection)设置。
    缺省被允许的HTTP method有 [DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT]。

  • 如果请求URL 不是标准化(normalize)的URL则该请求会被拒绝,以避免安全限制被绕过。

    StrictHttpFirewall中该规则不能被禁用,因为关掉该限制风险极高。一种想通过该规则又想尽量安全的替代方法,是在请求到达前对URL进行标准化处理。跟StrictHttpFirewall相对应的另外一个HttpFirewall实现是DefaultHttpFirewallDefaultHttpFirewall中对请求URL的限制没有这一条,但是相应地安全性就下降了。

    这里需要提醒的是,对请求URL做标准化处理是一种比较"脆弱"的(fragile)手段。建议即使请求会被拒绝,也尽量不要对其URL做标准化。

    标准化的URL必须符合以下条件 :
    在其requestURI/contextPath/servletPath/pathInfo中,必须不能包含以下字符串序列之一 :
    ["//","./","/…/","/."]

  • 如果请求URL 中包含不可打印ASCII字符则该请求会被拒绝。

    该规则不可关闭,因为对应风极高。

    有关知识点 :

    1. requestURI : URL中去除协议,主机名,端口之后其余的部分,
    2. contextPath : requestURI中对应webapp的部分,
    3. servletPathrequestURI中对应识别Servlet的部分
    4. ,pathInfo : requestURI中去掉contextPath,servletPath剩下的部分
  • 如果请求URL(无论是URL编码前还是URL编码后)包含了分号(;或者%3b或者%3B)则该请求会被拒绝。

    通过开关函数setAllowSemicolon(boolean) 可以设置是否关闭该规则。缺省使用该规则。

  • 如果请求URL(无论是URL编码前还是URL编码后)包含了斜杠(%2f或者%2F)则该请求会被拒绝。

    通过开关函数setAllowUrlEncodedSlash(boolean) 可以设置是否关闭该规则。缺省使用该规则。

  • 如果请求URL(无论是URL编码前还是URL编码后)包含了反斜杠(\或者%5c或者%5B)则该请求会被拒绝。

    通过开关函数setAllowBackSlash(boolean) 可以设置是否关闭该规则。缺省使用该规则。

  • 如果请求URLURL编码后包含了%25(URL编码了的百分号%),或者在URL编码前包含了百分号%则该请求会被拒绝。

    通过开关函数setAllowUrlEncodedPercent(boolean) 可以设置是否关闭该规则。缺省使用该规则。

  • 如果请求URLURL编码后包含了URL编码的英文句号.(%2e或者%2E)则该请求会被拒绝。

    通过开关函数setAllowUrlEncodedPeriod(boolean) 可以设置是否关闭该规则。缺省使用该规则。

注意:这里提到的"URL编码后"对应英文是URL encoded,"URL编码前"指的是未执行URL编码的原始URL字符串,或者是"URL编码后"的URL经过解码URL decode得到的URL字符串(应该等于原始URL字符串)。

继承关系

StrictHttpFirewall

使用介绍


// FilterChainProxy 代码片段
private void doFilterInternal(ServletRequest request, ServletResponse response,
			FilterChain chain) throws IOException, ServletException {

		// 这里 firewall 缺省就是一个 StrictHttpFirewall 实例,
		// FilterChainProxy 允许外部设置使用指定的 firewall 实例
		// 这里对请求/响应对象进行防火墙增强,如果请求被检测到可疑会被拒绝,
		// 响应对象会被增加输出头部/cookie/redirect location值时的安全检查
		FirewalledRequest fwRequest = firewall
				.getFirewalledRequest((HttpServletRequest) request);
		HttpServletResponse fwResponse = firewall
				.getFirewalledResponse((HttpServletResponse) response);

		// 获取跟该请求匹配的所有安全过滤器
		List<Filter> filters = getFilters(fwRequest);

		if (filters == null || filters.size() == 0) {
			if (logger.isDebugEnabled()) {
				logger.debug(UrlUtils.buildRequestUrl(fwRequest)
						+ (filters == null ? " has no matching filters"
								: " has an empty filter list"));
			}

			fwRequest.reset();

			// 调用安全过滤器链 
			chain.doFilter(fwRequest, fwResponse);

			return;
		}

		// 构造并调用安全过滤器链  
		VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
		vfc.doFilter(fwRequest, fwResponse);
	}

源代码

源代码版本 : Spring Security Web 5.1.4.RELEASE

package org.springframework.security.web.firewall;

import org.springframework.http.HttpMethod;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;


public class StrictHttpFirewall implements HttpFirewall {
	/**
	 * Used to specify to #setAllowedHttpMethods(Collection) that any HTTP method should be allowed.
	 */
	private static final Set<String> ALLOW_ANY_HTTP_METHOD = Collections.unmodifiableSet(Collections.emptySet());

	private static final String ENCODED_PERCENT = "%25";

	private static final String PERCENT = "%";

	private static final List<String> FORBIDDEN_ENCODED_PERIOD = 
		Collections.unmodifiableList(Arrays.asList("%2e", "%2E"));

	private static final List<String> FORBIDDEN_SEMICOLON = 
		Collections.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));

	private static final List<String> FORBIDDEN_FORWARDSLASH = 
		Collections.unmodifiableList(Arrays.asList("%2f", "%2F"));

	private static final List<String> FORBIDDEN_BACKSLASH = 
		Collections.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));

	private Set<String> encodedUrlBlacklist = new HashSet<String>();

	private Set<String> decodedUrlBlacklist = new HashSet<String>();

	private Set<String> allowedHttpMethods = createDefaultAllowedHttpMethods();

	public StrictHttpFirewall() {
		urlBlacklistsAddAll(FORBIDDEN_SEMICOLON);
		urlBlacklistsAddAll(FORBIDDEN_FORWARDSLASH);
		urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);

		this.encodedUrlBlacklist.add(ENCODED_PERCENT);
		this.encodedUrlBlacklist.addAll(FORBIDDEN_ENCODED_PERIOD);
		this.decodedUrlBlacklist.add(PERCENT);
	}


	public void setUnsafeAllowAnyHttpMethod(boolean unsafeAllowAnyHttpMethod) {
		this.allowedHttpMethods = unsafeAllowAnyHttpMethod ? ALLOW_ANY_HTTP_METHOD : createDefaultAllowedHttpMethods();
	}

	public void setAllowedHttpMethods(Collection<String> allowedHttpMethods) {
		if (allowedHttpMethods == null) {
			throw new IllegalArgumentException("allowedHttpMethods cannot be null");
		}
		if (allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
			this.allowedHttpMethods = ALLOW_ANY_HTTP_METHOD;
		} else {
			this.allowedHttpMethods = new HashSet<>(allowedHttpMethods);
		}
	}

	
	public void setAllowSemicolon(boolean allowSemicolon) {
		if (allowSemicolon) {
			urlBlacklistsRemoveAll(FORBIDDEN_SEMICOLON);
		} else {
			urlBlacklistsAddAll(FORBIDDEN_SEMICOLON);
		}
	}


	public void setAllowUrlEncodedSlash(boolean allowUrlEncodedSlash) {
		if (allowUrlEncodedSlash) {
			urlBlacklistsRemoveAll(FORBIDDEN_FORWARDSLASH);
		} else {
			urlBlacklistsAddAll(FORBIDDEN_FORWARDSLASH);
		}
	}


	public void setAllowUrlEncodedPeriod(boolean allowUrlEncodedPeriod) {
		if (allowUrlEncodedPeriod) {
			this.encodedUrlBlacklist.removeAll(FORBIDDEN_ENCODED_PERIOD);
		} else {
			this.encodedUrlBlacklist.addAll(FORBIDDEN_ENCODED_PERIOD);
		}
	}

	public void setAllowBackSlash(boolean allowBackSlash) {
		if (allowBackSlash) {
			urlBlacklistsRemoveAll(FORBIDDEN_BACKSLASH);
		} else {
			urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);
		}
	}


	public void setAllowUrlEncodedPercent(boolean allowUrlEncodedPercent) {
		if (allowUrlEncodedPercent) {
			this.encodedUrlBlacklist.remove(ENCODED_PERCENT);
			this.decodedUrlBlacklist.remove(PERCENT);
		} else {
			this.encodedUrlBlacklist.add(ENCODED_PERCENT);
			this.decodedUrlBlacklist.add(PERCENT);
		}
	}

	private void urlBlacklistsAddAll(Collection<String> values) {
		this.encodedUrlBlacklist.addAll(values);
		this.decodedUrlBlacklist.addAll(values);
	}

	private void urlBlacklistsRemoveAll(Collection<String> values) {
		this.encodedUrlBlacklist.removeAll(values);
		this.decodedUrlBlacklist.removeAll(values);
	}

	@Override
	public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
		rejectForbiddenHttpMethod(request);
		rejectedBlacklistedUrls(request);

		if (!isNormalized(request)) {
			throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
		}

		String requestUri = request.getRequestURI();
		if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
			throw new RequestRejectedException(
				"The requestURI was rejected because it can only contain printable ASCII characters.");
		}
		return new FirewalledRequest(request) {
			@Override
			public void reset() {
			}
		};
	}

	private void rejectForbiddenHttpMethod(HttpServletRequest request) {
		if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
			return;
		}
		if (!this.allowedHttpMethods.contains(request.getMethod())) {
			throw new RequestRejectedException("The request was rejected because the HTTP method \"" +
					request.getMethod() +
					"\" was not included within the whitelist " +
					this.allowedHttpMethods);
		}
	}

	private void rejectedBlacklistedUrls(HttpServletRequest request) {
		for (String forbidden : this.encodedUrlBlacklist) {
			if (encodedUrlContains(request, forbidden)) {
				throw new RequestRejectedException(
					"The request was rejected because the URL contained a potentially malicious String \"" 
					+ forbidden + "\"");
			}
		}
		for (String forbidden : this.decodedUrlBlacklist) {
			if (decodedUrlContains(request, forbidden)) {
				throw new RequestRejectedException(
					"The request was rejected because the URL contained a potentially malicious String \"" 
					+ forbidden + "\"");
			}
		}
	}

	@Override
	public HttpServletResponse getFirewalledResponse(HttpServletResponse response) {
		return new FirewalledResponse(response);
	}

	private static Set<String> createDefaultAllowedHttpMethods() {
		Set<String> result = new HashSet<>();
		result.add(HttpMethod.DELETE.name());
		result.add(HttpMethod.GET.name());
		result.add(HttpMethod.HEAD.name());
		result.add(HttpMethod.OPTIONS.name());
		result.add(HttpMethod.PATCH.name());
		result.add(HttpMethod.POST.name());
		result.add(HttpMethod.PUT.name());
		return result;
	}


    // 对请求 request URL 的四个部分做标准化检查 :
    // requestURI,  contextPath, servletPath, pathInfo
	private static boolean isNormalized(HttpServletRequest request) {
		if (!isNormalized(request.getRequestURI())) {
			return false;
		}
		if (!isNormalized(request.getContextPath())) {
			return false;
		}
		if (!isNormalized(request.getServletPath())) {
			return false;
		}
		if (!isNormalized(request.getPathInfo())) {
			return false;
		}
		return true;
	}

	private static boolean encodedUrlContains(HttpServletRequest request, String value) {
		if (valueContains(request.getContextPath(), value)) {
			return true;
		}
		return valueContains(request.getRequestURI(), value);
	}

	private static boolean decodedUrlContains(HttpServletRequest request, String value) {
		if (valueContains(request.getServletPath(), value)) {
			return true;
		}
		if (valueContains(request.getPathInfo(), value)) {
			return true;
		}
		return false;
	}

	private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
		int length = uri.length();
		for (int i = 0; i < length; i++) {
			char c = uri.charAt(i);
			if (c < '\u0020' || c > '\u007e') {
				return false;
			}
		}

		return true;
	}

	private static boolean valueContains(String value, String contains) {
		return value != null && value.contains(contains);
	}


    // 检测一个路径参数path是否是一个标准化了的路径 :
    // 1. 如果路径中包含连续两个反斜杠 "//" 则会被认为是非标准化的
    // 2. 如果路径中包含 "./" , "/../" , 或者 "/." , 则会被认为是非标准化的
	private static boolean isNormalized(String path) {
		if (path == null) {
			return true;
		}

		if (path.indexOf("//") > -1) {
			return false;
		}

		for (int j = path.length(); j > 0;) {
			int i = path.lastIndexOf('/', j - 1);
			int gap = j - i;

			if (gap == 2 && path.charAt(i + 1) == '.') {
				// ".", "/./" or "/."
				return false;
			} else if (gap == 3 && path.charAt(i + 1) == '.' && path.charAt(i + 2) == '.') {
				return false;
			}

			j = i;
		}

		return true;
	}

}

参考文章

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值