본문 바로가기

IT

Valve(ErrorReportVavle)를 이용한 내장 톰캣 스프링 톰캣 버전 감추기

728x90

이슈는 간단했지만 해결책은 결국 돌고 돌았던 내용을 정리해보려고 한다. 

(최종 코드는 맨 하단을 참고)

버전 노출

일반적인 url path에 대한 400이나 404 등의 에러 핸들링은 성공적으로 진행되었고 문제가 없었다. 

ErrorController, @ExceptionHandler을 적절히 사용하여 커스텀하게 에러를 핸들링하고 있던 도중 url에 특수문자가 들어간 경우 톰

캣의 버전이 노출된다는 요청이 있었고 보안상 막을 필요가 있었다.

 

첫번째로 의문이 들었다. 왜 내가 만든 ErrorController를 타지 않는 것이지? 디버깅을 해볼 필요도 없었다. 요청에 대한 트레이싱 정보가 아예 애플리케이션에 전달 조차 되지 않았다. 내가 생각한 것보다 더 앞단에서 막는 것 같다는 생각이 들었고 이것저것 찾아보기 시작했다.

스프링 구조

위의 그림은 일반적인 요청(Request)과 응답(Response)에 대한 스프링의 구조인데 스프링 개발자들에게는 많이 익숙할 것이다. 

문제는 path가 "/%"," /[", "/]" 등의 요청은 아예 필터자체에 도달하지 못하기 때문에 내가 에러를 커스텀하게 핸들링해도 이미 톰캣이 앞단에서 처리해버리고 응답을 보낸 것이다. 

톰캣 8.xx 버전부터는 RFC 7230, RFC 3986에 의해 특수 문자를 받지 않는다고 한다.

  

valve

대충 이런 그림으로 이해하면 될 듯하다. 

Catalina Container에서 Web Application에 요청이 가기전 Valve라는 파이프라인을 거치며 전처리 작업이 이루어지게 되는데 여기서 걸러지는 듯 했다. 

특수문자에 해당하는 처리 자체는 CoyoteAdapter 클래스에서 하드 코딩으로 박혀있는 듯하다.

 

  1. Valve:
    • Valve는 Tomcat의 파이프라인 구조에서 요청 처리의 일부를 담당하는 구성 요소입니다.
    • 파이프라인에 여러 개의 Valve가 연결되어 있으며, 각 Valve는 특정한 역할을 수행합니다.
    • 요청이 서블릿 컨테이너에 도달하기 전, 혹은 응답이 클라이언트에게 보내지기 전에 Valve에서 지정된 로직이 수행됩니다.
    • 예를 들어, 로깅, 보안, 인증 등의 기능을 추가할 수 있습니다.
  2. CoyoteAdapter:
    • CoyoteAdapter는 서블릿 컨테이너 내부에서 요청을 처리하는 역할을 합니다.
    • 클라이언트로부터 들어오는 HTTP 요청을 받아들이고, 서블릿 컨테이너의 Servlet 엔진으로 전달하여 실제로 애플리케이션 코드를 실행하게 합니다.
    • 이 단계에서 서블릿 필터, 서블릿, 리스너 등이 동작하게 됩니다.

ChatGPT에게 정의를 물어보니 그렇다고 한다. 그렇다면 대충 요청에 대한 처리 흐름은 

클라이언트 → Connector → Valve (전처리 단계) → CoyoteAdapter → Servlet Container (서블릿, 필터 ) → Valve (후처리 단계) → Connector → 클라이언트 로 보면 될 듯하다.

 

그래서 이제 이걸 어떻게 처리하냐가 문제인데, 일단 톰캣이 해당 특수문자를 받을 수 있다면 어차피 ErrorController를 타지 않을까 생각했다. 보안상 막아둔 것인데 이걸 통과시키는게 맞을까 싶었지만 시도해보았다.

 

package com.tree.baobab.core.config;

import lombok.extern.log4j.Log4j2;
import org.apache.catalina.Context;
import org.apache.catalina.Pipeline;
import org.apache.catalina.Valve;
import org.apache.catalina.connector.Connector;
import org.apache.coyote.http11.AbstractHttp11Protocol;
import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

@Component
@Log4j2
public class TomcatCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {

    @Bean
    public TomcatServletWebServerFactory tomcatFactory() {
        return new TomcatServletWebServerFactory() {
            @Override
            protected void postProcessContext(Context context) {
                //((StandardJarScanner) context.getJarScanner()).setScanManifest(false);
            }
        };
    }


    @Override
    public void customize(TomcatServletWebServerFactory factory) {
    
        factory.addConnectorCustomizers(connector -> {
            if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol) {
                AbstractHttp11Protocol<?> protocolHandler = (AbstractHttp11Protocol<?>) connector
                        .getProtocolHandler();
                protocolHandler.setKeepAliveTimeout(80000);
                protocolHandler.setMaxKeepAliveRequests(50);
                protocolHandler.setUseKeepAliveResponseHeader(true);
            }
            connector.setProperty("relaxedQueryChars", "<>[\\]^`{|}%");
        });

    }

}

 

결과는 반은 성공이지만 반은 실패였다. "/]", "/[" 등의 요청은 예상대로 잘 흘러갔다. 예상대로 스프링이 던지는 예외처리를 내보내는 것을 확인했다.

애플리케이션 처리 완료

하지만 문제는 "/%", "/%1 ~/%9" 까지의 path는 아직도 톰캣 앞단에서 막고 있었다.

버전 노출

"/^" -> "/%5E" 

"/`" -> "/%60" 

이렇게 문자가 인코딩되어 들어가는데 %는 그대로 전달되고 CoyoteAdapter에서 의도적으로 아예 막아 버린듯하다.

 

스프링 문서를 참고해보니 %는 아예 허용 목록에도 포함되어 있지 않은 듯하다.

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#application-properties.server.server.tomcat.relaxed-query-chars

 

허용 문자

/% 에 대한 요청은 LB나 nginx에서 바꿔줘야하나... 싶었지만 애플리케이션 레벨에서 처리하는게 맞지 않을까 싶다.

 

다른 회사들은 어떻게 처리했을까 궁금하다. WAR+톰캣의 경우는 톰캣 설정 관련된 파일 하나만 따로 수정하여 간단히 처리하는 듯 했지만 현재 환경은 JAR로 내장형 톰캣을 사용하기에 좀 번거로운 듯했다.

 

WAR + 톰캣의 경우에는 다양한 우리나라 블로그들이 많았다. 

간략히 설명하자면 /etc/tomcat7/server.xml 파일에 Error 페이지에 대한 전처리 설정을 위해 Valve를 추가하는 듯 했다.

<Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false"/>

이게 가장 큰 힌트가 되었다. ErrorReportValve라는 걸 알게 된 순간 내가 만든 에러페이지를 뱉을 수 있다는 자신감이 생겼다. 

 

public class CustomErrorReportValve extends ErrorReportValve {
    @Override
    protected void report(Request request, Response response, Throwable throwable) {
        if  (!response.setErrorReported())
            return;
        try {
            Writer writer = response.getReporter();
            writer.write(Integer.toString(response.getStatus()));
            writer.write(" Fatal error.  Could not process request.");
            response.finishResponse();
        } catch (IOException e) {
        }
    }
}
    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        //factory.addEngineValves(new CustomErrorReportValve());
        factory.addContextValves(new CustomErrorReportValve());

        factory.addConnectorCustomizers(connector -> {
            if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol) {
                AbstractHttp11Protocol<?> protocolHandler = (AbstractHttp11Protocol<?>) connector
                        .getProtocolHandler();
                protocolHandler.setKeepAliveTimeout(80000);
                protocolHandler.setMaxKeepAliveRequests(50);
                protocolHandler.setUseKeepAliveResponseHeader(true);
                protocolHandler.setRelaxedPathChars("<>[\\\\]^`{|}");
                protocolHandler.setRelaxedQueryChars("<>[\\\\]^`{|}");
            }
            //connector.setProperty("relaxedQueryChars", "<>[\\]^`{|}%");
        });

    }

}

기존 Valve에 아무리 끼어넣어도 적용이 안됐다. invoke() 메소드를 재구현한 이후 Request를 찍어봐도 요청이 닿지 않았다. ErrorReportValve 말고도 Valve, ValveBase 등의 인터페이스도 열려있어서 아예 리다이렉트를 해준다면? 생각도 해봤다.  

public class CustomErrorReportValve extends ValveBase {
    @Override
    public void invoke(Request request, Response response) throws IOException, ServletException {
     log.info(request.getRequestURI());   
     getNext().invoke(request,response);
    }
}

로그만 찍어보니 "/%"에 대한 요청이 닿지 않았다. 아마 여러 Valve중 앞단에서 이미 끊긴거라고 예상이 들었다.

 

모든 Valve의 리스트를 받아와서 커스텀한 Valve를 맨 앞에 설정하면 어떻게든 적용되지 않을까? 

    // 기존의 Valve를 가져옴
    List<Valve> existingValves = new ArrayList<>(factory.getEngineValves());

    // CustomErrorReportValve 추가
    factory.addContextValves(new CustomErrorReportValve());

    // 기존 Valve 리스트에 추가된 CustomErrorReportValve 찾아서 순서를 변경
    existingValves.removeIf(valve -> valve instanceof CustomErrorReportValve);
    existingValves.add(0, new CustomErrorReportValve());

    // 변경된 Valve 리스트를 다시 설정
    factory.setEngineValves(existingValves);

아니나 다를까 원하는 결과를 얻지 못했고 무언가 점점 이상한 길로 빠진다는 생각도 들었다. 통용되는 방법은 아닌듯했고 스프링도 원치 않을 것이다.

 

다시 작업한 내용을 돌이켜보며 놓친 부분이 무엇이 있을까 확인해봤다. 

ErrorReportValve를 재구현한 CustomErrorReportValve를 적용하는 방법이 이상한걸 그제서야 눈치챘다. 저렇게 일반적으로 추가하면 톰캣이 내가 만든 CustomErrorReportValve를 ErrorReportValve로 적용하게끔 설정이 안되는 것 같았다. WAR + 톰캣의 경우에는 간단히 태그로 추가해서 필자도 당연스럽게 넘겼던 것 같다.

 

구글링 결과 깃헙 이슈에 등록된 동일한 케이스를 보고 해결할 수 있었다. 

Allow custom ErrorReportValve to be used with Tomcat and provide whitelabel version #21257

https://github.com/spring-projects/spring-boot/issues/21257

(ClaudioConsolmagno 님 감사드립니다 ...) 

 

@Component
public class TomcatCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {

    @Bean
    public TomcatServletWebServerFactory tomcatFactory() {
        return new TomcatServletWebServerFactory() {
            @Override
            protected void postProcessContext(Context context) {
                //((StandardJarScanner) context.getJarScanner()).setScanManifest(false);
            }
        };
    }
    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        factory.addContextCustomizers((context) -> {
            if (context.getParent() instanceof StandardHost parent) {
                parent.setErrorReportValveClass(CustomErrorReportValve.class.getName());
                parent.addValve(new CustomErrorReportValve());
            }
        });

        factory.addConnectorCustomizers(connector -> {
            if (connector.getProtocolHandler() instanceof AbstractHttp11Protocol) {
                AbstractHttp11Protocol<?> protocolHandler = (AbstractHttp11Protocol<?>) connector
                        .getProtocolHandler();
                protocolHandler.setKeepAliveTimeout(80000);
                protocolHandler.setMaxKeepAliveRequests(50);
                protocolHandler.setUseKeepAliveResponseHeader(true);
                protocolHandler.setRelaxedPathChars("<>[\\\\]^`{|}");
                protocolHandler.setRelaxedQueryChars("<>[\\\\]^`{|}");
            }
        });

    }

}

정상 적용

/**
 * Get the parent container.
 *
 * @return Return the Container for which this Container is a child, if
 *         there is one. If there is no defined parent, return
 *         <code>null</code>.
 */
Container getParent();

context.getParent() ->  Container에 setErrorReportValveClass라는 메서드를 지원해주기에 내가 만든 CustomErrorReportValve를 추가할 수 있었고 의도한대로 정상 적용된 것을 확인할 수 있었다. 

 

이번에도 느꼈지만 스프링은 정말 모든 인터페이스에 대해 열어주는것 같다. Default 클래스와 사용자가 재정의한 클래스가 어떻게 우선순위로 적용되는지 궁금해지기도 했다. 처음부터 List로 만들어놓고 추상화에 의존하도록 설계하고 런타임시에 체크해서 껴놓지 않았을까,,,