Spring 5.0.3 RequestRejectedException: la solicitud fue rechazada porque la URL no se normalizó

88

No estoy seguro si esto es un error con Spring 5.0.3 o una nueva característica para arreglar las cosas por mi parte.

Después de la actualización, recibo este error. Curiosamente, este error solo está en mi máquina local. El mismo código en el entorno de prueba con el protocolo HTTPS funciona bien.

Continuo...

La razón por la que recibo este error es porque mi URL para cargar la página JSP resultante es /location/thisPage.jsp. Evaluar el código request.getRequestURI()me da un resultado /WEB-INF/somelocation//location/thisPage.jsp. Si arreglo la URL de la página JSP a esto location/thisPage.jsp, las cosas funcionan bien.

Entonces, mi pregunta es, ¿debo eliminar /de la JSPruta en el código porque eso es lo que se requiere en el futuro? O Springha introducido un error ya que la única diferencia entre mi máquina y el entorno de prueba es el protocolo HTTPfrente a 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
fuente
1
Está previsto que el problema se resuelva en 5.1.0; Actualmente 5.0.0 no tiene este problema.
java_dude

Respuestas:

67

Spring Security Documentation menciona el motivo del bloqueo // en la solicitud.

Por ejemplo, podría contener secuencias transversales de ruta (como /../) o múltiples barras diagonales (//) que también podrían hacer que las coincidencias de patrones fallen. Algunos contenedores los normalizan antes de realizar el mapeo de servlets, pero otros no. Para protegerse contra problemas como estos, FilterChainProxy usa una estrategia HttpFirewall para verificar y envolver la solicitud. Las solicitudes no normalizadas se rechazan automáticamente de forma predeterminada, y los parámetros de ruta y las barras inclinadas duplicadas se eliminan para fines de coincidencia.

Entonces hay dos posibles soluciones:

  1. eliminar doble barra (enfoque preferido)
  2. Permita // en Spring Security personalizando StrictHttpFirewall usando el siguiente código.

Paso 1 Cree un firewall personalizado que permita la barra diagonal en la URL.

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

Paso 2 Y luego configure este bean en websecurity

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

El paso 2 es un paso opcional, Spring Boot solo necesita un bean para ser declarado de tipo HttpFirewall

Araña de Munish
fuente
Sí, se ha introducido la seguridad de recorrido de ruta. Esa es una característica nueva y esto podría haber causado el problema. Lo que no estoy muy seguro, como ve, funciona en HTTPS y no en HTTP. Prefiero esperar hasta que se resuelva este error jira.spring.io/browse/SPR-16419
java_dude
muy posiblemente sea parte de nuestro problema ... pero ... el usuario no está escribiendo un // así que estoy tratando de averiguar cómo se agrega ese segundo / en primer lugar ... si la primavera está generando nuestro jstl url no debería agregar eso, o normalizarlo después de agregarlo.
xenoterracide
4
En realidad, esto no resuelve la solución, al menos para Spring Security 5.1.1. Debe usar DefaultHttpFirewall si necesita URL con dos barras inclinadas como a / b // c. El método isNormalized no se puede configurar ni anular en StrictHttpFirewall.
Jason Winnebeck
¿Alguna posibilidad de que alguien pueda dar consejos sobre cómo hacer esto solo en Spring en lugar de Boot?
Schoon
28

setAllowUrlEncodedSlash(true)no funcionó para mí. Aún isNormalizedretorno del método internofalse cuando hay doble barra.

He sustituido StrictHttpFirewallcon DefaultHttpFirewallal tener sólo el código siguiente:

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

Funcionando bien para mi.
¿Algún riesgo al usar DefaultHttpFirewall?

maor chetrit
fuente
1
Si. El hecho de que no pueda crear una llave de repuesto para su compañero de cuarto no significa que deba colocar la única llave debajo del felpudo. No aconsejado. La seguridad no debe cambiarse.
java_dude
16
@java_dude Genial cómo no proporcionaste información ni justificación alguna, solo una vaga analogía.
kaqqao
Otra opción es crear StrictHttpFirewalluna subclase para dar un poco más de control sobre el rechazo de las URL, como se detalla en esta respuesta .
vallismortis
1
Esto funcionó para mí, pero también tuve que agregar esto en mi bean XML:<sec:http-firewall ref="defaultHttpFirewall"/>
Jason Winnebeck
1
¿Cuáles son las implicaciones de usar esta solución?
Felipe Desiderati
10

Encontré el mismo problema con:

Spring Boot versión = 1.5.10
Spring Security versión = 4.2.4


El problema se produjo en los puntos finales, donde ModelAndViewviewName se definió con una barra diagonal anterior . Ejemplo:

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

Si quité la barra, funcionó bien. Ejemplo:

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

También hice algunas pruebas con RedirectView y pareció funcionar con una barra diagonal anterior.

Torsten Ojaperv
fuente
2
Ésa no es la solución. ¿Y si esto fuera un error del lado de Spring? Si lo cambian, tendrá que deshacer todos los cambios nuevamente. Preferiría esperar hasta 5.1 ya que está marcado para resolverse para entonces.
java_dude
1
No, no tiene que revertir el cambio porque definir viewName sin la barra diagonal anterior funciona bien en versiones anteriores.
Torsten Ojaperv
Eso es exactamente el problema. Si funcionó bien y no cambió nada, Spring ha introducido un error. La ruta siempre debe comenzar con "/". Consulte la documentación de primavera. Échales un vistazo a github.com/spring-projects/spring-security/issues/5007 y github.com/spring-projects/spring-security/issues/5044
java_dude
1
Esto también me mordió. Actualizar todo el ModelAndView sin el '/' principal solucionó el problema
Nathan Perrier
jira.spring.io/browse/SPR-16740 Abrí un error, pero eliminar el / no ha sido una solución para mí, y en la mayoría de los casos solo devolvemos el nombre de la vista como una cadena (desde el controlador) . Es necesario considerar la vista de redireccionamiento como una solución.
xenoterracide
4

En mi caso, actualizado de spring-securiy-web 3.1.3 a 4.2.12, defaultHttpFirewallse cambió de DefaultHttpFirewalla StrictHttpFirewallde forma predeterminada. Así que defínalo en la configuración XML como se muestra a continuación:

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

establecer HTTPFirewallcomoDefaultHttpFirewall

Bingxin
fuente
1
Agregue una descripción a su código que explique qué está sucediendo y por qué. Ésta es una buena práctica. Si no lo hace, su respuesta corre el riesgo de ser eliminada. Ya se ha marcado como de baja calidad.
herrbischoff
3

La siguiente solución es un trabajo limpio, no compromete la seguridad porque estamos usando el mismo firewall estricto.

Los pasos para la reparación son los siguientes:

PASO 1: Cree una clase que anule StrictHttpFirewall como se muestra a continuación.

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

}

PASO 2: Cree una clase 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();
    }
}

PASO 3: Cree un filtro personalizado para suprimir la excepción Rejected

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

PASO 4: Agregue el filtro personalizado a la cadena de filtro de resorte en la configuración de seguridad

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

Ahora, usando la corrección anterior, podemos manejar la RequestRejectedExceptionpágina Error 404.

Venkatraman Muthukrishnan
fuente
Gracias. Este es el enfoque que utilicé temporalmente para permitirnos actualizar nuestro microservicio de Java hasta que se actualicen todas las aplicaciones de front-end. No necesitaba los pasos 3 y 4 para permitir que '//' se considerara normalizado. Acabo de comentar la condición que verificó la doble barra en isNormalized y luego configuré un bean para usar la clase CustomStrictHttpFirewall en su lugar.
gtaborga
¿Existe una solución alternativa más sencilla a través de la configuración? Pero sin apagar el firewall ..
Prathamesh dhanawade
0

En mi caso, el problema fue causado por no haber iniciado sesión con Postman, por lo que abrí una conexión en otra pestaña con una cookie de sesión que tomé de los encabezados en mi sesión de Chrome.

Alex D
fuente