Spring

스프링부트 - CORS / CSRF

코린이 파닥거리기 2025. 5. 15. 01:44
728x90
반응형
SMALL

CORS(Cross-Origin Resource Sharing)란?

교차 출처간 리소스를 공유하는 것이다.

다른 출처 간에 웹 페이지의 자원을 안전하게 공유할 수 있도록 하는 기술이다.

Origin은 URL브라우저에 쓰여있는 주소이다.

즉, 프론트와 백의 도메인 또는 포트가 다를 때, 브라우저가 요청을 막는 보안 기능이라고 보면 된다.


SOP(Same-Origin Policy )란?

웹 브라우저는 보안상의 이유로, 한 출처에서 로드된 문서나 스크립트가 다른 출처의 리소스와 상호작용하는 것을

제한한다. 

예로, example.com에서 로드된 웹 페이지의 JS는 기본적으로 api.otherserver.com의 데이터를 AJAX요청으로 

가져올 수 없기때문이다. 

SOP의 존재이유?

SOP는 악의적인 웹 사이트가 사용자의 동의 없이 다른 웹 페이지의 민감한 데이터에 접근하는 것을 막는 

중요한 보안 장치이기 때문이다.


이 CORS는 단일서버 즉 스프링만 사용하면 알 필요가 없지만

분산 서버 즉 두개 이상의 서버를 사용할 때 반드시 알아야하는 개념이다.

뭐 동작은 이렇게 된다고 보면된다.

 

스프링에서 CORS 허용 방법 3가지

  1. 컨트롤러에서 직접 하나씩 설정
  2. WebMvcConfigurer에서 전역 설정해주기
  3. 스프링 시큐리티에서 전역 설정(가장 강추하는 허용방법)

보통은 시큐리티 CORS를 모두 허용 + 전역 CORS 또는 각 컨트롤러 CORS를 제한한다.

※컨트롤러 cors는 해당 URL 요청에 대해서만 제한함


 

그럼 예시를 들어서 설명해보겠다.

package com.example.security.controller;

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class ApiController {
    // origins속성을 줘서 어떠한 곳에서만 허용을 해주겠다.
    // @CrossOrigin(origins = "http://127.0.0.1:5500")
    @PostMapping("/message")
    public String message() {
        return "서버에서 보낸 메시지입니다.";
    }
}

localhost:8080의 포트 서버가 실행되고 있고

 

<body>
    <button onclick="send()">요청</button>
    <script>
        const send = () => {
            fetch("http://localhost:8080/api/message", {
                method: "post"
            })
                .then(res => res.text())
                .then(data => console.log(data));
        }
    </script>
</body>

localhost:5500의 라이브 서버가 실행되고 있다.

 

여기서 요청 버튼을 누르면

해당 개발자 도구 콘솔에 Failed to fetch오류가 뜨는 것을 볼 수가 있다.

 

그럼 이 문제를 해결하기 위해서는?

    @CrossOrigin(origins = "http://127.0.0.1:5500")
    @PostMapping("/message")
    public String message() {
        return "서버에서 보낸 메시지입니다.";
    }

POST매핑되는 메서드 위에 5500번 포트에 대해서 CORS허용을 해주는 것이다.

그럼 위와같이 서버에서 보낸 메세지입니다. 가 콘솔창에 뜨게 된다.

 

전역 설정 모든 요청 허용하는 CORS

package com.example.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig {
    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @SuppressWarnings("null")
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                        .allowedOrigins("http://127.0.0.1:5500")
                        .allowedMethods("GET", "POST", "DELETE", "OPTION")
                        .allowCredentials(true);
            }
        };
    }
}

이렇게 해당 WebMvcConfigurer에서 전역 설정을 해준다.

 

스프링 시큐리티에서 CORS 전역설정 해주기

http.cors(cors -> cors
                .configurationSource(request -> {
                    CorsConfiguration config = new CorsConfiguration();
                    config.setAllowedOrigins(List.of("http://localhost:5500"));
                    config.setAllowedMethods(List.of("GET", "POST"));
                    config.setAllowCredentials(true);
                    config.setAllowedHeaders(List.of("*"));
                    return config;
                }));

다음과 같이 설정을 해주면 전역 설정이 되는것이다.


CSRF란?

사이트간 요청 위조 공격방식인데

사용자의 의도와 무관하게 인증된 사용자의 권한으로 요청이 전송되게 만드는 공격이다.

즉, 로그인한 사용자의 권한으로 요청이 전송되게 만드는 공격이다.


CSRF 공격 차단 방법

  1. CSRF 토큰 사용하기
  2. Referer 또는 ORigin 헤더값 확인하기 
  3. SamSite쿠키 설정하기 
    쿠키의 SamSite 속성을 변경해서 외부 도메인에서의 요청 시 쿠키가 전송되지 않도록 설정

CRRF 토큰 사용하기

흐름은 이렇다고 보면 된다.

일단 GET 방식으로 서버에 요청을 보내면

  • 시큐리티가 토큰을 자동으로 발행해서 전송을 해준다.
  • 그 후 Post방식으로 요청을 보낼 때 token을 쿠키에 담아서 같이 보내준다.

클라이언트에서 POST 방식으로 요청을 보낼 때 CSRF토큰이 없으면 시큐리티가 차단을 시킨다.

그러므로 POST + CSRF토큰이 있어야 시큐리티가 허용을 해준다.

그래서 다른 서버에서 CSRF공격하고싶을 때 위조 사이트가 요청 시 사용자가 CSRF토큰을 보내지 않으면

요청 자체를 차단해준다.

※시큐리티 기본 값: CSRF 토큰 없이는 요청 불가(CSRF 방어가 설정되어있다.)

 

왜 GET에서 대부분 CSRF안해주고 POST에만 해주는 것일까?

기본적인 HTTP 요청에서 GET은 글 조회, 글 상세보기 같은 조회하는 역할만 하는것이고

POST는 DB값을 변경시킬때 글쓰기, 이체, 좋아요, 댓글 삭제 등과 같이 POST방식으로 동작하는 업무들에 대해서

CSRF를 적용해주는 것이다.

-> 피해를 직접적으로 볼 수 있는 것들에 대해서

 

그럼 이 방법에 대해서 예시로 이해를 해보자

우선 4개의 파일이 필요하다.

  • SubmitController.java (POST 요청 처리)
  • csrf-test.html(form과 fetch로 요청을 보내는 HTML)
  • WebConfig.java(CSRF 토큰 전달 시 필요한 설정)
  • SecurityConfig.java(시큐리티 설정)

SubmitController

package com.example.security.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

@Controller
public class SubmitController {
    @GetMapping("/csrf-test")
    public String testPage() {
        return "csrf-test"; // resources/templates/csrf-test.html
    }

    @PostMapping("/submit")
    @ResponseBody
    public String submit() {
        return "POST 요청 성공!";
    }
}

 

csrf-test.html

<head>
    <meta charset="UTF-8">
    <meta name="_csrf" th:content="${_csrf.token}">
    <meta name="_csrf_header" th:content="${_csrf.headerName}">
    <title>CSRF 테스트</title>
</head>

<body>
    <h2>Form 전송 (HTML)</h2>
    <form th:action="@{/submit}" method="post">
        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
        <button type="submit">폼으로 전송</button>
    </form>
    <h2>Fetch 전송 (JS)</h2>
    <button onclick="sendFetch()">fetch로 전송</button>
    <script>
        function sendFetch() {
            const token = document.querySelector('meta[name="_csrf"]').content;
            const header = document.querySelector('meta[name="_csrf_header"]').content;
            fetch("/submit", {
                method: "POST",
                headers: {
                    [header]: token,
                    "Content-Type": "application/json"
                },
                body: JSON.stringify({ test: "hello" })
            }).then(res => res.text()).then(console.log)
                .catch(err => console.error("에러:", err));
        }
    </script>
</body>

 

SecurityConfig

package com.example.security.config;

import java.util.List;

import com.example.security.controller.ApiController;
import com.example.security.filter.JwtAuthFilter;
import com.example.security.util.JwtUtil;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;

@Configuration
public class SecurityConfig {

    private final ApiController apiController;

    SecurityConfig(ApiController apiController) {
        this.apiController = apiController;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, JwtUtil jwtUtil, UserDetailsService userDetailsService)
            throws Exception {
        http
                // .csrf(csrf -> {
                // csrf.disable(); //csrf 공격이 들어오더라도 통과시켜주겠다.

                // })
                .authorizeHttpRequests(auth -> auth
                        // 먼저 사용한 것이 우선순위가 높다.
                        .requestMatchers("/admin").authenticated()
                        .requestMatchers("/my-page").authenticated()
                        // 권한별 접속페이지를 따로 구성을 한것임
                        .requestMatchers("/admin-page").hasRole("ADMIN")// hasRole이 authenticated보다 강력하다.
                        .requestMatchers("/user-page").hasRole("USER")
                        .requestMatchers("/jwt/login").permitAll()
                        .requestMatchers("/jwt/protected").authenticated()
                        .anyRequest().permitAll())
                // 로그인하는 주소는 모두 허용해야됨
                // 커스텀 필터 등록
                // Spring security필터 보다 우리의 커스텀 필터가 먼저 등록되어라
                // UsernamePasswordAuthenticationFilter POST요청 처리
                .addFilterBefore(new JwtAuthFilter(jwtUtil, userDetailsService),
                        UsernamePasswordAuthenticationFilter.class)
                .csrf(csrf -> csrf.disable())
                // 이런것들은 나중에 비활성화 시킴
                // .formLogin(form -> form
                // .loginPage("/login") // 아직 없지만 나중에 만들 예정
                // .defaultSuccessUrl("/hello", true)
                // .permitAll())
                .formLogin(form -> form
                        .loginPage("/login") // 우리가 만든 로그인 페이지
                        .loginProcessingUrl("/login") // form의 action과 동일
                        .defaultSuccessUrl("/hello", true) // 성공 시 리디렉션
                        .failureUrl("/login?error=true") // 실패 시 리디렉션
                        .permitAll())
                .logout(logout -> logout
                        .logoutUrl("/logout") // default: /logout (POST)
                        .logoutSuccessUrl("/login?logout=true")
                        .invalidateHttpSession(true) // 세션 제거
                        .deleteCookies("JSESSIONID") // 쿠키 제거

                )
                .rememberMe(r -> r
                        .key("remember-me-key") // 서버 비밀키
                        .rememberMeParameter("remember-me") // form 체크박스 name
                        .tokenValiditySeconds(60 * 60 * 24 * 7) // 7일
                );
        http.cors(cors -> cors
                .configurationSource(request -> {
                    CorsConfiguration config = new CorsConfiguration();
                    config.setAllowedOrigins(List.of("http://localhost:5500"));
                    config.setAllowedMethods(List.of("GET", "POST"));
                    config.setAllowCredentials(true);
                    config.setAllowedHeaders(List.of("*"));
                    return config;
                }));

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        return http.getSharedObject(AuthenticationManagerBuilder.class).build();
    }

}

CSRF허용 기능을 꺼주고 

csrf기능을 테스트 해보겠다.

위 버튼 두개를 클릭하면 form전송도 가능하고, fetch도 가능해진다.

 

그럼 포트가 다른경우는 어떻게 봐야되나

 

리액트                      스프링부트

Posrt  3000                 8080

  1. cors문제 해결해줘야됨
  2.  시큐리티 사용여부를 따져줘야된다.
    ->  CSRF 문제를 생각해야기 때문 - POST요청은 필수적이기 때문에

시큐리티 사용 x + 리액트 사용 x 

--> CORS와 CSRF는 상관이 없다.

 

시큐리티 사용 x + 리액트 사용 O

--> CORS / CSRF 상관이 있다.


아 뭔가 CORS 랑 CSRF랑 짬뽕이 돼서 글 쓰는 내내 이게 맞나 싶다.. 생각된다..

728x90
반응형
LIST