yoowonyoungabout

금융 도메인에서의 프론트엔드 보안 챙기기

코드 XSS 0건, 설정 누락 7건 — CSP·소스맵·보안 헤더

들어가며

금융 백오피스의 보안 점검을 진행했다. API 보안, XSS/인젝션, 빌드 설정, 권한/인가 4개 영역을 점검한 결과, 코드 레벨의 XSS 취약점은 0건이었다. Zod 화이트리스트 검증, React 자동 이스케이프, TypeScript strict 모드가 잘 갖춰져 있었다.

빈틈은 오히려 코드 바깥에 있었다. CSP 미설정, 소스맵 노출, 보안 헤더 부재처럼 “설정하지 않아서 생긴” 취약점들이었다. 이 글에서는 점검 과정에서 발견한 핵심 이슈들과, 각각이 금융 도메인에서 왜 위험한지를 정리한다.


1. localStorage에 토큰이 평문으로 저장되고 있었다

가장 심각한 이슈였다. accessToken과 refreshToken이 localStorage에 JSON 평문으로 저장되고 있었다.

// Zustand persist 설정
{
  name: 'app-session',
  storage: createJSONStorage(() => localStorage),
}

브라우저 개발자 도구에서 localStorage.getItem('app-session')을 실행하면 토큰이 바로 보인다. 만약 XSS 공격이 성공하면, 공격자는 이 토큰으로 사용자인 척 API를 호출할 수 있다.

일반 서비스에서도 위험하지만, 금융 시스템에서는 토큰 하나가 곧 금전 거래 권한이다. 관리자 토큰이 탈취되면 거래 승인, 자산 변경 같은 민감한 액션이 가능해진다.

HttpOnly 쿠키로 전환하면 해결되는가?

// 현재 흐름
로그인 → 서버가 JSON body로 토큰 반환 → 프론트가 localStorage에 저장

// 전환 후 흐름
로그인 → 서버가 Set-Cookie 헤더로 토큰 발급 (HttpOnly) → 브라우저가 자동 관리

HttpOnly 플래그가 붙은 쿠키는 JavaScript에서 접근이 불가능하다. XSS가 성공해도 토큰을 읽을 수 없다. 다만 이 전환은 백엔드 변경이 필수이므로, 먼저 CSP로 XSS 자체를 차단하는 방어막을 추가했다.

Note: 핵심: localStorage 토큰 저장 자체가 취약한 것은 아니다. XSS가 성공했을 때 토큰이 노출된다는 것이 문제다. 그래서 XSS를 원천 차단하는 CSPXSS가 성공해도 토큰을 보호하는 HttpOnly 쿠키, 이 두 레이어를 함께 갖추는 것이 정답이다.


2. CSP가 없으면 XSS 방어의 마지막 보루가 없다

프로젝트에 Content Security Policy가 전혀 설정되어 있지 않았다.

CSP는 브라우저에게 “허용된 출처의 리소스만 실행해라”라고 지시하는 보안 정책이다. 설정하지 않으면 어떤 출처의 스크립트든 실행된다.

CSP가 없을 때의 공격 시나리오

1. 공격자가 어떤 경로로든 악성 스크립트를 페이지에 삽입
2. 스크립트가 localStorage에서 토큰을 읽음
3. 토큰을 공격자의 서버(https://evil.com)로 전송
4. 공격자가 탈취한 토큰으로 거래 승인 API 호출

CSP를 설정하면

<meta
  http-equiv="Content-Security-Policy"
  content="
    default-src 'self';
    script-src 'self' https://cdn.example.com;
    connect-src 'self' https://api.example.com;
    frame-ancestors 'none';
    object-src 'none'
  "
/>

각 디렉티브가 하는 일:

디렉티브 역할
script-src 'self' https://cdn.example.com 자기 자신과 허용된 CDN의 스크립트만 실행 허용
connect-src 'self' https://api.example.com API 호출은 자기 자신과 백엔드 도메인으로만 허용
frame-ancestors 'none' 다른 사이트가 iframe으로 삽입하는 것을 차단
object-src 'none' Flash/Java 같은 플러그인 객체 차단

이제 같은 공격이 시도되면:

1. 공격자가 악성 스크립트를 삽입
2. 스크립트가 localStorage에서 토큰을 읽음 → 여기까진 가능
3. 토큰을 https://evil.com으로 전송 시도
4. [X] 브라우저가 차단 — connect-src에 evil.com이 없음

Point: 핵심: 코드에 XSS 취약점이 없다는 것과, 취약점이 생겨도 방어할 수 있다는 것은 다르다. CSP는 후자를 보장하는 방어막이다. 특히 localStorage에 토큰을 저장하는 구조라면 CSP는 선택이 아니라 필수다.


3. 소스맵이 배포 환경에 포함되고 있었다

// Before — production 외 모든 환경에서 소스맵 생성
sourcemap: mode !== "production";

// After — 모든 환경에서 비활성화
sourcemap: false;

기존 설정은 production에서만 소스맵을 제거했다. Stage 환경은 외부에서 접근 가능한 배포인데도 소스맵이 포함되고 있었다.

소스맵이 노출되면 무엇이 위험한가

소스맵이 있으면 브라우저 개발자 도구에서 번들링 전의 원본 TypeScript 코드를 그대로 복원할 수 있다.

// 소스맵 없이 보이는 빌드 결과
const a=()=>{const e=localStorage.getItem("app-session");...}

// 소스맵이 있으면 원본이 복원됨
const getSession = () => {
  const stored = localStorage.getItem('app-session')
  const parsed = JSON.parse(stored)
  return parsed.state
}

API 엔드포인트, 권한 체크 로직, 비즈니스 규칙이 모두 드러난다. 금융 시스템에서 권한 체크가 어떤 조건으로 이루어지는지가 노출되면, 공격자는 우회 방법을 훨씬 쉽게 찾을 수 있다.


4. 보안 응답 헤더가 하나도 없었다

CloudFront + S3로 배포하고 있었는데, HTTP 응답에 보안 헤더가 하나도 설정되어 있지 않았다.

$ curl -sI https://stage.example.com

HTTP/2 200
content-type: text/html
server: AmazonS3
x-cache: Miss from cloudfront
# 보안 헤더는 없음

누락된 헤더와 각각이 방어하는 공격

헤더 없을 때 가능한 공격
Strict-Transport-Security 사용자가 http://로 접속하면 암호화 없이 통신. 중간자가 토큰을 엿볼 수 있음
X-Frame-Options: DENY 공격자가 우리 사이트를 iframe으로 감싸고 투명하게 덮어씌워, 사용자의 클릭을 가로챔 (클릭재킹)
X-Content-Type-Options: nosniff 브라우저가 텍스트 파일을 JavaScript로 추측 실행하는 것을 방지
Referrer-Policy 외부 링크 클릭 시 현재 URL의 쿼리 파라미터(토큰, 필터 정보 등)가 상대 서버에 전송됨

CSP는 index.html의 meta 태그로 추가할 수 있었지만, 나머지 4개는 HTTP 응답 헤더로만 설정 가능하다. CloudFront Response Headers Policy로 설정했다.

CloudFront Response Headers Policy 설정

{
  "SecurityHeadersConfig": {
    "StrictTransportSecurity": {
      "AccessControlMaxAgeSec": 31536000,
      "IncludeSubdomains": true,
      "Override": true
    },
    "FrameOptions": {
      "FrameOption": "DENY",
      "Override": true
    },
    "ContentTypeOptions": {
      "Override": true
    },
    "ReferrerPolicy": {
      "ReferrerPolicy": "strict-origin-when-cross-origin",
      "Override": true
    }
  }
}

설정 후 응답 헤더를 확인하면:

$ curl -sI https://app.example.com

HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin

5. 점검 결과 요약

점검 영역 발견 건수 심각도 조치 상태
토큰 저장 방식 1건 높음 백엔드 HttpOnly 쿠키 전환 협의 → 적용 완료
CSP 미설정 1건 높음 index.html meta 태그로 추가 완료
소스맵 노출 1건 중간 모든 환경 비활성화 완료
보안 응답 헤더 부재 4건 중간 CloudFront Response Headers Policy 설정 완료
XSS/인젝션 취약점 0건 React 자동 이스케이프 + Zod 검증 + TS strict
총계 7건 코드 레벨 0건, 설정/인프라 7건 → 전건 조치

핵심 발견: 코드에서는 취약점이 0건이었지만, “설정하지 않아서 생긴” 취약점이 7건이었다. 코드 품질과 시스템 보안은 다른 레이어임을 보여주는 결과다.


6. Before vs After

항목 Before (점검 전) After (조치 후)
CSP 미설정 script-src, connect-src, frame-ancestors 설정
소스맵 stage 환경 노출 전체 환경 sourcemap: false
HSTS 미설정 max-age=31536000; includeSubDomains
X-Frame-Options 미설정 DENY
X-Content-Type-Options 미설정 nosniff
Referrer-Policy 미설정 strict-origin-when-cross-origin
토큰 저장 localStorage 평문 HttpOnly 쿠키로 전환 완료

7. 정리: 코드 보안과 인프라 보안은 레이어가 다르다

이번 점검에서 얻은 핵심 교훈은, 코드에 취약점이 없는 것과 시스템이 안전한 것은 다르다는 점이다.

레이어 역할
코드 취약점이 생기지 않게 한다
브라우저 정책 (CSP) 취약점이 생겨도 실행을 차단한다
전송 (HTTPS, HSTS) 데이터가 이동 중에 탈취되지 않게 한다
저장 (HttpOnly 쿠키) 토큰이 JavaScript에서 접근 불가하게 한다

각 레이어는 독립적으로 동작한다. 코드가 완벽해도 CSP가 없으면 의존성 하나의 공급망 공격에 무방비다. CSP가 있어도 HTTPS가 강제되지 않으면 중간자 공격에 노출된다. 방어는 겹겹이 쌓아야 한다.

Point: 금융 도메인에서 프론트엔드 보안을 한 줄로 요약하면: “코드에서 막고, 브라우저에서 막고, 네트워크에서 막아라.” 한 레이어가 뚫려도 다음 레이어가 버텨야 한다.