Spring 5.0.3 RequestRejectedException: la demande a été rejetée car l'URL n'était pas normalisée


88

Je ne sais pas s'il s'agit d'un bogue avec Spring 5.0.3 ou d'une nouvelle fonctionnalité pour corriger les choses de mon côté.

Après la mise à niveau, j'obtiens cette erreur. Fait intéressant, cette erreur ne concerne que ma machine locale. Le même code sur l'environnement de test avec le protocole HTTPS fonctionne correctement.

Continuer ...

La raison pour laquelle j'obtiens cette erreur est que mon URL pour charger la page JSP résultante est /location/thisPage.jsp. L'évaluation du code request.getRequestURI()me donne un résultat /WEB-INF/somelocation//location/thisPage.jsp. Si je fixe l'URL de la page JSP à cela location/thisPage.jsp, les choses fonctionnent correctement.

Ma question est donc la suivante: devrais-je supprimer /du JSPchemin dans le code, car c'est ce qui est nécessaire à l'avenir. Ou Springa introduit un bogue car la seule différence entre ma machine et mon environnement de test est le protocole HTTPpar rapport 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)


1
Le problème devrait être résolu dans la version 5.1.0; Actuellement 5.0.0 n'a pas ce problème.
java_dude

Réponses:


67

Spring Security Documentation mentionne la raison du blocage // dans la requête.

Par exemple, il peut contenir des séquences de parcours de chemin (comme /../) ou plusieurs barres obliques (//) qui peuvent également provoquer l'échec des correspondances de motifs. Certains conteneurs les normalisent avant d'effectuer le mappage de servlet, mais d'autres non. Pour se protéger contre de tels problèmes, FilterChainProxy utilise une stratégie HttpFirewall pour vérifier et encapsuler la demande. Les demandes non normalisées sont automatiquement rejetées par défaut et les paramètres de chemin et les barres obliques en double sont supprimés à des fins de correspondance.

Il y a donc deux solutions possibles -

  1. supprimer la double barre oblique (approche préférée)
  2. Autorisez // dans Spring Security en personnalisant StrictHttpFirewall à l'aide du code ci-dessous.

Étape 1 Créez un pare-feu personnalisé qui autorise les barres obliques dans l'URL.

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

Étape 2 Et puis configurez ce bean dans Websecurity

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

L'étape 2 est une étape facultative, Spring Boot a juste besoin d'un bean pour être déclaré de type HttpFirewall


Oui, la sécurité de traversée de chemin a été introduite. C'est une nouvelle fonctionnalité et cela aurait pu causer le problème. Ce dont je ne suis pas trop sûr, car vous voyez que cela fonctionne sur HTTPS et non sur HTTP. Je préfère attendre que ce bogue soit résolu jira.spring.io/browse/SPR-16419
java_dude

très probablement une partie de notre problème ... mais ... l'utilisateur ne tape pas dans un // donc j'essaie de comprendre comment ce second / est ajouté en premier lieu ... si spring génère notre jstl url il ne devrait pas l'ajouter, ni le normaliser après l'avoir ajouté.
xenoterracide

4
Cela ne résout pas réellement la solution, du moins pour Spring Security 5.1.1. Vous devez utiliser DefaultHttpFirewall si vous avez besoin d'URL avec deux barres obliques comme a / b // c. La méthode isNormalized ne peut pas être configurée ou remplacée dans StrictHttpFirewall.
Jason Winnebeck

Y a-t-il une chance que quelqu'un puisse donner des conseils sur la façon de faire cela au printemps uniquement par opposition à Boot?
schoon

28

setAllowUrlEncodedSlash(true)n'a pas fonctionné pour moi. Toujours la méthode interne isNormalizedretourne en falsecas de double barre oblique.

Je l' ai remplacé StrictHttpFirewallavec DefaultHttpFirewallen ayant le code suivant seulement:

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

Ça marche bien pour moi.
Un risque en utilisant DefaultHttpFirewall?


1
Oui. Ce n'est pas parce que vous ne pouvez pas créer une clé de rechange pour votre colocataire que vous devez placer la seule clé sous le paillasson. Pas conseillé. La sécurité ne doit pas être modifiée.
java_dude

16
@java_dude Super comment vous n'avez fourni aucune information ou justification du tout, juste une vague analogie.
kaqqao

Une autre option consiste à sous-classer StrictHttpFirewallpour donner un peu plus de contrôle sur le rejet des URL, comme détaillé dans cette réponse .
vallismortis

1
Cela a fonctionné pour moi mais j'ai aussi dû ajouter ceci dans mon bean XML:<sec:http-firewall ref="defaultHttpFirewall"/>
Jason Winnebeck

1
Quelles sont les implications de l'utilisation de cette solution?
Felipe Desiderati

10

J'ai rencontré le même problème avec:

Version Spring Boot = 1.5.10 Version
Spring Security = 4.2.4


Le problème s'est produit sur les points de terminaison, où le nom de ModelAndViewvue a été défini avec une barre oblique précédente . Exemple:

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

Si j'ai supprimé la barre oblique, cela fonctionnait bien. Exemple:

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

J'ai également fait quelques tests avec RedirectView et cela semblait fonctionner avec une barre oblique précédente.


2
Ce n'est pas la solution. Et si c'était un bug du côté de Spring. S'ils le changent, vous devrez à nouveau annuler tous les changements. Je préférerais attendre 5.1 car il est marqué pour être résolu d'ici là.
java_dude

1
Non, vous n'êtes pas obligé d'annuler la modification, car la définition de viewName sans la barre oblique précédente fonctionne bien sur les anciennes versions.
Torsten Ojaperv

C'est exactement ce qu'est le problème. Si cela a bien fonctionné et que vous n'avez rien changé, Spring a introduit un bogue. Le chemin doit toujours commencer par "/". Consultez la documentation du printemps. Découvrez-les sur github.com/spring-projects/spring-security/issues/5007 & github.com/spring-projects/spring-security/issues/5044
java_dude

1
Cela m'a mordu aussi. La mise à jour de tous les ModelAndView sans le premier '/' a résolu le problème
Nathan Perrier

jira.spring.io/browse/SPR-16740 J'ai ouvert un bogue, mais supprimer le premier / n'a pas été un correctif pour moi, et dans la plupart des cas, nous renvoyons simplement le nom de la vue sous forme de chaîne (à partir du contrôleur) . Besoin de considérer la vue de redirection comme une solution.
xenoterracide


4

Dans mon cas, mis à jour de spring-securiy-web 3.1.3 à 4.2.12, le a defaultHttpFirewallété changé de DefaultHttpFirewallà StrictHttpFirewallpar défaut. Alors définissez-le simplement dans la configuration XML comme ci-dessous:

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

définir HTTPFirewallcommeDefaultHttpFirewall


1
Veuillez ajouter une description à votre code expliquant ce qui se passe et pourquoi. C'est une bonne pratique. Sinon, votre réponse risque d'être supprimée. Il a déjà été signalé comme étant de mauvaise qualité.
herrbischoff

3

La solution ci-dessous est un travail propre qui ne compromet pas la sécurité car nous utilisons le même pare-feu strict.

Les étapes de fixation sont les suivantes:

ÉTAPE 1: Créez une classe remplaçant StrictHttpFirewall comme ci-dessous.

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

}

ÉTAPE 2: créer une 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();
    }
}

ÉTAPE 3: créer un filtre personnalisé pour supprimer l' exception 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);
            }
        }
}

ÉTAPE 4: Ajoutez le filtre personnalisé à la chaîne de filtres à ressort dans la configuration de sécurité

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

Maintenant, en utilisant le correctif ci-dessus, nous pouvons gérer RequestRejectedExceptionavec la page Erreur 404.


Merci. C'est l'approche que j'ai utilisée temporairement pour nous permettre de mettre à niveau notre microservice Java jusqu'à ce que les applications frontales soient toutes mises à niveau. Je n'avais pas besoin des étapes 3 et 4 pour permettre à «//» d'être considéré comme normalisé. Je viens de commenter la condition qui a vérifié la double barre oblique dans isNormalized, puis j'ai configuré un bean pour utiliser la classe CustomStrictHttpFirewall à la place.
gtaborga

Existe-t-il une solution de contournement plus simple via config? Mais sans désactiver le pare-feu ..
Prathamesh dhanawade

0

Dans mon cas, le problème était dû au fait que je n'étais pas connecté à Postman, j'ai donc ouvert une connexion dans un autre onglet avec un cookie de session que j'ai pris dans les en-têtes de ma session Chrome.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.