Spring 5.0.3 RequestRejectedException: Żądanie zostało odrzucone, ponieważ adres URL nie został znormalizowany

88

Nie jestem pewien, czy jest to błąd w Spring 5.0.3, czy nowa funkcja, która naprawia rzeczy po mojej stronie.

Po uaktualnieniu pojawia się ten błąd. Co ciekawe ten błąd występuje tylko na moim lokalnym komputerze. Ten sam kod w środowisku testowym z protokołem HTTPS działa dobrze.

Kontynuacja ...

Powodem, dla którego otrzymuję ten błąd, jest to, że mój adres URL do wczytywania wynikowej strony JSP to /location/thisPage.jsp. Ocena kodu request.getRequestURI()daje mi wynik /WEB-INF/somelocation//location/thisPage.jsp. Jeśli poprawię adres URL strony JSP location/thisPage.jsp, wszystko działa dobrze.

Więc moje pytanie brzmi, czy powinienem usunąć /ze JSPścieżki w kodzie, ponieważ jest to wymagane w przyszłości. Albo Springwprowadził błąd, ponieważ jedyną różnicą między moją maszyną a środowiskiem testowym jest protokół HTTPa 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
źródło
1
Planowane jest rozwiązanie problemu w 5.1.0; Obecnie 5.0.0 nie ma tego problemu.
java_dude

Odpowiedzi:

67

Spring Security Documentation podaje przyczynę zablokowania // w żądaniu.

Na przykład może zawierać sekwencje przechodzenia przez ścieżkę (takie jak /../) lub wiele ukośników (//), które mogą również powodować niepowodzenie dopasowywania wzorców. Niektóre kontenery normalizują je przed wykonaniem mapowania serwletów, ale inne nie. Aby chronić się przed takimi problemami, FilterChainProxy używa strategii HttpFirewall do sprawdzania i zawijania żądania. Niestandardowe żądania są domyślnie automatycznie odrzucane, a parametry ścieżki i zduplikowane ukośniki są usuwane w celu dopasowania.

Istnieją więc dwa możliwe rozwiązania -

  1. usuń podwójny ukośnik (preferowane podejście)
  2. Zezwól // w Spring Security, dostosowując StrictHttpFirewall za pomocą poniższego kodu.

Krok 1 Utwórz niestandardową zaporę sieciową, która umożliwia ukośnik w adresie URL.

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

Krok 2 A następnie skonfiguruj ten bean w zabezpieczeniach internetowych

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

Krok 2 jest krokiem opcjonalnym, Spring Boot potrzebuje tylko komponentu bean, aby zadeklarować typ HttpFirewall

Żyrandol Munish
źródło
Tak, wprowadzono zabezpieczenia przy przemierzaniu ścieżek. To nowa funkcja, która mogła spowodować problem. Którego nie jestem zbyt pewien, ponieważ widzisz, działa na HTTPS, a nie na HTTP. Wolałbym poczekać, aż ten błąd zostanie rozwiązany jira.spring.io/browse/SPR-16419
java_dude
bardzo możliwe, że część naszego problemu ... ale ... użytkownik nie wpisuje //, więc próbuję dowiedzieć się, w jaki sposób ta druga / jest dodawana w pierwszej kolejności ... jeśli wiosna generuje nasze jstl url, nie powinien tego dodawać ani normalizować po dodaniu.
xenoterracide
4
To faktycznie nie rozwiązuje rozwiązania, przynajmniej w przypadku Spring Security 5.1.1. Musisz użyć DefaultHttpFirewall, jeśli potrzebujesz adresów URL z dwoma ukośnikami, takimi jak a / b // c. Metody isNormalized nie można skonfigurować ani zastąpić w StrictHttpFirewall.
Jason Winnebeck
Jest jakaś szansa, że ​​ktoś mógłby dać wskazówki, jak to zrobić wiosną w pojedynkę, w przeciwieństwie do Boota?
szkun
28

setAllowUrlEncodedSlash(true)nie działa dla mnie. Nadal wewnętrzna metoda isNormalizedpowraca falsepo podwójnym ukośniku.

Wymieniłem StrictHttpFirewallz DefaultHttpFirewallmając tylko następujący kod:

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

U mnie działa dobrze.
Jakiekolwiek ryzyko związane z używaniem DefaultHttpFirewall?

maor chetrit
źródło
1
Tak. To, że nie możesz stworzyć zapasowego klucza dla swojego współlokatora, nie oznacza, że ​​powinieneś umieścić jedyny klucz pod wycieraczką. Nie zalecane. Nie należy zmieniać zabezpieczeń.
java_dude
16
@java_dude Świetnie, że nie podałeś żadnych informacji ani uzasadnienia, a jedynie niejasną analogię.
kaqqao
Inną opcją jest podklasa, StrictHttpFirewallaby dać nieco większą kontrolę nad odrzucaniem adresów URL, jak szczegółowo opisano w tej odpowiedzi .
vallismortis
1
To zadziałało dla mnie, ale musiałem też dodać to w moim pliku XML fasoli:<sec:http-firewall ref="defaultHttpFirewall"/>
Jason Winnebeck
1
Jakie są konsekwencje zastosowania tego rozwiązania?
Felipe Desiderati
10

Napotkałem ten sam problem z:

Wersja Spring Boot = 1.5.10
Wersja Spring Security = 4.2.4


Problem wystąpił na punktach końcowych, w których ModelAndViewnazwa_widoku została zdefiniowana z poprzedzającym ukośnikiem . Przykład:

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

Jeśli usunąłem cięcie, działało dobrze. Przykład:

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

Zrobiłem również kilka testów z RedirectView i wydawało się, że działa z poprzedzającym ukośnikiem.

Torsten Ojaperv
źródło
2
To nie jest rozwiązanie. A co jeśli to był błąd po stronie wiosny. Jeśli ją zmienią, będziesz musiał ponownie cofnąć całą zmianę. Wolałbym poczekać do wersji 5.1, ponieważ oznaczono, że zostanie rozwiązana do tego czasu.
java_dude,
1
Nie, nie musisz cofać zmiany, ponieważ zdefiniowanie nazwy widoku bez poprzedzającego ukośnika działa dobrze w starszych wersjach.
Torsten Ojaperv
Na tym właśnie polega problem. Jeśli zadziałało dobrze i nic nie zmieniłeś, to Spring wprowadził błąd. Ścieżka powinna zawsze zaczynać się od „/”. Sprawdź jakąkolwiek dokumentację wiosenną. Sprawdź je na github.com/spring-projects/spring-security/issues/5007 & github.com/spring-projects/spring-security/issues/5044
java_dude
1
To też mnie ugryzło. Aktualizacja całego ModelAndView bez wiodącego znaku „/” rozwiązała problem
Nathan Perrier
jira.spring.io/browse/SPR-16740 Otworzyłem błąd, ale usunięcie wiodącego / nie było dla mnie poprawką iw większości przypadków zwracamy tylko nazwę widoku jako ciąg (ze sterownika) . Musisz spojrzeć na przekierowanie jako rozwiązanie.
xenoterracide
4

W moim przypadku, zaktualizowany z spring-securiy-web 3.1.3 do 4.2.12, defaultHttpFirewallzostał zmieniony zDefaultHttpFirewall aby StrictHttpFirewalldomyślnie. Po prostu zdefiniuj to w konfiguracji XML, jak poniżej:

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

ustaw HTTPFirewalljakoDefaultHttpFirewall

Bingxin
źródło
1
Dodaj opis do swojego kodu wyjaśniający, co się dzieje i dlaczego. To jest dobra praktyka. Jeśli tego nie zrobisz, Twoja odpowiedź może zostać usunięta. Został już oznaczony jako niskiej jakości.
herrbischoff
3

Poniższe rozwiązanie to czyste obejście, które nie zagraża bezpieczeństwu, ponieważ używamy tego samego ścisłego firewalla.

Kroki do mocowania są następujące:

KROK 1: Utwórz klasę przesłaniającą StrictHttpFirewall jak poniżej.

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

}

KROK 2: Utwórz klasę 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();
    }
}

KROK 3: Utwórz niestandardowy filtr, aby pominąć odrzucony wyjątek

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

KROK 4: Dodaj niestandardowy filtr do łańcucha filtrów sprężynowych w konfiguracji bezpieczeństwa

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

Teraz korzystając z powyższej poprawki, możemy sobie poradzić RequestRejectedExceptionze stroną błędu 404.

Venkatraman Muthukrishnan
źródło
Dziękuję Ci. To jest podejście, które zastosowałem tymczasowo, aby umożliwić nam aktualizację naszej mikrousługi Java do czasu aktualizacji wszystkich aplikacji front-end. Nie potrzebowałem kroku 3 i 4, aby pomyślnie zezwolić na uznanie znaku „//” za znormalizowany. Właśnie zakomentowałem warunek, który sprawdzał pod kątem podwójnego ukośnika w isNormalized, a następnie skonfigurowałem fasolę, aby zamiast tego używała klasy CustomStrictHttpFirewall.
gtaborga
Czy istnieje łatwiejsze obejście problemu za pomocą konfiguracji? Ale bez wyłączania zapory ..
Prathamesh dhanawade
0

W moim przypadku problem był spowodowany niezalogowaniem się do Postmana, więc otworzyłem połączenie na innej karcie z sesyjnym plikiem cookie, który wziąłem z nagłówków mojej sesji Chrome.

Alex D.
źródło