Spring 5.0.3 RequestRejectedException: a solicitação foi rejeitada porque o URL não foi normalizado

88

Não tenho certeza se isso é um bug do Spring 5.0.3 ou um novo recurso para consertar coisas do meu lado.

Após a atualização, estou recebendo este erro. Curiosamente, este erro está apenas na minha máquina local. O mesmo código no ambiente de teste com protocolo HTTPS funciona bem.

Continuando...

Estou recebendo esse erro porque meu URL para carregar a página JSP resultante é /location/thisPage.jsp. Avaliar o código request.getRequestURI()me dá resultados /WEB-INF/somelocation//location/thisPage.jsp. Se eu corrigir a URL da página JSP para isso location/thisPage.jsp, as coisas funcionam bem.

Portanto, minha pergunta é: devo remover /do JSPcaminho no código, porque é isso que é necessário no futuro. Ou Springintroduziu um bug porque a única diferença entre minha máquina e o ambiente de teste é o protocolo HTTPversus HTTPS.

 org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL was not normalized.
    at org.springframework.security.web.firewall.StrictHttpFirewall.getFirewalledRequest(StrictHttpFirewall.java:123)
    at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:194)
    at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:186)
    at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
    at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
java_dude
fonte
1
O problema está planejado para ser resolvido em 5.1.0; Atualmente 5.0.0 não tem esse problema.
java_dude

Respostas:

67

A Documentação do Spring Security menciona o motivo do bloqueio // na solicitação.

Por exemplo, ele pode conter sequências de percurso de caminho (como /../) ou várias barras (//) que também podem fazer com que as correspondências de padrão falhem. Alguns contêineres os normalizam antes de executar o mapeamento do servlet, mas outros não. Para se proteger contra problemas como esses, o FilterChainProxy usa uma estratégia HttpFirewall para verificar e encerrar a solicitação. As solicitações não normalizadas são rejeitadas automaticamente por padrão, e os parâmetros do caminho e barras duplicadas são removidos para fins de correspondência.

Portanto, existem duas soluções possíveis -

  1. remover barra dupla (abordagem preferencial)
  2. Permita // no Spring Security personalizando o StrictHttpFirewall usando o código abaixo.

Etapa 1 Crie um firewall personalizado que permita barra no URL.

@Bean
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
    StrictHttpFirewall firewall = new StrictHttpFirewall();
    firewall.setAllowUrlEncodedSlash(true);    
    return firewall;
}

Etapa 2 E então configurar este bean em websecurity

@Override
public void configure(WebSecurity web) throws Exception {
    //@formatter:off
    super.configure(web);
    web.httpFirewall(allowUrlEncodedSlashHttpFirewall());
....
}

A etapa 2 é uma etapa opcional, Spring Boot só precisa de um bean para ser declarado do tipo HttpFirewall

Munish Chandel
fonte
Sim, a segurança de passagem de caminho foi introduzida. Esse é um novo recurso e pode ter causado o problema. O que não tenho certeza, pois você vê que funciona em HTTPS e não em HTTP. Prefiro esperar até que esse bug seja resolvido jira.spring.io/browse/SPR-16419
java_dude
muito possivelmente parte do nosso problema ... mas ... o usuário não está digitando um // então estou tentando descobrir como esse segundo / está sendo adicionado em primeiro lugar ... se a primavera está gerando nosso jstl url não deve adicionar isso, ou normalizá-lo após adicioná-lo.
xenoterracida de
4
Isso não resolve realmente a solução, pelo menos para Spring Security 5.1.1. Você deve usar DefaultHttpFirewall se precisar de URLs com duas barras como a / b // c. O método isNormalized não pode ser configurado ou substituído em StrictHttpFirewall.
Jason Winnebeck
Alguma chance de alguém dar dicas sobre como fazer isso sozinho na primavera em vez de no Boot?
escuna de
28

setAllowUrlEncodedSlash(true)não funcionou para mim. isNormalizedRetorno de método ainda interno falsequando tem barra dupla.

I substituídos StrictHttpFirewallcom DefaultHttpFirewallpor ter apenas o código a seguir:

@Bean
public HttpFirewall defaultHttpFirewall() {
    return new DefaultHttpFirewall();
}

Funcionando bem para mim.
Algum risco ao usar DefaultHttpFirewall?

maor chetrit
fonte
1
Sim. Só porque você não pode criar uma chave sobressalente para seu colega de quarto, não significa que você deve colocar a única chave sob o capacho. Não recomendado. A segurança não deve ser alterada.
java_dude
16
@java_dude Ótimo como você não forneceu nenhuma informação ou justificativa, apenas uma vaga analogia.
Kaqqao
Outra opção é criar uma subclasse StrictHttpFirewallpara dar um pouco mais de controle sobre a rejeição de URLs, conforme detalhado nesta resposta .
vallismortis
1
Isso funcionou para mim, mas eu também tive que adicionar isso no meu bean XML:<sec:http-firewall ref="defaultHttpFirewall"/>
Jason Winnebeck
1
Quais são as implicações de usar esta solução?
Felipe Desiderati
10

Encontrei o mesmo problema com:

Versão Spring Boot = 1.5.10
Versão Spring Security = 4.2.4


O problema ocorreu nos nós de extremidade, onde ModelAndViewviewName foi definido com uma barra anterior . Exemplo:

ModelAndView mav = new ModelAndView("/your-view-here");

Se eu removi a barra, funcionou bem. Exemplo:

ModelAndView mav = new ModelAndView("your-view-here");

Também fiz alguns testes com RedirectView e parecia funcionar com uma barra anterior.

Torsten Ojaperv
fonte
2
Essa não é a solução. E se isso fosse um bug do lado da primavera. Se eles alterarem, você terá que desfazer todas as alterações novamente. Prefiro esperar até 5.1, pois está marcado para ser resolvido até então.
java_dude
1
Não, você não precisa reverter a alteração porque definir viewName sem precedente a barra funciona bem em versões anteriores.
Torsten Ojaperv
Esse é exatamente o problema. Se funcionou bem e você não mudou nada, o Spring introduziu um bug. O caminho deve sempre começar com "/". Verifique qualquer documentação de primavera. Verifique estes github.com/spring-projects/spring-security/issues/5007 & github.com/spring-projects/spring-security/issues/5044
java_dude
1
Isso me mordeu também. Atualizar todo o ModelAndView sem o '/' inicial corrigiu o problema
Nathan Perrier
jira.spring.io/browse/SPR-16740 Abri um bug, mas remover o principal / não foi uma correção para mim e, na maioria dos casos, estamos apenas retornando o nome da visualização como uma string (do controlador) . É preciso considerar a visualização de redirecionamento como uma solução.
xenoterracide
6

Uma vez que usei barra dupla ao chamar a API, obtive o mesmo erro.

Tive que chamar http: // localhost: 8080 / getSomething, mas gostei de http: // localhost: 8080 // getSomething . Eu resolvi isso removendo a barra extra.

Vikash Kumar
fonte
podemos escrever algum tratamento de exceção para isso, de modo que possamos avisar ao cliente sobre sua entrada errada?
YouAreAwesome
4

No meu caso, atualizado de spring-securiy-web 3.1.3 para 4.2.12, o defaultHttpFirewallfoi alterado de DefaultHttpFirewallpara StrictHttpFirewallpor padrão. Portanto, apenas defina-o na configuração XML como abaixo:

<bean id="defaultHttpFirewall" class="org.springframework.security.web.firewall.DefaultHttpFirewall"/>
<sec:http-firewall ref="defaultHttpFirewall"/>

definido HTTPFirewallcomoDefaultHttpFirewall

Bingxin
fonte
1
Adicione alguma descrição ao seu código explicando o que está acontecendo e por quê. Esta é uma boa prática. Se não o fizer, sua resposta corre o risco de ser excluída. Já foi sinalizado como de baixa qualidade.
herrbischoff
3

A solução abaixo é uma solução limpa. Não compromete a segurança porque estamos usando o mesmo firewall rígido.

As etapas para correção são as seguintes:

ETAPA 1: Crie uma classe substituindo StrictHttpFirewall conforme abaixo.

package com.biz.brains.project.security.firewall;

import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpMethod;
import org.springframework.security.web.firewall.DefaultHttpFirewall;
import org.springframework.security.web.firewall.FirewalledRequest;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.RequestRejectedException;

public class CustomStrictHttpFirewall implements HttpFirewall {
    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 CustomStrictHttpFirewall() {
        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)) {
            request.setAttribute("isNormalized", new RequestRejectedException("The request was rejected because the URL was not normalized."));
        }

        String requestUri = request.getRequestURI();
        if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
            request.setAttribute("isNormalized",  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())) {
            request.setAttribute("isNormalized",  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)) {
                request.setAttribute("isNormalized",  new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\""));
            }
        }
        for (String forbidden : this.decodedUrlBlacklist) {
            if (decodedUrlContains(request, forbidden)) {
                request.setAttribute("isNormalized",  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;
    }

    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);
    }

    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;
    }

}

ETAPA 2: crie uma classe FirewalledResponse

package com.biz.brains.project.security.firewall;

import java.io.IOException;
import java.util.regex.Pattern;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;

class FirewalledResponse extends HttpServletResponseWrapper {
    private static final Pattern CR_OR_LF = Pattern.compile("\\r|\\n");
    private static final String LOCATION_HEADER = "Location";
    private static final String SET_COOKIE_HEADER = "Set-Cookie";

    public FirewalledResponse(HttpServletResponse response) {
        super(response);
    }

    @Override
    public void sendRedirect(String location) throws IOException {
        // TODO: implement pluggable validation, instead of simple blacklisting.
        // SEC-1790. Prevent redirects containing CRLF
        validateCrlf(LOCATION_HEADER, location);
        super.sendRedirect(location);
    }

    @Override
    public void setHeader(String name, String value) {
        validateCrlf(name, value);
        super.setHeader(name, value);
    }

    @Override
    public void addHeader(String name, String value) {
        validateCrlf(name, value);
        super.addHeader(name, value);
    }

    @Override
    public void addCookie(Cookie cookie) {
        if (cookie != null) {
            validateCrlf(SET_COOKIE_HEADER, cookie.getName());
            validateCrlf(SET_COOKIE_HEADER, cookie.getValue());
            validateCrlf(SET_COOKIE_HEADER, cookie.getPath());
            validateCrlf(SET_COOKIE_HEADER, cookie.getDomain());
            validateCrlf(SET_COOKIE_HEADER, cookie.getComment());
        }
        super.addCookie(cookie);
    }

    void validateCrlf(String name, String value) {
        if (hasCrlf(name) || hasCrlf(value)) {
            throw new IllegalArgumentException(
                    "Invalid characters (CR/LF) in header " + name);
        }
    }

    private boolean hasCrlf(String value) {
        return value != null && CR_OR_LF.matcher(value).find();
    }
}

ETAPA 3: Crie um filtro personalizado para suprimir a RejectedException

package com.biz.brains.project.security.filter;

import java.io.IOException;
import java.util.Objects;

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 org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.security.web.firewall.RequestRejectedException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;

import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestRejectedExceptionFilter extends GenericFilterBean {

        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
            try {
                RequestRejectedException requestRejectedException=(RequestRejectedException) servletRequest.getAttribute("isNormalized");
                if(Objects.nonNull(requestRejectedException)) {
                    throw requestRejectedException;
                }else {
                    filterChain.doFilter(servletRequest, servletResponse);
                }
            } catch (RequestRejectedException requestRejectedException) {
                HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
                HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
                log
                    .error(
                            "request_rejected: remote={}, user_agent={}, request_url={}",
                            httpServletRequest.getRemoteHost(),  
                            httpServletRequest.getHeader(HttpHeaders.USER_AGENT),
                            httpServletRequest.getRequestURL(), 
                            requestRejectedException
                    );

                httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND);
            }
        }
}

PASSO 4: adicione o filtro personalizado à cadeia de filtro de mola na configuração de segurança

@Override
protected void configure(HttpSecurity http) throws Exception {
     http.addFilterBefore(new RequestRejectedExceptionFilter(),
             ChannelProcessingFilter.class);
}

Agora usando a correção acima, podemos lidar RequestRejectedExceptioncom a página Erro 404.

Venkatraman Muthukrishnan
fonte
Obrigado. Esta é a abordagem que usei temporariamente para permitir que atualizemos nosso microsserviço Java até que todos os aplicativos front-end sejam atualizados. Não precisei das etapas 3 e 4 para permitir que '//' fosse considerado normalizado com êxito. Acabei de comentar a condição que verificava se há barra dupla em isNormalized e, em seguida, configurei um bean para usar a classe CustomStrictHttpFirewall.
gtaborga
Existe uma solução mais fácil via configuração? Mas sem desligar o firewall ..
Prathamesh dhanawade
0

No meu caso, o problema foi causado por não estar conectado com o Postman, então abri uma conexão em outra guia com um cookie de sessão que tirei dos cabeçalhos da minha sessão do Chrome.

Alex D
fonte