CSRF, XSS를 고려하여 인증/인가 설계하기

CGH96·2023년 3월 2일
1

서론

현재 진행 중인 프로젝트에서 jwt 토큰 기반의 인증/인가를 구현 중이다. 문득, 단순하게 access token을 refresh token으로 갱신해주는 것만으로 안전하다고 할 수 있는지에 대한 의문이 생겼다.

먼저 프론트엔드 쪽에서 진행될 수 있는 공격 기법을 찾아보았다. 가장 대표적이고 많이 나오는 것이 XSS, CSRF였다.


Xss란?

Cross Site Scripting의 약자로 공격자가 웹 사이트에서 스크립트를 삽입하면 브라우저가 해당 스크립트를 실행시키면서 공격이 진행된다.


XSS는 크게 3가지 공격방법으로 나누어진다.

  • Reflected XSS
  • Stored XSS
  • DOM Based XSS

하나하나 차근차근 살펴보자.

1. Reflected XSS 시나리오

위 그림과 같이 이메일로 사용자를 속일 수도 있지만 공격자가 미리 공격 대상 사이트에 스크립트를 삽입하고 사용자가 접속하면 해당 스크립트가 브라우저에 전송되어 실행되게 할 수도 있다.



2. Stored XSS 시나리오

reflected Xss는 스크립트가 서버에 전달되었다가 사용자에게 반사되어서 실행되는 것이 포인트라면 Stored Xss는 스크립트가 삽입된 게시글이 서버에 한 번 저장되었다가 사용자가 그 게시물을 클릭하면 스크립트가 브라우저로 전달되어 실행된다.



3. DOM Based XSS 시나리오

공격 흐름은 reflected XSS와 비슷하다. 다만 사용자가 링크를 클릭하면 그에 대한 응답으로 해당 사이트의 HTML문서가 받아와지고, 이어서 DOM을 조작하는 스크립트가 실행된다는 것이 차이점이다.

예를 들면 공격자의 계좌로 돈을 입금하게 하는 동작 등을 실행시킬 수 있다.



Xss 대응

XSS공격을 방지하려면 html태그와 관련된 값들을 게시물에 입력하지 못하게 하거나 게시물로 입력되더라도 코드가 실행되지 않도록 방지 해야 한다.
이런 것들을 방지하기 위해서 프론트엔드에는 dompurify와 같은 라이브러리들이 잘 되어있다.

react에 html로 이루어진 텍스트를 전달하는 props dangerouslySetInnerHTMLdangerously가 붙는 이유이기도 하다.
또한 중요한 정보들은 httpOnly쿠키에 담아서 js가 쿠키에 접근하지 못하도록 하는 것도 하나의 방법이다.





CSRF란?

Cross Site Request Forgery(사이트 간 요청 위조)의 약자이다.
Xss는 브라우저 내에서 특정 자바스크립트를 실행시키도록 하는 기법이라면, CSRF는 사용자가 특정 행위를 하도록 유도하여 서버에 위조된 요청을 보내도록 하는 기법이다.

CSRF를 통해 공격자는 송금, 회원 정보 변경 등 다양한 공격이 가능하다.

공격을 하기위한 조건

  • 피해자가 공격자가 만든 피싱 사이트에 접속
  • 피해자가 위조 요청을 보낼 사이트에 로그인 된 상태


    조건을 맞추기 까다로워 보이지만 최근에는 자동 로그인이 되어 있는 사이트들이 많아서 생각보다 맞추기 쉬운 조건이다.


    공격 방식은 크게 두가지로 나눠진다.

1. 사용자가 피싱 사이트에 접속하도록 유도



2. 위조한 요청을 하이퍼링크에 삽입

인증/인가가 필요한 중요한 요청들이 간단하게 실행될 수 있는 이유는 쿠키에 있다.
쿠키는 서버에서 응답헤더에 담아서 클라이언트에게 보내주면, 클라이언트가 서버에 요청을 보낼 때마다 브라우저가 자동으로 요청 헤더에 쿠키를 담아서 보낸다.


이 때, 쿠키에 jwt 또는 sessionID같은 중요한 값들을 담고 있다면 공격자가 해당 값들을 탈취하지 않고도 위조된 요청을 쉽게 성공시킬 수 있는 것이다.

CSRF 대응

1. Referrer 검증

BackEnd에서 request header의 referrer를 확인하여 적절한 사이트에서 온 요청인지를 검증하는 방법

2. Security Token (CSRF Token)

사용자의 세션에 임의의 난수 값을 저장하고 사용자의 Request마다 난수값을 포함시켜 서버에 전송한다.

이후 BackEnd에서 요청을 받을 때마다 세션에 저장된 토큰 값과 요청 파라미터에 전달되는 토큰 값이 일치하는지 검증하는 방법이다.



프로젝트 적용 방식

# refresh token은 httpOnly쿠키에 저장

  1. js로 접근할 수 없기 때문에 Xss에 상대적으로 안전하다.
  2. 헤더에 withCredential: true가 추가 되어야 한다.
  3. sameSite=no; secure;
  • 크롬에서 default는 sameSite=lax이다. 이는 몇몇 예외를 제외하고는 서버와 클라이언트의 도메인이 같아야 쿠키를 허용하는 옵션이다.
  • 현재 우리 프로젝트는 백엔드와 프론트엔드가 같은 도메인이 아니다.
  • sameSite=no를 하고 싶을 경우, secure옵션을 추가하여 https통신으로만 전달해야 한다.
  • sameSite=lax가 표준은 아니지만 다른 브라우저들도 이렇게 따라가는 분위기라고 한다.

# access token은 json payload로 받아서 localStorage에 저장.

  1. CSRF에 상대적으로 안전하다. 쿠키처럼 request header에 자동 탑재 되지 않기 때문이다.
  2. XSS에 취약하기 때문에 만료시간을 짧게 한다.


# access token 갱신 프로세스




코드

단순히 refresh token을 axios interceptor에서 갱신 처리하는 코드만 작성할 경우 에러가 발생했다.

원인을 파악한 결과, 한 페이지 내에서 access token을 사용한 요청이 여러개 있었고, 첫번째 요청이 이미 access token갱신을 완료하여 만료가 되지 않았음에도 계속 access token갱신을 요청하면서 발생한 에러였다.

스택 오버플로우에서 나와 비슷한 문제를 겪는 사람의 글을 찾을 수 있었고, 참고하여 나의 프로젝트에 맞게 변형시켰다.

이해를 돕기 위해 코드 작동 원리를 그림으로 그려보았다.
각 요청의 번호는 서버에 요청한 순서를 의미한다.

import type { AxiosError, AxiosResponse } from "axios";
import axios from "axios";

import { postRefreshToken } from "./auth";

export const BASE_URL = "https://api.chagok.site";

export const AxiosClient = axios.create({
  baseURL: `${BASE_URL}`,
  withCredentials: true,
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let failedQueue: { resolve: any; reject: any }[] = [];
let isRefreshing = false;

AxiosClient.interceptors.response.use(
  (response: AxiosResponse) => {
    return response;
  },
  async (error: AxiosError) => {
    const originalRequest = error.config;

    if (originalRequest && error?.response?.status == 403) {
      if (isRefreshing) {
        try {
          const token = await new Promise((resolve, reject) => {
            failedQueue.push({ resolve, reject });
          });
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return axios(originalRequest);
        } catch (error) {
          throw error;
        }
      }
      isRefreshing = true;

      try {
        const newAccessToken = await getNewAcceessToken();
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
        processQueue(null, newAccessToken);
        return axios(originalRequest);
      } catch (error) {
        throw error;
      } finally {
        isRefreshing = true;
      }
    }
    throw error;
  },
);

async function getNewAcceessToken() {
  const data = await postRefreshToken();
  const newAccessToken = data.jwtToken;

  return newAccessToken;
}

function processQueue(error: AxiosError | null, token = null) {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });

  failedQueue = [];
}

0개의 댓글