Spring Security - 9. CorsFilter

하쮸·2025년 1월 31일

1. CORS.

  • CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)
    • 웹 브라우저에서 보안 정책(동일 출처 정책, SOP)을 우회해서 다른 도메인의 리소스에 접근할 수 있도록 허용하는 방식.
    • 브라우저는 보안적인 이유로 cross-origin HTTP 요청들을 제한함.
      • 웹 보안 정책 중 하나인 SOP(Same-Origin Policy, 동일 출처 정책) 때문에 브라우저는 기본적으로 다른 출처(도메인, 포트, 프로토콜)의 리소스 요청을 차단함.
    • cross-origin요청을 하려면 서버의 동의가 필요함.
    • SOP(Same-Origin Policy, 동일 출처 정책)
      • 클라이언트(브라우저)가 서버에 요청을 보낼 때, 출처(origin)가 다르면 요청을 차단하는 보안 정책.
      • 출처(origin) = 프로토콜 + 도메인 + 포트
        • 프로토콜(Protocol)
          • http와 https는 프로토콜이 다름.
        • 도메인(Hostname)
          • abcd.com과 dcba.com은 다름.
        • 포트(Port)
          • 8080포트와 3000포트는 다름.
      • 위 세 가지 중 하나라도 다른 경우를 요청이 차단됨.
  • 위처럼 허락을 하거나 거절하는 메커니즘의 경우 HTTP-Header를 이용하면 가능한데 이것을 CORS(Cross-Origin Resource Sharing)라고 함.
  • CORS 기본 동작.
    • 클라이언트에서 HTTP요청의 헤더에 Origin을 담아 전달.
      • 기본적으로 웹은 HTTP 프로토콜을 이용하여 서버에 요청을 보내는데 이때 브라우저는 요청 헤더에 Origin 이라는 필드에 출처를 담아서 보냄.
    • 서버는 응답 헤더에 Access-Control-Allow-Origin을 담아 클라이언트로 반환함.
    • 클라이언트에서 서버가 보내준 Access-Control-Allow-Origin과 기존 Origin을 비교해서 검증에 실패하면 CORS 에러가 발생하고, 위 이미지와 같이 Origin:http://localhost:3000으로 동일할 경우 cross-origin의 리소스를 문제없이 가져올 수 있게됨.
  • CORS가 필요한 이유.
    • CORS 없이 모든 곳에서 데이터를 요청할 수 있게 되면 다른 사이트에서 기존 사이트를 흉내낼 수 있음.
    • Ex) 기존 사이트와 완전히 동일하게 동작하도록 만들어서 사용자가 로그인을 하도록 만들고, 로그인했던 세션을 탈취하여 악의적으로 정보를 추출하거나 다른 사람의 정보를 입력하는 등 공격을 할 수 있음.
  • 즉 공격을 할 수 없도록 브라우저에서 보호하고, 필요한 경우 에만 서버와 협의하여 요청할 수 있도록 하기 위해서 필요함.

2. CorsFilter.

  • CORS
  • CorsFilter
    • CORS 사전 요청(Pre-flight request)을 처리하고, CORS 단순(Simple request) 및 실제(Actual request)요청을 가로채서 CorsProcessor를 사용하여 처리하는 필터.
    • 또한, CorsConfigurationSource에서 설정한 정책(코드)을 기반으로 CORS 관련 응답 헤더를 추가하는 등의 작업을 수행할 수 있음.

CorsFilter

package org.apache.catalina.filters;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import jakarta.servlet.FilterChain;
import jakarta.servlet.GenericFilter;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.apache.catalina.Globals;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.http.RequestUtil;
import org.apache.tomcat.util.http.ResponseUtil;
import org.apache.tomcat.util.http.parser.MediaType;
import org.apache.tomcat.util.res.StringManager;

/**
 * <p>
 * A {@link jakarta.servlet.Filter} that enable client-side cross-origin requests by implementing W3C's CORS
 * (<b>C</b>ross-<b>O</b>rigin <b>R</b>esource <b>S</b>haring) specification for resources. Each
 * {@link HttpServletRequest} request is inspected as per specification, and appropriate response headers are added to
 * {@link HttpServletResponse}.
 * </p>
 * <p>
 * By default, it also sets following request attributes, that help to determine the nature of the request downstream.
 * </p>
 * <ul>
 * <li><b>cors.isCorsRequest:</b> Flag to determine if the request is a CORS request. Set to <code>true</code> if a CORS
 * request; <code>false</code> otherwise.</li>
 * <li><b>cors.request.origin:</b> The Origin URL, i.e. the URL of the page from where the request is originated.</li>
 * <li><b>cors.request.type:</b> Type of request. Possible values:
 * <ul>
 * <li>SIMPLE: A request which is not preceded by a pre-flight request.</li>
 * <li>ACTUAL: A request which is preceded by a pre-flight request.</li>
 * <li>PRE_FLIGHT: A pre-flight request.</li>
 * <li>NOT_CORS: A normal same-origin request.</li>
 * <li>INVALID_CORS: A cross-origin request which is invalid.</li>
 * </ul>
 * </li>
 * <li><b>cors.request.headers:</b> Request headers sent as 'Access-Control-Request-Headers' header, for pre-flight
 * request.</li>
 * </ul>
 * If you extend this class and override one or more of the getXxx() methods, consider whether you also need to override
 * {@link CorsFilter#doFilter(ServletRequest, ServletResponse, FilterChain)} and add appropriate locking so that the
 * {@code doFilter()} method executes with a consistent configuration.
 *
 * @see <a href="http://www.w3.org/TR/cors/">CORS specification</a>
 */
public class CorsFilter extends GenericFilter {

    private static final long serialVersionUID = 1L;
    private static final StringManager sm = StringManager.getManager(CorsFilter.class);

    private transient Log log = LogFactory.getLog(CorsFilter.class); // must not be static


    /**
     * A {@link Collection} of origins consisting of zero or more origins that are allowed access to the resource.
     */
    private final Collection<String> allowedOrigins = new HashSet<>();

    /**
     * Determines if any origin is allowed to make request.
     */
    private boolean anyOriginAllowed;

    /**
     * A {@link Collection} of methods consisting of zero or more methods that are supported by the resource.
     */
    private final Collection<String> allowedHttpMethods = new HashSet<>();

    /**
     * A {@link Collection} of headers consisting of zero or more header field names that are supported by the resource.
     */
    private final Collection<String> allowedHttpHeaders = new HashSet<>();

    /**
     * A {@link Collection} of exposed headers consisting of zero or more header field names of headers other than the
     * simple response headers that the resource might use and can be exposed.
     */
    private final Collection<String> exposedHeaders = new HashSet<>();

    /**
     * A supports credentials flag that indicates whether the resource supports user credentials in the request. It is
     * true when the resource does and false otherwise.
     */
    private boolean supportsCredentials;

    /**
     * Indicates (in seconds) how long the results of a pre-flight request can be cached in a pre-flight result cache.
     */
    private long preflightMaxAge;

    /**
     * Determines if the request should be decorated or not.
     */
    private boolean decorateRequest;


    @Override
    public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
            final FilterChain filterChain) throws IOException, ServletException {
        if (!(servletRequest instanceof HttpServletRequest) || !(servletResponse instanceof HttpServletResponse)) {
            throw new ServletException(sm.getString("corsFilter.onlyHttp"));
        }

        // Safe to downcast at this point.
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        // Determines the CORS request type.
        CorsFilter.CORSRequestType requestType = checkRequestType(request);

        // Adds CORS specific attributes to request.
        if (isDecorateRequest()) {
            decorateCORSProperties(request, requestType);
        }
        switch (requestType) {
            case SIMPLE:
                // Handles a Simple CORS request.
            case ACTUAL:
                // Handles an Actual CORS request.
                this.handleSimpleCORS(request, response, filterChain);
                break;
            case PRE_FLIGHT:
                // Handles a Pre-flight CORS request.
                this.handlePreflightCORS(request, response, filterChain);
                break;
            case NOT_CORS:
                // Handles a Normal request that is not a cross-origin request.
                this.handleNonCORS(request, response, filterChain);
                break;
            default:
                // Handles a CORS request that violates specification.
                this.handleInvalidCORS(request, response, filterChain);
                break;
        }
    }


    @Override
    public void init() throws ServletException {
        parseAndStore(getInitParameter(PARAM_CORS_ALLOWED_ORIGINS, DEFAULT_ALLOWED_ORIGINS),
                getInitParameter(PARAM_CORS_ALLOWED_METHODS, DEFAULT_ALLOWED_HTTP_METHODS),
                getInitParameter(PARAM_CORS_ALLOWED_HEADERS, DEFAULT_ALLOWED_HTTP_HEADERS),
                getInitParameter(PARAM_CORS_EXPOSED_HEADERS, DEFAULT_EXPOSED_HEADERS),
                getInitParameter(PARAM_CORS_SUPPORT_CREDENTIALS, DEFAULT_SUPPORTS_CREDENTIALS),
                getInitParameter(PARAM_CORS_PREFLIGHT_MAXAGE, DEFAULT_PREFLIGHT_MAXAGE),
                getInitParameter(PARAM_CORS_REQUEST_DECORATE, DEFAULT_DECORATE_REQUEST));
    }
    
    ......
    
    protected CORSRequestType checkRequestType(final HttpServletRequest request) {
        CORSRequestType requestType = CORSRequestType.INVALID_CORS;
        if (request == null) {
            throw new IllegalArgumentException(sm.getString("corsFilter.nullRequest"));
        }
        String originHeader = request.getHeader(REQUEST_HEADER_ORIGIN);
        // Section 6.1.1 and Section 6.2.1
        if (originHeader != null) {
            if (originHeader.isEmpty()) {
                requestType = CORSRequestType.INVALID_CORS;
            } else if (!RequestUtil.isValidOrigin(originHeader)) {
                requestType = CORSRequestType.INVALID_CORS;
            } else if (RequestUtil.isSameOrigin(request, originHeader)) {
                return CORSRequestType.NOT_CORS;
            } else {
                String method = request.getMethod();
                if (method != null) {
                    if ("OPTIONS".equals(method)) {
                        String accessControlRequestMethodHeader =
                                request.getHeader(REQUEST_HEADER_ACCESS_CONTROL_REQUEST_METHOD);
                        if (accessControlRequestMethodHeader != null && !accessControlRequestMethodHeader.isEmpty()) {
                            requestType = CORSRequestType.PRE_FLIGHT;
                        } else if (accessControlRequestMethodHeader != null &&
                                accessControlRequestMethodHeader.isEmpty()) {
                            requestType = CORSRequestType.INVALID_CORS;
                        } else {
                            requestType = CORSRequestType.ACTUAL;
                        }
                    } else if ("GET".equals(method) || "HEAD".equals(method)) {
                        requestType = CORSRequestType.SIMPLE;
                    } else if ("POST".equals(method)) {
                        String mediaType = MediaType.parseMediaTypeOnly(request.getContentType());
                        if (mediaType == null) {
                            requestType = CORSRequestType.SIMPLE;
                        } else {
                            if (SIMPLE_HTTP_REQUEST_CONTENT_TYPE_VALUES.contains(mediaType)) {
                                requestType = CORSRequestType.SIMPLE;
                            } else {
                                requestType = CORSRequestType.ACTUAL;
                            }
                        }
                    } else {
                        requestType = CORSRequestType.ACTUAL;
                    }
                }
            }
        } else {
            requestType = CORSRequestType.NOT_CORS;
        }

        return requestType;
    }
    
    ....
package jakarta.servlet;

import java.io.Serializable;
import java.util.Enumeration;

/**
 * Provides a base class that implements the Filter and FilterConfig interfaces to reduce boilerplate when writing new
 * filters.
 *
 * @see jakarta.servlet.Filter
 * @see jakarta.servlet.FilterConfig
 *
 * @since Servlet 4.0
 */
public abstract class GenericFilter implements Filter, FilterConfig, Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * The filter configuration.
     */
    private volatile FilterConfig filterConfig;


    @Override
    public String getInitParameter(String name) {
        return getFilterConfig().getInitParameter(name);
    }


    @Override
    public Enumeration<String> getInitParameterNames() {
        return getFilterConfig().getInitParameterNames();
    }


    /**
     * Obtain the FilterConfig used to initialise this Filter instance.
     *
     * @return The config previously passed to the {@link #init(FilterConfig)} method
     */
    public FilterConfig getFilterConfig() {
        return filterConfig;
    }


    @Override
    public ServletContext getServletContext() {
        return getFilterConfig().getServletContext();
    }


    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        this.filterConfig = filterConfig;
        init();
    }


    /**
     * Convenience method for sub-classes to save them having to call <code>super.init(config)</code>. This is a NO-OP
     * by default.
     *
     * @throws ServletException If an exception occurs that interrupts the Filter's normal operation
     */
    public void init() throws ServletException {
        // NO-OP
    }


    @Override
    public String getFilterName() {
        return getFilterConfig().getFilterName();
    }
}
  • 다른 필터들과는 다르게 GenericFilter라는 추상 클래스를 상속 받아 구현되어 있음.
    • 다른 스프링 시큐리티 필터는 GenericFilterBean라는 추상 클래스를 상속 받아 구현.

CorsFilter 클래스 내부에 있는 doFilter() 메서드

@Override
    public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
            final FilterChain filterChain) throws IOException, ServletException {
        if (!(servletRequest instanceof HttpServletRequest) || !(servletResponse instanceof HttpServletResponse)) {
            throw new ServletException(sm.getString("corsFilter.onlyHttp"));
        }

        // Safe to downcast at this point.
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        // Determines the CORS request type.
        CorsFilter.CORSRequestType requestType = checkRequestType(request);

        // Adds CORS specific attributes to request.
        if (isDecorateRequest()) {
            decorateCORSProperties(request, requestType);
        }
        switch (requestType) {
            case SIMPLE:
                // Handles a Simple CORS request.
            case ACTUAL:
                // Handles an Actual CORS request.
                this.handleSimpleCORS(request, response, filterChain);
                break;
            case PRE_FLIGHT:
                // Handles a Pre-flight CORS request.
                this.handlePreflightCORS(request, response, filterChain);
                break;
            case NOT_CORS:
                // Handles a Normal request that is not a cross-origin request.
                this.handleNonCORS(request, response, filterChain);
                break;
            default:
                // Handles a CORS request that violates specification.
                this.handleInvalidCORS(request, response, filterChain);
                break;
        }
    }
  • 매개변수로 받은 servletRequest를 HttpServletRequest로 캐스팅을 한 뒤 CORS 요청 타입을 switch문으로 조건을 비교해서 CORS를 처리함.

2-1. GenericFilter vs GenericFilterBean

  • 공통점.
    • 두 추상 클래스는 Filter 인터페이스를 구현(implements)하고 있음.
  • 차이점.
    • GenericFilter는 스프링의 Bean으로 관리되지않음.
    • GenericFilterBean은 스프링 컨테이너에서 Bean으로 관리됨.

2-2. CorsConfigurationSource 구현 방법.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

	@Bean
	@Order(0)
	public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
		http
			.securityMatcher("/api/**")
			.cors((cors) -> cors
				.configurationSource(apiConfigurationSource())
			)
			...
		return http.build();
	}

	@Bean
	@Order(1)
	public SecurityFilterChain myOtherFilterChain(HttpSecurity http) throws Exception {
		http
			.cors((cors) -> cors
				.configurationSource(myWebsiteConfigurationSource())
			)
			...
		return http.build();
	}

	UrlBasedCorsConfigurationSource apiConfigurationSource() {
		CorsConfiguration configuration = new CorsConfiguration();
		configuration.setAllowedOrigins(Arrays.asList("https://api.example.com"));
		configuration.setAllowedMethods(Arrays.asList("GET","POST"));
		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", configuration);
		return source;
	}

	UrlBasedCorsConfigurationSource myWebsiteConfigurationSource() {
		CorsConfiguration configuration = new CorsConfiguration();
		configuration.setAllowedOrigins(Arrays.asList("https://example.com"));
		configuration.setAllowedMethods(Arrays.asList("GET","POST"));
		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", configuration);
		return source;
	}

}

OR

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		
		http
								.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {

                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

                        CorsConfiguration configuration = new CorsConfiguration();

                        configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
                        configuration.setAllowedMethods(Collections.singletonList("*"));
                        configuration.setAllowCredentials(true);
                        configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setMaxAge(3600L);

                        configuration.setExposedHeaders(Collections.singletonList("Set-Cookie"));
                        configuration.setExposedHeaders(Collections.singletonList("Authorization"));

                        return configuration;
                    }
                }));

    return http.build();
}

3. 참고.

profile
Every cloud has a silver lining.

0개의 댓글