콘텐츠로 이동

Web Security basics

분류: Layer 1 - 백엔드 기초 | 작성일: 2026-04-02

웹 보안(Web Security)은 웹 애플리케이션의 데이터, 권한, 실행 흐름, 통신 채널을 공격자가 임의로 바꾸지 못하게 만드는 설계 원칙과 방어 기술의 묶음이다.

이 문서에서 가장 중요한 단어는 **신뢰 경계(trust boundary)**다. 신뢰 경계는 “이 지점부터는 데이터를 아직 믿으면 안 된다” 또는 “이 지점을 통과한 뒤에도 어떤 조건에서만 믿을 수 있다”를 표시하는 선이다.

웹 보안 사고 대부분은 경계가 흐려질 때 생긴다. 사용자 입력이 SQL 명령처럼 해석되고, 게시글 내용이 브라우저 코드처럼 실행되고, 쿠키가 공격자 의도와 무관하게 자동 첨부되며, 평문 통신이 중간자에게 읽힌다.

따라서 보안 공부의 목표는 공격 이름을 외우는 것이 아니다. 데이터가 어디에서 들어와 어디에서 해석되는지, 어떤 자격이 자동으로 붙는지, 실패했을 때 어떤 신호가 보이는지를 설명할 수 있어야 한다.

위협 모델(threat model)은 보호할 자산, 공격 가능한 주체와 진입점, 예상하는 실패와 영향, 그 실패를 줄일 방어 경계를 질문으로 정리한 것이다. 첫 회독은 모든 공격·도구·설정값을 외우는 대신 다음 네 가지를 설명할 수 있으면 완료한다.

  1. 요청 하나를 보고 보호할 자산, 신뢰하지 않을 입력, 공격자가 노리는 결과를 구분해 간단한 위협 모델을 세울 수 있다.
  2. 인증은 주체의 신원을 확인하고 인가는 그 주체의 행동을 허용하는 판단이며, 인증된 요청의 입력도 자동으로 신뢰할 수 없다고 설명할 수 있다.
  3. 입력 검증, 파라미터 바인딩, 출력 인코딩이 각각 어느 해석 경계를 지키며 서로를 대체하지 않는지 설명할 수 있다.
  4. 브라우저의 쿠키 자동 첨부와 HTML·JavaScript 해석이 CSRF와 XSS로 이어지는 이유, SameSite·CSRF 토큰·CSP가 맡는 역할을 설명할 수 있다.

DDoS는 어느 계층의 어떤 자원이 먼저 고갈되는지, WAF는 애플리케이션 앞에서 HTTP 요청량을 줄이지만 인증·인가와 비즈니스 정책을 대신하지 못한다는 지도까지만 잡는다. SYN 동작, 임계값 산정, Count에서 Block으로 전환하는 운영 판단은 선택 심화로 미룬다.


웹 보안은 많은 도구 이름으로 흩어져 보인다. helmet, CSP(Content Security Policy), CORS(Cross-Origin Resource Sharing), SameSite, WAF(Web Application Firewall), bcrypt, Argon2id, lockfile, rate limiting은 서로 다른 영역의 도구다. 여기서는 이름을 먼저 지도처럼 훑고, 각 도구가 막는 경계와 실패 신호는 아래 절에서 하나씩 풀어간다. 하지만 이 도구들이 막는 실패는 몇 가지 패턴으로 묶인다.

2-0. 인증과 인가가 끝나도 입력은 신뢰하지 않는다

섹션 제목: “2-0. 인증과 인가가 끝나도 입력은 신뢰하지 않는다”

인증(authentication)은 요청 주체가 누구인지 확인하고, 인가(authorization)는 그 주체가 특정 리소스에 특정 행동을 해도 되는지 판단한다. 둘을 통과했다는 사실은 요청 본문이나 출력 값이 안전하다는 뜻이 아니다.

예를 들어 글 수정 권한이 있는 관리자가 프로필 설명에 위험한 HTML을 저장하면 인가 판단은 올바르게 끝났어도 Stored XSS는 남을 수 있다. 반대로 입력을 완벽히 검증했어도 다른 사용자의 글을 수정할 권한까지 생기지는 않는다. 따라서 신원 확인, 행동 허용, 입력 검증, 안전한 해석과 출력은 서로 다른 경계에서 각각 수행해야 한다.

2-1. 경계 1: 데이터와 명령어를 섞지 않는다

섹션 제목: “2-1. 경계 1: 데이터와 명령어를 섞지 않는다”

사용자 입력은 원래 데이터다. 이메일, 검색어, 댓글, 파일명, URL, JSON 필드는 모두 데이터로 다뤄져야 한다. 문제는 SQL 엔진, HTML 파서, 셸, 템플릿 엔진 같은 인터프리터가 특정 문자를 만나면 데이터를 명령어로 해석한다는 점이다.

SQL Injection과 XSS는 이 경계가 무너진 대표 사례다. SQL Injection은 데이터가 SQL 조건식으로 해석되는 문제고, XSS는 데이터가 HTML 또는 JavaScript 실행 문맥으로 해석되는 문제다.

방어의 핵심은 두 가지다. 입력 단계에서는 형식과 범위를 검증하고, 해석 단계에서는 데이터와 명령어의 자리를 구조적으로 분리한다. SQL에서는 parameterized query가 그 역할을 하고, HTML에서는 출력 문맥에 맞는 encoding 또는 sanitizing이 그 역할을 한다.

2-2. 경계 2: 입력 검증과 출력 인코딩은 다른 일이다

섹션 제목: “2-2. 경계 2: 입력 검증과 출력 인코딩은 다른 일이다”

입력 검증(input validation)은 “이 값이 우리 시스템이 받을 수 있는 형태인가”를 확인한다. 예를 들어 이메일 필드는 문자열이어야 하고, 길이가 제한되어야 하며, 이메일 형식이어야 한다.

출력 인코딩(output encoding)은 “이 값을 지금 출력하는 문맥에서 코드로 해석되지 않게 바꾸는 일”이다. HTML 본문, HTML attribute, URL, JavaScript 문자열은 각각 다른 문맥이다. 같은 < 문자도 HTML 본문에서는 태그 시작으로, JavaScript 문자열에서는 문자열 일부로 다뤄진다.

둘은 대체 관계가 아니다. @IsEmail()은 이메일이 아닌 값을 거부하지만, 댓글 내용처럼 자유로운 텍스트를 HTML에 출력할 때는 별도 encoding이 필요하다. 반대로 출력 encoding이 있어도 DB에 들어가기 전에 타입과 길이를 제한해야 불필요한 비용과 예외를 줄일 수 있다.

2-3. 경계 3: 브라우저가 자동으로 붙이는 자격을 의심한다

섹션 제목: “2-3. 경계 3: 브라우저가 자동으로 붙이는 자격을 의심한다”

쿠키는 브라우저가 요청에 자동으로 붙인다. 이 자동 첨부가 편리함의 이유이자 CSRF(Cross-Site Request Forgery)의 이유다. 사용자가 은행 사이트에 로그인되어 있으면, 다른 사이트에서 은행으로 폼 요청을 보내도 브라우저는 은행 쿠키를 함께 보낼 수 있다.

CSRF 방어는 “요청을 보낼 수 있느냐”보다 “피해자의 자격이 자동으로 붙었느냐”에 집중해야 한다. SameSite 쿠키와 CSRF 토큰은 이 자동 첨부 문제를 약하게 만들거나, 공격자가 알 수 없는 값을 요구해서 요청을 거부한다.

2-4. 경계 4: 공격자 비용과 방어자 비용을 비교한다

섹션 제목: “2-4. 경계 4: 공격자 비용과 방어자 비용을 비교한다”

DDoS(Distributed Denial of Service)와 패스워드 brute force는 모두 비용 비대칭 문제다. 공격자는 작은 요청을 많이 보내고, 서버는 비싼 DB 조회나 CPU 연산을 수행한다. 공격자는 GPU로 초당 수많은 패스워드를 추측하고, 서버는 사용자가 로그인할 때만 한 번 검증하면 된다.

좋은 방어는 공격자에게 비용을 크게 부과하면서 정상 사용자 비용은 감당 가능한 수준으로 둔다. rate limiting은 요청 수를 제한하고, bcrypt와 Argon2id는 패스워드 추측 한 번의 비용을 의도적으로 높인다.

2-5. 경계 5: 출처와 무결성을 확인한다

섹션 제목: “2-5. 경계 5: 출처와 무결성을 확인한다”

공급망 공격은 내가 직접 작성하지 않은 코드가 내 권한으로 실행될 때 생긴다. npm 패키지, Docker 이미지, CDN 스크립트, CI 액션, OS 패키지는 모두 외부에서 들어오는 실행 단위다.

lockfile, integrity hash, dependency review, 최소 권한 CI 토큰, 이미지 스캔은 같은 질문에 답한다. “이 코드가 내가 의도한 출처에서 왔고, 설치되는 동안 바뀌지 않았으며, 실행되어도 피해 범위가 제한되는가?“


2.5. 선행 한계와 등장 이유: 단일 방어선에서 다층 방어로

섹션 제목: “2.5. 선행 한계와 등장 이유: 단일 방어선에서 다층 방어로”

lineage_oneliner는 이 문서의 중심 흐름을 압축한다.

사용자 입력 신뢰 실패 → 데이터/명령어 경계 무너짐 → 다층 검증·암호화·프로토콜 강화로 방어

초기 웹 애플리케이션은 “사용자가 정상적인 값을 보낼 것”이라고 가정하기 쉬웠다. 하지만 브라우저는 누구나 조작할 수 있고, HTTP 요청은 개발자 도구나 스크립트로 직접 만들 수 있으며, 네트워크 중간 구간은 항상 내 통제 아래 있지 않다.

첫 번째 실패는 입력 신뢰였다. 서버가 이메일 필드에 들어온 문자열을 SQL 문자열에 붙이면, DB는 그 일부를 조건식으로 해석한다. 게시글 내용을 그대로 HTML로 내보내면, 브라우저는 그 일부를 태그나 스크립트로 해석한다. 이 실패가 SQL Injection과 XSS의 뿌리다.

두 번째 실패는 자동 자격 신뢰였다. 쿠키 기반 세션은 사용자가 요청마다 비밀번호를 보내지 않아도 되는 장점이 있다. 하지만 브라우저가 쿠키를 자동 첨부하기 때문에, 외부 사이트에서 만든 요청도 피해자의 권한을 일부 빌릴 수 있다. 이 실패가 CSRF의 뿌리다.

세 번째 실패는 채널 신뢰였다. HTTP 평문 통신은 중간자가 내용을 읽거나 바꾸기 쉽다. TLS는 서버 인증서, 키 교환, 암호화된 세션을 통해 중간자가 내용을 이해하지 못하게 만든다. HSTS(HTTP Strict Transport Security)는 브라우저가 같은 사이트에 HTTP로 되돌아가지 않도록 기억시키는 장치다.

네 번째 실패는 비용 신뢰였다. 정상 요청 하나는 서버에 작아 보이지만, 인증, DB 조회, 외부 API 호출, 이미지 처리처럼 비싼 작업을 유발할 수 있다. DDoS와 brute force는 이 비용 차이를 이용한다. rate limiting과 느린 패스워드 해싱은 공격자가 대량 시도를 할 때 비용이 급격히 커지게 만든다.

다섯 번째 실패는 출처 신뢰였다. 패키지 이름이 익숙하다고 안전한 것은 아니고, lockfile 없이 설치하면 어제와 오늘 다른 코드가 들어올 수 있다. 공급망 보안은 외부 코드가 내부 권한으로 실행되기 전에 출처, 버전, 무결성, 권한 범위를 확인한다.

이 흐름 때문에 웹 보안은 한 가지 도구로 끝나지 않는다. 입력 검증, 출력 인코딩, parameterized query, 쿠키 속성, TLS, HSTS, rate limiting, WAF, 패스워드 해싱, dependency control이 서로 다른 층에서 같은 목표를 나눠 맡는다.


3-1. SQL Injection: 데이터가 SQL 명령어로 해석되는 순간

섹션 제목: “3-1. SQL Injection: 데이터가 SQL 명령어로 해석되는 순간”

SQL Injection은 사용자 입력이 SQL 문자열의 일부가 되어, 원래 의도와 다른 조건식이나 명령으로 해석되는 취약점이다. 핵심은 “나쁜 문자열” 자체가 아니라 문자열 결합으로 SQL 문법을 만든 구조다.

예를 들어 로그인 로직이 아래와 같은 SQL을 만든다고 하자.

SELECT *
FROM users
WHERE email = '<사용자 입력>'
AND password_hash = '<검증 값>';

개발자의 의도는 <사용자 입력> 자리에 이메일 데이터만 들어가는 것이다. 하지만 애플리케이션이 문자열을 직접 이어 붙이면, 공격자가 따옴표와 조건식을 섞은 값을 보내 SQL parser가 다른 의미로 읽게 만들 수 있다. 민감한 예시는 WAF-safe 표기를 위해 일부 문자를 zero-width-space로 분리한다.

입력 예시: ' O​R '1'='1
개발자가 기대한 의미:
email 값이 "' O​R '1'='1" 인 사용자를 찾는다.
취약한 문자열 결합에서 DB가 읽을 수 있는 의미:
email 조건을 닫고, 뒤에 항상 참인 조건식을 붙인다.

이 예시는 공격을 따라 하라는 목적이 아니다. SQL Injection을 막는 이유를 이해하기 위한 핵심은 입력이 데이터 칸에 묶이지 않고 SQL 문법 칸으로 새어 나갔다는 점이다.

// 취약한 패턴: 입력이 SQL 문자열에 직접 들어간다.
async function findUserUnsafe(email: string) {
const sql = "SELECT * FROM users WHERE email = '" + email + "'";
return dataSource.query(sql);
}
// 안전한 패턴: SQL 문법과 값의 위치가 분리된다.
async function findUserSafe(email: string) {
return userRepository
.createQueryBuilder("user")
.where("user.email = :email", { email })
.getOne();
}

user.email = :email에서 :email은 SQL 조각이 아니라 값 자리다. DB 드라이버는 실제 실행 시 값을 별도 파라미터로 보낸다. 그러면 입력 안에 따옴표, 공백, O​R, DR​OP TA​BLE 같은 SQL처럼 보이는 문자가 들어와도 DB는 그것을 문법이 아니라 값으로 처리한다.

개념적으로 DB에 전달되는 구조:
SQL text:
SELECT * FROM users WHERE email = $1
Parameter:
$1 = "user@example.com"

입력 검증은 왜 여전히 필요한가

섹션 제목: “입력 검증은 왜 여전히 필요한가”

Parameterized query가 SQL 문법 주입을 막아도 입력 검증은 필요하다. 이메일 필드에 5MB 문자열이 들어오거나, 문자열이어야 할 값이 객체로 들어오거나, 내부 정책상 허용하지 않는 도메인이 들어오는 문제는 별도다.

입력 검증의 역할은 “받을 수 없는 값을 빨리 거부하는 것”이다. Parameterized query의 역할은 “받은 값이 SQL 문법으로 해석되지 않게 하는 것”이다. 둘은 서로 보완한다.

class LoginDto {
@IsEmail()
email: string;
@IsString()
@MaxLength(200)
password: string;
}

같은 원리가 다른 인터프리터에서도 반복된다

섹션 제목: “같은 원리가 다른 인터프리터에서도 반복된다”

SQL Injection은 하나의 사례일 뿐이다. 사용자 입력이 어떤 인터프리터에 들어가고, 그 인터프리터가 입력을 코드로 해석할 수 있으면 같은 유형의 문제가 생긴다.

데이터와 명령어 경계가 무너지는 위치

SQL

문자열 결합으로 쿼리를 만들면 입력이 조건식이나 명령처럼 읽힐 수 있다.

Parameterized query, ORM value binding을 사용한다.

Shell

파일명이나 옵션을 셸 문자열에 붙이면 셸 메타문자가 실행 흐름을 바꿀 수 있다.

셸을 거치지 않는 execFile 계열과 인자 배열을 사용한다.

HTML

텍스트가 HTML 문맥에 그대로 들어가면 태그나 이벤트 핸들러처럼 해석될 수 있다.

출력 문맥별 encoding 또는 sanitizer를 사용한다.

LLM Prompt Injection도 넓게 보면 해석 경계 문제다. 다만 SQL처럼 ? placeholder로 명령과 데이터를 완전히 분리하기 어렵다. 그래서 LLM 도구 호출에서는 출력 검증, 권한 분리, 사용자 승인, 도구별 allowlist가 더 중요해진다.

  • TypeORM을 쓰는데도 where("email = '" + email + "'") 같은 문자열 결합이 보인다.
  • raw query가 필요할 때 두 번째 파라미터 배열 없이 문자열만 전달한다.
  • DTO 검증 없이 req.body 전체를 query builder나 search filter에 넘긴다.
  • DB 로그에 사용자 입력 전체가 SQL text 안에 박혀 보인다.
  • 앱 DB 계정에 DR​OP, schema 변경, superuser 권한이 불필요하게 부여되어 있다.

SQL Injection 방어의 첫 번째 목표는 파라미터 바인딩이다. 두 번째 목표는 입력 검증이다. 세 번째 목표는 최소 권한이다. 첫 번째가 실패해도 세 번째가 피해 범위를 줄인다.


3-2. XSS: 데이터가 브라우저 코드로 실행되는 순간

섹션 제목: “3-2. XSS: 데이터가 브라우저 코드로 실행되는 순간”

XSS(Cross-Site Scripting)는 사용자가 제공한 값이 브라우저에서 HTML 또는 JavaScript로 해석되어 실행되는 취약점이다. 서버 입장에서는 단순 문자열이지만, 브라우저 입장에서는 그 문자열이 DOM, 태그, attribute, script 문맥에 놓이는 순간 실행 의미를 가질 수 있다.

SQL Injection이 DB parser의 문제라면, XSS는 브라우저 parser의 문제다. 둘의 공통점은 데이터가 명령어로 바뀌었다는 점이다. 차이는 방어 위치다. SQL은 DB에 보내기 전 값 바인딩이 핵심이고, XSS는 브라우저에 내보낼 때 출력 문맥을 통제하는 것이 핵심이다.

XSS 유형별 신뢰 경계

Stored XSS

악성 문자열이 DB에 저장되고 다른 사용자가 조회할 때 브라우저에서 실행된다.

게시글, 댓글, 프로필, 관리자 메모처럼 저장 후 재노출되는 입력

Reflected XSS

URL 파라미터나 요청 값이 응답 HTML에 바로 반사되어 링크 클릭 순간 실행된다.

검색어, 오류 메시지, redirect 파라미터

DOM-based XSS

클라이언트 코드가 URL, hash, storage 값을 읽어 DOM에 위험한 방식으로 삽입한다.

innerHTML, eval, 문자열 기반 setTimeout, 위험한 HTML 삽입 API
1. 사용자가 댓글에 다음과 같은 HTML처럼 보이는 값을 저장한다.
<scr​ipt>sendCookieToExample()</scr​ipt>
2. 서버는 문자열을 검증하거나 정제하지 않고 DB에 저장한다.
3. 다른 사용자가 댓글 목록을 연다.
4. 서버가 댓글 내용을 HTML에 그대로 넣어 응답한다.
5. 브라우저가 해당 문자열을 텍스트가 아니라 script 태그로 해석한다.

여기서 중요한 점은 저장 시점과 실행 시점이 다르다는 것이다. Stored XSS는 공격자가 글을 쓸 때가 아니라 피해자가 글을 읽을 때 실행된다. 따라서 “내 API 응답은 정상 200이다”라는 관찰만으로 안전하다고 판단하면 안 된다.

출력 인코딩은 < 같은 문자를 &lt;처럼 바꾸어 브라우저가 태그로 읽지 못하게 만드는 방식이다. 일반 텍스트를 화면에 보여줄 때 가장 기본적인 방어다.

Sanitizing은 일부 HTML을 허용하되 위험한 태그나 attribute만 제거하는 방식이다. 마크다운 렌더링, 리치 텍스트 에디터, CMS처럼 사용자가 제한된 HTML을 입력할 수 있어야 할 때 필요하다.

목표가 "사용자 입력을 텍스트로 보여주기"라면:
output encoding
목표가 "굵게, 링크, 목록 같은 일부 HTML을 허용하기"라면:
sanitizer + 허용 목록 기반 정책

작은 차이는 출력 문맥에서 드러난다. HTML 본문에서는 <&lt;로 바꾸는 것만으로 태그 실행을 막을 수 있지만, HTML attribute 안에서는 따옴표와 공백도 경계가 된다. URL에 넣을 값은 URL encoding이 필요하고, JavaScript 문자열에 넣을 값은 또 다른 escaping 규칙을 따른다. 그래서 “입력은 한 번 검증하고 끝”이 아니라 “출력하는 문맥마다 안전하게 바꾼다”가 XSS 방어의 핵심이다.

입력 검증만으로 XSS를 끝낼 수 없는 이유도 여기에 있다. 댓글은 자유 텍스트라 < 문자를 전부 입력 단계에서 막기 어렵다. 따라서 저장은 문자열로 하되, 출력할 때 문맥에 맞게 안전하게 바꿔야 한다.

CSP(Content Security Policy)는 브라우저에게 “이 페이지에서 어떤 출처의 스크립트와 리소스를 실행할 수 있는가”를 알려주는 HTTP 헤더다. 예를 들어 script-src 'self'는 같은 출처의 스크립트만 허용한다.

CSP는 XSS의 원인을 제거하지 않는다. 원인은 여전히 위험한 출력이다. 하지만 출력 encoding이나 sanitizer가 한 번 실패했을 때, 브라우저가 인라인 스크립트나 외부 스크립트 실행을 거부하게 만드는 방어층이다.

Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';

CSP 경계 조건

unsafe-inline

인라인 스크립트를 넓게 허용해 XSS 방어 효과가 크게 줄어든다.

가능하면 nonce 또는 hash 기반 허용으로 좁힌다.

unsafe-eval

문자열을 코드로 실행하는 경로를 열어 DOM XSS와 결합될 수 있다.

라이브러리 요구사항을 확인하고 제거 가능한지 먼저 본다.

report-only

위반을 보고하지만 실제 차단하지 않는다. 테스트용이지 운영 방어가 아니다.

배포 전 관찰 후 enforcement 헤더로 전환한다.

HttpOnly 쿠키는 JavaScript에서 쿠키 값을 읽지 못하게 한다. XSS 자체를 막지는 못하지만, XSS가 곧바로 세션 쿠키 탈취로 이어지는 것을 줄인다.

Secure 쿠키는 HTTPS에서만 쿠키가 전송되게 한다. SameSite 쿠키는 CSRF 방어와 더 관련이 깊지만, 세션 쿠키의 전송 범위를 줄인다는 점에서 함께 봐야 한다.

res.cookie("session", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
});
  • 사용자 입력을 innerHTML, dangerouslySetInnerHTML, 문자열 기반 eval에 넣는다.
  • 리치 텍스트를 허용하면서 sanitizer 정책이 없다.
  • CSP가 Content-Security-Policy-Report-Only로만 내려간다.
  • CSP에 unsafe-inline, unsafe-eval, script-src *가 넓게 남아 있다.
  • 세션 쿠키에 HttpOnly가 없어 XSS 발생 시 토큰 탈취로 바로 이어진다.
  • 오류 메시지나 검색어가 HTML에 그대로 반사된다.

3-3. CSRF: 브라우저가 피해자의 쿠키를 자동으로 붙이는 문제

섹션 제목: “3-3. CSRF: 브라우저가 피해자의 쿠키를 자동으로 붙이는 문제”

CSRF(Cross-Site Request Forgery)는 공격자가 피해자의 브라우저를 이용해 피해자 권한으로 상태 변경 요청을 보내게 만드는 취약점이다. 핵심은 공격자가 쿠키 값을 직접 훔치지 않아도 된다는 점이다. 브라우저가 대상 사이트의 쿠키를 요청에 자동으로 붙이기 때문이다.

<!-- 공격자가 만든 외부 사이트의 개념적 예시 -->
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker_account" />
<input type="hidden" name="amount" value="1000000" />
</form>
<scr​ipt>document.forms[0].submit()</scr​ipt>

피해자가 bank.example에 로그인되어 있고, 세션 쿠키가 cross-site POST에 첨부된다면 서버는 이 요청을 피해자가 보낸 정상 요청처럼 볼 수 있다. 공격자는 쿠키 값을 몰라도 된다. 브라우저가 자동으로 보내기 때문이다.

SameSite는 쿠키가 cross-site 요청에 자동 첨부되는 범위를 제한한다.

SameSite 선택 기준

Lax

일반적인 서비스 기본값으로 적합하다. 외부 사이트의 일부 top-level GET에는 쿠키가 갈 수 있지만, cross-site POST 위험은 크게 줄인다.

상태 변경 API를 GET으로 만들지 않는다는 전제가 필요하다.

Strict

외부 출처에서 시작한 요청에는 쿠키를 거의 보내지 않는다. 강하지만 로그인 복귀, 결제 복귀, 초대 링크 흐름을 깨뜨릴 수 있다.

관리자, 금융, 고위험 작업처럼 UX보다 방어 강도가 우선일 때

None + Secure

cross-site 쿠키 전송을 허용한다. 반드시 Secure가 필요하며 HTTPS 전제가 있어야 한다.

서로 다른 schemeful site 사이에서 쿠키를 실제로 보내야 할 때

SameSite는 강력하지만 전부는 아니다. Lax에서는 일부 GET 요청에 쿠키가 갈 수 있으므로, 상태 변경 작업을 GET으로 만들면 방어가 약해진다. None은 cross-site 쿠키가 필요한 구조에서 쓰지만, Secure 없이 쓰면 브라우저가 거부하거나 위험한 설정이 된다.

CSRF 토큰은 서버가 만든 예측 불가능한 값을 폼이나 헤더에 넣게 하고, 상태 변경 요청마다 그 값을 검증하는 방식이다. 외부 사이트는 피해자의 쿠키를 자동으로 붙일 수는 있어도, 정상 페이지가 받은 토큰 값을 읽을 수 없다.

CSRF 토큰 검증 흐름
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
participant E as Evil Site

U->>S: 정상 페이지 요청
S-->>B: 세션 쿠키와 CSRF 토큰 발급
B->>S: 상태 변경 요청 + 쿠키 + 토큰
S-->>B: 토큰 일치, 처리
E->>S: 위조 상태 변경 요청 + 쿠키 자동 첨부
S-->>E: 토큰 없음 또는 불일치, 거부

토큰은 쿠키와 다른 채널로 확인되어야 의미가 있다. 쿠키에 있는 값만 다시 쿠키로 확인하면 공격자 요청에도 같은 값이 자동으로 붙기 때문이다. Double Submit Cookie 패턴은 쿠키 값과 별도 헤더 또는 폼 값을 비교해 외부 사이트가 읽을 수 없는 값을 요구한다.

모바일 앱이나 일부 SPA는 쿠키 대신 Authorization: Bearer ... 헤더를 사용한다. 이 헤더는 브라우저가 자동으로 붙이지 않는다. 따라서 토큰이 메모리에 있고 JavaScript가 직접 헤더를 붙이는 구조라면 전통적인 CSRF는 성립하기 어렵다.

하지만 토큰을 LocalStorage에 오래 저장하면 XSS 한 번에 토큰이 탈취될 수 있다. 쿠키 기반 인증은 CSRF를 신경 써야 하고, LocalStorage 기반 토큰은 XSS 피해가 커진다. 저장 위치 선택은 CSRF와 XSS를 함께 비교해야 한다.

CORS(Cross-Origin Resource Sharing)는 다른 출처의 JavaScript가 응답을 읽을 수 있는지를 제어한다. CSRF는 응답을 읽는 문제가 아니라, 쿠키가 붙은 상태 변경 요청이 전송되는 문제다.

퀴즈

CORS를 엄격하게 설정하면 CSRF도 자동으로 막히는가?

힌트: 요청 전송과 응답 읽기는 다른 문제다.

정답 보기

아니다. CORS는 JavaScript fetch/XHR의 응답 읽기를 제한한다. HTML form submit처럼 쿠키가 자동 첨부되는 요청 자체를 막는 방어는 SameSite와 CSRF 토큰이다.

  • 상태 변경 API가 GET으로 구현되어 있다.
  • 쿠키 기반 인증인데 SameSite가 없거나 None만 있고 Secure가 없다.
  • “CORS를 설정했으니 CSRF는 막힌다”고 문서화되어 있다.
  • CSRF 토큰이 있지만 서버에서 검증하지 않거나 실패 시에도 요청을 처리한다.
  • cross-site 결제/로그인 복귀 때문에 SameSite=None을 썼는데 민감 작업에 추가 토큰이 없다.
  • 쿠키 기반 관리자 기능이 SameSite와 CSRF 토큰 없이 외부 링크에서 호출될 수 있다.

3-4. MITM, TLS, HSTS: 채널을 믿을 수 있게 만드는 장치

섹션 제목: “3-4. MITM, TLS, HSTS: 채널을 믿을 수 있게 만드는 장치”

MITM(Man-in-the-Middle)은 클라이언트와 서버 사이에 공격자가 끼어들어 트래픽을 읽거나 바꾸는 상황이다. HTTP 평문 통신에서는 요청 URL, 헤더, 쿠키, 본문이 중간 구간에서 노출될 수 있다.

TLS는 이 문제를 세 가지로 나눠 해결한다. 첫째, 인증서로 서버가 진짜인지 확인한다. 둘째, 핸드셰이크로 세션 키를 합의한다. 셋째, 이후 통신을 암호화하고 변조를 감지한다.

TLS가 제공하는 것:
1. 서버 인증
브라우저가 인증서와 CA 서명을 검증한다.
2. 기밀성
중간자가 패킷을 보아도 본문을 읽지 못한다.
3. 무결성
중간자가 내용을 바꾸면 검증이 실패한다.

TLS에서 중요한 학습 포인트는 “HTTPS를 켰다”와 “안전한 TLS 운영을 한다”가 같은 말이 아니라는 점이다. 구버전 프로토콜이나 약한 cipher가 남아 있으면 호환성 fallback 때문에 안전한 연결이 약한 연결로 내려갈 수 있다. 예를 들어 사용자는 https://로 접속했다고 생각해도, 중간 장비나 구형 클라이언트가 낮은 TLS 버전·약한 암호군을 허용하면 공격자는 그 낮은 공통분모를 노린다. BEAST나 POODLE 같은 오래된 사례의 교훈은 특정 공격 이름을 외우는 것이 아니라, “호환성을 위해 남겨둔 구버전 경로가 전체 채널 보안을 결정할 수 있다”는 점이다. 그래서 운영 점검에서는 HTTPS 리디렉션, 허용 TLS 버전, HSTS, 인증서 만료를 한 묶음으로 본다.

인증서는 “이 공개키는 이 도메인의 서버가 사용한다”는 주장과, 그 주장을 CA(Certificate Authority)가 서명한 결과다. 브라우저와 OS는 신뢰할 루트 CA 목록을 갖고 있고, 서버 인증서가 그 신뢰 체인으로 이어지는지 확인한다.

중간자가 가짜 인증서를 내밀면 정상 브라우저는 경고를 띄운다. 사용자가 이 경고를 무시하면 TLS의 서버 인증이 깨진다. 그래서 운영에서는 인증서 만료, 잘못된 SAN, 중간 인증서 누락이 단순 설정 문제가 아니라 보안 장애다.

HTTPS 리디렉션만으로는 첫 요청 문제가 남는다. 사용자가 http://example.com으로 처음 접속하면, 서버가 301로 HTTPS를 안내하기 전까지 첫 요청은 평문이다. 중간자가 이 응답을 가로채면 HTTPS로 올라가기 전에 공격할 수 있다.

HSTS(HTTP Strict Transport Security)는 브라우저에게 “이 도메인은 앞으로 HTTPS로만 접속하라”고 기억시키는 헤더다.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

max-age=31536000은 1년 동안 HTTPS만 쓰라는 뜻이다. includeSubDomains는 서브도메인까지 적용한다. preload는 브라우저의 HSTS preload 목록에 등록할 의도가 있다는 표시다.

숫자로 보면 max-age=31536000은 약 1년이다. 이 값은 “한 번 설정하면 브라우저가 오래 기억한다”는 운영적 의미를 갖는다. 처음 적용할 때는 짧은 max-age로 관찰하고, 모든 서브도메인이 HTTPS를 안정적으로 제공할 때 길게 늘리는 식으로 접근한다. 특히 includeSubDomainspreload는 되돌리기 어렵기 때문에 작은 서비스 하나가 HTTP에 남아 있어도 전체 도메인 UX를 깨뜨릴 수 있다.

TLS는 전송 중 보호다. 서버 안에서 로그에 토큰을 남기거나, DB에 패스워드를 평문 저장하거나, XSS로 브라우저 안에서 토큰이 탈취되는 문제는 TLS가 막지 못한다.

HSTS는 브라우저가 HTTPS를 강제하도록 돕지만, 인증서가 만료되었거나 도메인 설정이 잘못된 문제를 해결하지 않는다. 또한 preload는 되돌리기 어렵기 때문에 모든 서브도메인이 HTTPS를 안정적으로 제공할 때 적용해야 한다.

  • 로그인, 결제, 관리자 화면이 HTTP로 접근 가능하다.
  • HTTPS 리디렉션은 있지만 HSTS가 없다.
  • x-forwarded-proto를 신뢰하면서 reverse proxy의 trusted proxy 설정이 없다.
  • 인증서 만료 알림이 운영 모니터링에 없다.
  • mixed content 때문에 HTTPS 페이지에서 HTTP 리소스를 불러온다.
  • HSTS includeSubDomains를 켰는데 일부 서브도메인이 HTTPS를 제공하지 않는다.

3-5. DDoS와 Rate Limiting: 서버 비용을 보호하는 방어

섹션 제목: “3-5. DDoS와 Rate Limiting: 서버 비용을 보호하는 방어”

DDoS(Distributed Denial of Service)는 많은 요청으로 서버, 네트워크, 로드밸런서, 애플리케이션 자원을 고갈시켜 정상 사용자가 서비스를 이용하지 못하게 만드는 공격이다.

DDoS를 이해할 때는 “요청이 악성처럼 보이는가”보다 “어느 자원이 먼저 고갈되는가”를 봐야 한다. L3/L4 공격은 네트워크와 연결 자원을 소진하고, L7 공격은 정상 HTTP 요청처럼 보이면서 애플리케이션 비용을 소모한다.

DDoS 계층별 실패 지점

L3

IP 레벨에서 대역폭을 소진한다. 애플리케이션 코드까지 도달하지 않을 수 있다.

CDN, edge network, 클라우드 기본 보호가 핵심이다.

L4

TCP/UDP 연결 자원을 고갈시킨다. 연결 테이블과 로드밸런서가 먼저 영향을 받는다.

SYN backlog, connection limit, Shield 같은 네트워크 방어를 본다.

L7

정상 HTTP처럼 보이지만 비싼 엔드포인트를 반복 호출해 앱 비용을 태운다.

WAF, route별 rate limit, 인증별 quota, cache가 필요하다.

첫 회독에서는 L3/L4가 네트워크·연결 자원을, L7이 애플리케이션 자원을 소모한다는 구분까지만 잡으면 된다. WAF는 애플리케이션 앞에서 대량 HTTP 요청을 줄이고, 애플리케이션은 사용자·tenant·경로 비용처럼 비즈니스 맥락이 필요한 제한을 맡는다.

아래의 SYN 흐름, 임계값 예시, 제한 키 선택, WAF 배포 판단은 DDoS/WAF를 실제로 도입하거나 운영할 때 돌아오는 선택 심화다.

선택 심화: SYN Flood로 보는 L4 비용

섹션 제목: “선택 심화: SYN Flood로 보는 L4 비용”
정상 TCP 연결:
Client -> SYN -> Server
Client <- SYN-ACK <- Server
Client -> ACK -> Server
SYN Flood:
Attacker -> SYN with spoofed source -> Server
Server -> SYN-ACK 대기 상태 유지
Attacker -> ACK 없음
결과:
서버의 연결 대기 자원이 쌓이고, 정상 연결이 지연되거나 실패한다.

애플리케이션 개발자가 이 흐름을 모두 튜닝하지는 않는다. 하지만 L4 공격과 L7 공격의 위치를 구분해야 어떤 방어를 어디에 둘지 판단할 수 있다. L4는 edge와 load balancer에서 줄이고, L7은 WAF와 애플리케이션 rate limit으로 줄인다.

선택 심화: Rate limiting은 정상 사용자 모델에서 시작한다

섹션 제목: “선택 심화: Rate limiting은 정상 사용자 모델에서 시작한다”

rate limit은 숫자를 낮게 잡는다고 항상 안전해지는 것이 아니다. 너무 낮으면 NAT를 공유하는 회사, 학교, 카페 사용자가 정상적으로 차단된다. 너무 높으면 brute force와 HTTP flood를 줄이지 못한다.

먼저 정상 트래픽 분포를 본다. 예를 들어 로그인 요청이 정상 사용자 기준 p99에서 5분당 3회라면, 5분당 30회 제한은 충분한 여유를 두면서 brute force를 줄일 수 있다. 반대로 공개 검색 API가 정상적으로 5분당 수십 회 호출된다면 같은 임계값은 너무 낮다.

운영 숫자는 처음부터 정답으로 외우기보다 규모 감각으로 읽는다. 전체 경로에는 IP당 500 requests/5min 같은 넓은 WAF blanket rule을 두고, 로그인·회원가입·비밀번호 재설정처럼 위험과 비용이 큰 경로는 30~100 requests/5min처럼 더 낮게 둔다. 애플리케이션 내부 fallback rate limit은 WAF보다 뒤에 있으므로, origin까지 도달한 요청을 사용자·tenant·경로 비용 기준으로 더 세밀하게 줄이는 용도로 본다.

상황예시 임계값왜 이 정도인가
전체 HTTP flood 완화500 req/5min/IP정상 p99보다 충분히 높게 두어 edge에서 대량 요청만 줄인다.
로그인 brute force30 req/5min/IP 또는 더 낮게정상 사용자가 5분에 수십 번 로그인할 가능성은 낮다.
인증된 비싼 API사용자/tenant별 quotaIP가 아니라 비용을 만든 주체 기준으로 제한한다.

이 숫자는 복사해서 고정할 값이 아니다. 정상 traffic p95/p99, NAT 공유 가능성, 경로별 서버 비용을 반영해 조정한다.

임계값을 정하는 간단한 순서:
1. 정상 p95/p99 요청 수를 측정한다.
2. 경로의 비용을 분류한다.
- 정적 파일, cache hit, 검색, 로그인, 결제, 보고서 생성
3. 사용자 단위와 IP 단위 중 어떤 키가 맞는지 고른다.
4. 처음에는 count/log 모드로 false positive를 관찰한다.
5. 차단 후 429, WAF BlockedRequests, 고객 문의를 함께 본다.

선택 심화: IP 기준과 사용자 기준의 차이

섹션 제목: “선택 심화: IP 기준과 사용자 기준의 차이”

IP 기준은 인증 전 공개 엔드포인트에 유용하다. 하지만 NAT 환경에서는 많은 사용자가 한 IP를 공유한다. 로그인 이후에는 사용자 ID, API key, tenant ID 같은 더 안정적인 키를 함께 쓰는 편이 좋다.

Rate Limit 키 선택

IP

인증 전 요청을 막기 쉽지만 NAT 공유 사용자에게 false positive가 생길 수 있다.

공개 GET, 로그인 전 단계, edge WAF 기본 방어

IP + 식별자

로그인 brute force에서 특정 이메일이나 사용자명에 대한 반복 시도를 잡는다.

로그인, 비밀번호 재설정, 인증번호 요청

User/API key

인증된 사용자의 quota와 abuse를 더 공정하게 제어한다.

인증된 API, tenant별 사용량 제한, 유료 플랜 제한

선택 심화: WAF와 애플리케이션 rate limit의 관계

섹션 제목: “선택 심화: WAF와 애플리케이션 rate limit의 관계”

WAF(Web Application Firewall)는 edge 또는 load balancer 앞단에서 HTTP 요청을 검사하고 차단한다. SQL Injection, XSS 패턴, IP reputation, rate-based rule 같은 규칙을 적용할 수 있다.

WAF는 애플리케이션 코드보다 앞에서 공격량을 줄인다. 하지만 비즈니스 의미를 모르는 경우가 많다. 예를 들어 “사용자별 하루 100개 보고서 생성” 같은 정책은 애플리케이션 안에서 사용자와 권한을 기준으로 계산해야 한다.

좋은 구조는 둘을 겹치는 것이다. WAF는 명백한 대량 요청과 알려진 패턴을 줄이고, 애플리케이션은 사용자, tenant, 경로 비용을 기준으로 세밀하게 제한한다.

  • 모든 API에 같은 rate limit 숫자를 적용한다.
  • 로그인, 비밀번호 재설정, 인증번호 발송에 별도 제한이 없다.
  • IP 단독 제한 때문에 기업 NAT 사용자가 429를 받는다.
  • WAF Count 모드 지표를 보지 않고 바로 Block으로 배포한다.
  • 429 응답이 너무 자세해서 공격자에게 제한 정책을 알려준다.
  • 비싼 report/export API가 인증된 사용자에게 무제한 열려 있다.

3-6. Password Hashing, Salting, Argon2id: 유출 이후를 대비하는 방어

섹션 제목: “3-6. Password Hashing, Salting, Argon2id: 유출 이후를 대비하는 방어”

패스워드 저장의 목표는 “DB가 유출되어도 원문 패스워드를 바로 알 수 없게 하는 것”이다. 로그인할 때는 사용자가 입력한 패스워드를 같은 방식으로 처리해 저장된 값과 비교한다.

해시(hash)는 같은 입력에 같은 출력을 만들지만, 출력에서 입력을 되돌리기 어렵게 설계된 함수다. 하지만 모든 해시가 패스워드 저장에 적합한 것은 아니다. 파일 무결성에 좋은 빠른 해시는 공격자에게도 빠르다.

Salt는 패스워드마다 새로 생성하는 랜덤 값이다. 같은 패스워드를 두 사용자가 쓰더라도 salt가 다르면 저장되는 해시가 달라진다.

salt 없는 저장:
userA password = "same-password" -> hash X
userB password = "same-password" -> hash X
salt 있는 저장:
userA password + saltA -> hash A
userB password + saltB -> hash B

salt는 비밀 값이 아니다. 보통 해시 문자열 안에 함께 저장된다. salt의 목적은 공격자가 미리 계산해 둔 레인보우 테이블을 그대로 쓰지 못하게 하고, 같은 패스워드 사용자를 한눈에 묶지 못하게 하는 것이다.

Stretching은 패스워드 검증을 일부러 느리게 만드는 방식이다. bcrypt의 cost factor는 반복 비용을 조절한다. cost가 1 증가하면 대략 두 배 가까이 느려진다고 이해하면 된다.

예시적인 비용 감각:
cost 10: 로그인 서버에서 빠르게 검증 가능하지만 공격자도 더 많이 추측 가능
cost 12: 측정을 시작할 때 자주 비교하는 후보값
cost 14: 보안성은 높아지지만 서버 CPU와 로그인 latency 부담이 커짐

작은 벤치마크 숫자는 감각을 만드는 데 도움이 된다. 예를 들어 어떤 서버에서 bcrypt cost 10이 약 80ms, cost 12가 약 300ms, cost 14가 약 1200ms라면 cost 14는 단일 요청 보안성은 높지만 로그인 p99를 쉽게 밀어낼 수 있다. 반대로 cost 10은 사용자 경험은 가볍지만 DB 유출 이후 공격자의 추측 비용도 낮춘다.

정답 숫자는 없다. 로그인 SLO가 p95 600ms라면 해싱이 300ms 안팎에 머물러야 DB 조회, 세션 생성, 네트워크 지연을 더해도 전체 SLO를 지킬 수 있다. 서버가 빨라지면 work factor도 다시 측정해 올릴 수 있다.

패스워드 해싱 알고리즘 선택

SHA-256

무결성 확인과 서명 재료에는 유용하지만 너무 빨라 패스워드 추측 비용을 낮춘다.

패스워드 저장에는 사용하지 않는다.

bcrypt

salt와 cost factor를 포함하고 오래 검증된 선택지다. 레거시 시스템 유지에 현실적이다.

운영 유사 환경에서 cost별 p95/p99와 로그인 SLO를 측정해 선택할 때

Argon2id

CPU뿐 아니라 메모리 비용까지 요구해 GPU/ASIC 대량 추측 비용을 높인다.

신규 프로젝트의 기본 선택지

신규 프로젝트라면 Argon2id에서 시작하는 편이 좋다. 기존 bcrypt 시스템은 즉시 갈아엎기보다 cost, 입력 길이 제한, 재해시 전략을 확인한다. MD5나 SHA-256으로 패스워드를 저장하는 레거시는 로그인 성공 시 새 알고리즘으로 재해시하는 마이그레이션 경로를 잡아야 한다.

bcrypt는 입력을 72바이트까지만 처리하는 제약이 있다. 이 제약을 모르고 긴 패스워드를 허용하면, 앞 72바이트가 같은 두 입력이 같은 패스워드처럼 검증될 수 있다.

개념 예시:
password_A:
앞 72바이트가 동일하고 뒤쪽이 "AAAA"
password_B:
앞 72바이트가 동일하고 뒤쪽이 "BBBB"
bcrypt가 앞 72바이트만 본다면:
A와 B가 같은 입력처럼 취급될 수 있다.

한글과 이모지는 UTF-8에서 한 글자가 여러 바이트일 수 있다. 따라서 “72자”가 아니라 “72바이트”라는 점이 중요하다. 긴 패스워드를 허용해야 한다면 Argon2id를 쓰거나, bcrypt 앞단에서 well-designed pre-hash 전략을 검토한다. 신규 설계에서는 Argon2id가 더 단순하다.

import * as argon2 from "argon2";
async function hashPassword(plainPassword: string) {
return argon2.hash(plainPassword, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
}
async function verifyPassword(hash: string, plainPassword: string) {
return argon2.verify(hash, plainPassword);
}

위 값은 “복사해서 영원히 고정”할 숫자가 아니다. 서버 성능, 로그인 트래픽, latency budget을 기준으로 측정하고, 하드웨어가 바뀌면 다시 측정한다.

  • 패스워드 컬럼이 평문, MD5, SHA-1, SHA-256 단독 해시로 저장되어 있다.
  • salt가 없거나 모든 사용자에게 같은 salt를 쓴다.
  • bcrypt cost가 낮고 마지막 재평가 시점이 오래되었다.
  • bcrypt 사용 중인데 72바이트 초과 입력 정책이 없다.
  • 해싱 latency가 로그인 SLO를 밀어내는데 별도 측정이 없다.
  • 로그에 원문 패스워드나 reset token이 남는다.

3-7. Supply Chain Security: 내가 쓰는 코드도 입력이다

섹션 제목: “3-7. Supply Chain Security: 내가 쓰는 코드도 입력이다”

공급망 공격(Supply Chain Attack)은 외부 의존성, 빌드 도구, 배포 이미지, CI 액션, CDN 스크립트 같은 공급 경로를 통해 악성 코드가 들어오는 문제다. 웹 애플리케이션이 아무리 조심해도, 설치한 패키지가 빌드 시 환경변수를 읽어 외부로 보내면 피해가 생긴다.

이 절의 핵심 관점은 단순하다. 의존성도 사용자 입력처럼 신뢰 경계를 통과해야 한다. 다만 입력값 검증 대신 버전 고정, 출처 확인, 무결성 검증, 권한 제한, 변경 리뷰가 필요하다.

1. 개발자가 직접 또는 간접 의존성으로 패키지를 설치한다.
2. 패키지 설치 스크립트, 빌드 스크립트, 런타임 코드가 실행된다.
3. 그 코드는 개발자 머신, CI, 서버의 권한 안에서 동작한다.
4. 환경변수, 토큰, 파일, 네트워크 접근 권한이 있으면 피해 범위가 커진다.

공격자는 꼭 유명 패키지를 해킹할 필요가 없다. 비슷한 이름의 패키지를 올리는 typosquatting, 메인테이너 계정 탈취, transitive dependency 오염, CDN 소유권 변경, CI 액션 compromise 같은 경로가 있다.

대표 사례를 공격 절차가 아니라 방어 교훈으로만 보면 다음과 같다. event-stream 사건은 오래 쓰던 패키지라도 maintainer 권한이 바뀌면 위험이 들어올 수 있음을 보여줬다. xz-utils 사건은 작은 오픈소스 유지보수 경로가 넓은 시스템 신뢰 체인으로 연결될 수 있음을 보여줬다. polyfill.io 사건은 CDN과 도메인 소유권도 공급망이라는 점을 보여줬다. 공통 결론은 “직접 작성하지 않은 실행 코드는 버전, 출처, 권한을 계속 검증해야 한다”는 것이다.

package.json은 버전 범위를 표현할 수 있다. 예를 들어 ^4.17.0은 호환 범위 안의 더 최신 버전을 받을 수 있다. 이 유연성은 편리하지만, 오늘 설치한 코드와 다음 주 설치한 코드가 달라질 수 있다는 뜻이기도 하다.

package-lock.json은 실제로 설치된 정확한 버전과 integrity hash를 기록한다. 같은 lockfile로 설치하면 팀원과 CI가 같은 패키지 그래프를 받는다. 해시가 맞지 않으면 패키지가 바뀌었음을 감지할 수 있다.

lockfile이 줄이는 위험:
- 의도하지 않은 minor/patch 업그레이드
- 팀원마다 다른 transitive dependency
- 배포 시점마다 다른 설치 결과
- 패키지 tarball 변조

lockfile은 공급망 보안의 전부가 아니다. 이미 lockfile에 악성 버전이 들어왔다면 그대로 고정될 수도 있다. 그래서 dependency review, audit, changelog 확인, 최소 권한 CI가 함께 필요하다.

npm audit은 알려진 취약점 데이터베이스와 설치된 패키지를 비교한다. 이미 공개되어 식별된 취약점에는 유용하다. 하지만 아직 알려지지 않은 악성 행위, typosquatting, maintainer 계정 탈취, 설치 스크립트의 수상한 네트워크 접근을 모두 잡지는 못한다.

따라서 audit 결과가 0이라고 “공급망 안전”이라고 말하면 안 된다. 의존성 추가 PR에서는 다음 질문을 함께 봐야 한다.

의존성 추가 리뷰 질문:
1. 이 패키지가 꼭 필요한가?
2. 다운로드 수보다 유지보수 상태와 권한 범위가 적절한가?
3. install/postinstall script가 있는가?
4. 브라우저 번들에 들어가는 코드인가, 서버/CI에서 실행되는 코드인가?
5. transitive dependency가 갑자기 많이 늘어나는가?
6. 대체로 표준 라이브러리나 기존 의존성으로 해결할 수 있는가?

최소 권한으로 피해 범위를 줄인다

섹션 제목: “최소 권한으로 피해 범위를 줄인다”

공급망 공격은 “침입을 0으로 만든다”보다 “들어와도 피해 범위를 작게 만든다”가 현실적이다. CI 토큰에 쓰기 권한이 과도하면 패키지 하나가 저장소와 배포 환경을 바꿀 수 있다. 빌드 단계에서 프로덕션 DB 비밀번호를 볼 수 있으면 패키지 설치 스크립트가 그 값을 훔칠 수 있다.

그래서 CI는 job별 권한을 좁히고, secret은 필요한 단계에만 주며, 배포 토큰은 환경별로 분리한다. 서버 런타임도 파일 시스템, 네트워크, IAM 권한을 최소화한다.

  • lockfile이 커밋되지 않거나 자주 재생성되는데 리뷰하지 않는다.
  • 작은 기능 하나에 큰 의존성 트리가 추가된다.
  • 설치 스크립트가 있는 패키지를 검토 없이 받아들인다.
  • CI 모든 job에 repository write token과 배포 secret이 노출된다.
  • CDN 스크립트를 SRI나 버전 고정 없이 불러온다.
  • audit 0건을 공급망 보안 완료로 해석한다.

4. 횡단 원리: 새 취약점을 만났을 때 묻는 질문

섹션 제목: “4. 횡단 원리: 새 취약점을 만났을 때 묻는 질문”

새 CVE나 보안 사고를 읽을 때 공격 이름부터 외우면 금방 길을 잃는다. 먼저 데이터 흐름과 권한 흐름을 질문으로 나눈다.

  1. 신뢰 경계: 데이터가 어디에서 들어와 어디까지 이동했는가?
  2. 해석 컨텍스트: 입력이 데이터로 처리되었는가, 명령어로 해석되었는가?
  3. 자동 첨부 자격: 쿠키, 토큰, IAM role, 세션 같은 권한이 자동으로 붙었는가?
  4. 비대칭 비용: 공격자 작은 비용이 서버 큰 비용을 강제하는가?
  5. 출처와 무결성: 코드, 인증서, 패키지, 이미지가 바뀌지 않았다는 근거가 있는가?

이 질문은 SQL Injection, XSS, CSRF, MITM, DDoS, 패스워드 해싱, 공급망 보안을 한 모델로 묶는다.

4-2. 예시 A: SSRF를 이 모델에 넣어보기

섹션 제목: “4-2. 예시 A: SSRF를 이 모델에 넣어보기”

SSRF(Server-Side Request Forgery)는 서버가 사용자 입력 URL을 믿고 내부 네트워크로 요청을 보내는 문제다. 이 문서의 핵심 공격 목록에는 별도 절로 두지 않았지만, 다섯 질문으로 빠르게 분류할 수 있다.

Q1. 신뢰 경계:
외부 사용자가 보낸 URL이 서버 내부 HTTP client까지 도달했다.
Q2. 해석 컨텍스트:
문자열 URL이 네트워크 요청 목적지로 해석되었다.
Q3. 자동 첨부 자격:
서버의 내부 네트워크 접근 권한이나 cloud metadata 접근 권한이 붙었다.
방어 방향:
URL allowlist, 내부 IP 차단, redirect 제한, metadata endpoint 보호,
서버 권한 최소화가 함께 필요하다.

여기서 단순 문자열 형식 검증만으로는 부족하다. URL이 형식상 정상이어도 목적지가 내부망이면 위험할 수 있기 때문이다.

4-3. 예시 B: 로그 문자열이 실행 동작을 가진 경우

섹션 제목: “4-3. 예시 B: 로그 문자열이 실행 동작을 가진 경우”

로그는 원래 데이터를 기록하는 행위다. 하지만 어떤 라이브러리가 로그 문자열 안의 특정 패턴을 다시 lookup이나 실행 흐름으로 해석하면, 로그 입력이 명령어처럼 동작할 수 있다.

Q2. 해석 컨텍스트:
로그 문자열이 데이터가 아니라 라이브러리 기능 호출처럼 해석되었다.
Q5. 출처와 무결성:
패키지 자체가 정상 출처에서 왔어도 동작 정책이 위험할 수 있다.
방어 방향:
라이브러리 업데이트, 위험 기능 비활성화, egress 제한, 로그 입력 정규화,
dependency inventory가 필요하다.

이 예시는 “정상 라이브러리도 위험한 해석 컨텍스트를 가질 수 있다”는 점을 보여준다. 공급망 보안은 패키지 설치만이 아니라 패키지가 어떤 권한과 기능을 갖는지 이해하는 일이다.


5. Defense in Depth: 한 겹이 실패해도 전체가 무너지지 않게 한다

섹션 제목: “5. Defense in Depth: 한 겹이 실패해도 전체가 무너지지 않게 한다”

Defense in Depth(심층 방어)는 하나의 방어책이 실패해도 다른 방어책이 피해를 줄이도록 여러 층을 쌓는 설계다. 보안에서는 “정답 도구 하나”보다 “실패를 전제로 한 겹침”이 중요하다.

XSS 심층 방어
flowchart TD
A["입력: 타입과 길이 검증"] --> B["저장: 위험 HTML 정책 결정"]
B --> C["출력: 문맥별 encoding 또는 sanitizer"]
C --> D["브라우저: CSP로 실행 범위 제한"]
D --> E["쿠키: HttpOnly로 탈취 피해 축소"]
B -.->|sanitize 누락| C
C -.->|encoding 누락| D
D -.->|CSP 우회| E

입력 검증은 댓글이 문자열이고 길이가 과도하지 않은지 본다. 저장 정책은 HTML을 허용할지, 허용한다면 어떤 태그만 허용할지 정한다. 출력 단계는 실제 HTML 문맥에서 코드로 실행되지 않게 한다. CSP는 실행 가능한 스크립트 출처를 줄인다. HttpOnly 쿠키는 XSS가 생겨도 쿠키 값을 읽기 어렵게 한다.

어느 한 겹도 완전하지 않다. 하지만 겹치면 공격자가 우회해야 할 조건이 늘어난다.

1. DTO 검증:
email은 이메일 형식, id는 정수, limit은 범위 제한.
2. Parameterized query:
값과 SQL 문법을 분리.
3. ORM 기본 메서드 우선:
직접 SQL 문자열을 만들 기회를 줄임.
4. DB 계정 최소 권한:
앱 계정에 schema 변경 권한을 주지 않음.
5. 감사 로그와 alert:
비정상 query error, 대량 조회, 권한 오류를 관찰.

Parameterized query가 핵심 방어다. 하지만 DB 계정 최소 권한이 있으면 실수 하나가 전체 schema 파괴로 이어질 가능성이 줄어든다.

5-3. 인증과 쿠키 방어를 겹쳐보기

섹션 제목: “5-3. 인증과 쿠키 방어를 겹쳐보기”
쿠키 기반 세션:
HttpOnly + Secure + SameSite
상태 변경 요청에는 CSRF 토큰
관리자 작업에는 재인증 또는 step-up auth
세션 만료와 refresh 정책
로그아웃 시 서버 측 세션 무효화

세션 방어는 XSS와 CSRF를 함께 본다. HttpOnly는 XSS 피해를 줄이고, SameSite와 CSRF 토큰은 CSRF를 줄인다. Secure와 HSTS는 네트워크 노출을 줄인다.


6-1. 인증 토큰을 어디에 둘 것인가

섹션 제목: “6-1. 인증 토큰을 어디에 둘 것인가”
상황추천함께 봐야 할 위험
서버 렌더링 + 동일 도메인HttpOnly + Secure + SameSite=Lax 쿠키상태 변경에는 CSRF 토큰
SPA + 별도 API originHttpOnly + Secure 쿠키, SameSite는 관계별 결정CORS와 credentials, CSRF 방어를 별도로 검토
모바일 앱Authorization 헤더 + 짧은 access token토큰 저장소와 refresh rotation
브라우저 LocalStorage일반적으로 피한다XSS 한 번에 장기 토큰 탈취 가능

토큰 저장 위치는 “CSRF만 보면 헤더가 좋다”처럼 단순히 고르면 안 된다. 쿠키는 CSRF를 신경 써야 하지만 HttpOnly로 XSS 탈취를 줄일 수 있다. LocalStorage는 CSRF에는 강하지만 XSS 피해가 커진다.

별도 API 호스트가 곧 cross-site라는 뜻은 아니다. origin은 scheme, host, port 조합으로 구분하지만, 쿠키의 SameSite 판단에 쓰는 schemeful site는 scheme과 registrable domain을 기준으로 한다. 따라서 https://app.example.comhttps://api.example.com은 origin은 달라 CORS 검토가 필요하지만, schemeful site는 모두 httpsexample.com이므로 same-site다. SameSite=None은 서로 다른 schemeful site 사이에서 쿠키를 실제로 보내야 할 때만 사용하며, 이 경우 Secure가 반드시 필요하고 CSRF 토큰 같은 별도 방어도 적용한다.

6-2. 패스워드 해싱을 어떻게 고를 것인가

섹션 제목: “6-2. 패스워드 해싱을 어떻게 고를 것인가”
신규 프로젝트:
Argon2id를 기본값으로 둔다.
memoryCost, timeCost는 서버 성능과 로그인 SLO로 측정한다.
기존 bcrypt:
운영과 비슷한 환경에서 cost별 p95/p99를 측정한다.
로그인 SLO 안에서 감당할 수 있는 가장 높은 cost를 선택하고 주기적으로 재측정한다.
72바이트 입력 정책을 정한다.
로그인 성공 시 필요하면 재해시한다.
MD5/SHA 계열 단독 저장:
레거시 위험으로 분류한다.
원문을 알 수 없으므로 로그인 성공 시 새 알고리즘으로 업데이트한다.

마이그레이션은 “한 번에 모든 패스워드를 바꾸기”가 어렵다. 원문 패스워드를 모르기 때문이다. 그래서 사용자가 다음 로그인에 성공했을 때 새 알고리즘으로 재해시하는 전략을 많이 쓴다.

6-3. 선택 심화: Rate limit 숫자를 어떻게 고를 것인가

섹션 제목: “6-3. 선택 심화: Rate limit 숫자를 어떻게 고를 것인가”
경로기준 키숫자 결정 방식주의
전체 API 기본IP정상 p99에 충분한 여유NAT false positive
로그인IP + 사용자 식별자정상 재시도보다 높고 brute force보다 낮게성공 요청 제외 여부
비밀번호 재설정사용자 식별자 + IP메일/SMS 비용과 abuse 기준계정 열거 방지 메시지
인증된 비싼 APIUser/API key/tenant플랜과 비용 기준429와 quota UX

처음부터 완벽한 숫자를 맞추기 어렵다. 가능하면 count/log 모드로 배포해 정상 사용자 분포를 보고, 차단 모드로 바꾼 뒤에도 429 비율과 고객 문의를 함께 확인한다. 예를 들어 로그인 경로를 15분 30회로 바로 차단하기 전에 일주일 정도 count 모드로 보면, 회사 NAT나 QA 자동화가 정상적으로 20회 이상 시도하는지 알 수 있다. 차단 전환 뒤에는 429 비율, 동일 IP의 사용자 수, 성공/실패 로그인 비율을 함께 봐야 false positive와 brute force를 구분할 수 있다.

6-4. WAF, CSP, CORS를 헷갈리지 않는다

섹션 제목: “6-4. WAF, CSP, CORS를 헷갈리지 않는다”
도구주로 막는 것막지 못하는 것
WAF알려진 HTTP 공격 패턴, 대량 요청, IP reputation비즈니스 권한 오류, 내부 로직 버그
CSP브라우저에서 허용되지 않은 스크립트 실행서버 저장 단계의 오염, 모든 DOM XSS
CORS다른 출처 JS가 응답을 읽는 것CSRF 요청 전송 자체
SameSitecross-site 요청의 쿠키 자동 첨부XSS, 서버 내부 권한 오류

도구가 막는 경계가 다르다. 경계를 착각하면 “CORS로 CSRF를 막는다” 같은 잘못된 결론에 도달한다.


이 절은 긴 복구 런북이 아니라, 문제가 어떤 보안 개념과 연결되는지 빠르게 분류하기 위한 신호 목록이다.

Case 1. TypeORM을 쓰는데 SQL Injection 위험이 남는다

섹션 제목: “Case 1. TypeORM을 쓰는데 SQL Injection 위험이 남는다”

증상은 query builder 사용 코드 안에 문자열 보간이 보이는 것이다. .where("user.email = '" + email + "'")처럼 값을 SQL 문자열에 직접 붙이면 TypeORM을 쓰지만 파라미터 바인딩은 아니다.

해결 방향은 named parameter 또는 repository method로 바꾸는 것이다.

// 잘못된 방향
.where("user.email = '" + email + "'")
// 방어 방향
.where("user.email = :email", { email })

함께 볼 신호는 DB 계정 권한이다. 앱 계정이 schema 변경 권한까지 갖고 있으면, injection 하나의 피해 범위가 커진다.

Case 2. CSP 적용 후 화면이 깨진다

섹션 제목: “Case 2. CSP 적용 후 화면이 깨진다”

증상은 브라우저 콘솔에 Content Security Policy 위반이 보이고, 일부 스크립트나 스타일이 로드되지 않는 것이다.

이때 바로 unsafe-inline이나 script-src *를 넣으면 CSP를 거의 무력화할 수 있다. 먼저 어떤 리소스가 필요한지 식별하고, 도메인 allowlist, nonce, hash 기반 허용 중 맞는 방식을 고른다.

Report-Only 모드는 정책을 관찰하는 단계다. 운영 방어로 계산하려면 enforcement 헤더가 내려가야 한다.

Case 3. CORS를 설정했는데 CSRF가 걱정된다

섹션 제목: “Case 3. CORS를 설정했는데 CSRF가 걱정된다”

정상이다. CORS와 CSRF는 다른 문제다. CORS는 다른 출처 JavaScript의 응답 읽기를 막고, CSRF는 브라우저가 쿠키를 자동 첨부해 상태 변경 요청을 보내는 문제다.

쿠키 기반 인증이면 SameSite와 CSRF 토큰을 별도로 본다. 상태 변경 GET도 함께 제거한다.

Case 4. bcrypt가 로그인 latency를 밀어낸다

섹션 제목: “Case 4. bcrypt가 로그인 latency를 밀어낸다”

증상은 로그인 p95/p99가 갑자기 커지고 CPU 사용량이 올라가는 것이다. cost factor가 서버 성능 대비 높거나, 로그인 시도가 폭증했을 수 있다.

먼저 cost별 latency를 실제 운영과 비슷한 인스턴스에서 측정한다. 그다음 rate limiting, queueing, worker thread, Argon2id 전환 가능성을 검토한다. 보안 비용을 낮추기 전에 brute force 방어가 약해지는 영향을 함께 본다.

Case 5. 선택 심화: WAF rate limit이 정상 사용자를 차단한다

섹션 제목: “Case 5. 선택 심화: WAF rate limit이 정상 사용자를 차단한다”

증상은 특정 회사나 학교 사용자들이 동시에 429를 받는 것이다. 여러 사용자가 같은 NAT IP를 공유하면 IP 단독 제한이 정상 요청을 한 사람의 요청처럼 합산한다.

해결 방향은 경로별로 다르다. 인증 전 경로는 IP 기준을 유지하되 임계값을 조정하고, 인증 후 경로는 사용자 ID나 tenant ID 기준을 함께 쓴다. 로그인 경로는 IP와 사용자 식별자를 결합해 brute force와 NAT 문제를 동시에 줄인다.

Case 6. npm audit은 0건인데 의존성 추가가 불안하다

섹션 제목: “Case 6. npm audit은 0건인데 의존성 추가가 불안하다”

audit 0건은 “알려진 취약점이 매칭되지 않았다”는 뜻이다. 패키지가 새로 생긴 악성 패키지인지, install script가 무엇을 하는지, CI에서 어떤 secret을 볼 수 있는지는 별도 질문이다.

의존성 추가 PR에서는 lockfile diff, transitive dependency 수, maintainer 상태, install script, 실행 위치(브라우저/서버/CI)를 함께 본다.


이 절은 전체 프로젝트 설정 런북이 아니라, 본문 개념을 코드에서 확인할 때 필요한 최소 형태만 남긴다.

import helmet from "helmet";
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet());
app.enableCors({
origin: ["https://app.example.com"],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
}

이 코드는 완성된 보안 설정이 아니다. 핵심은 세 경계를 보여주는 것이다. helmet은 브라우저 보안 헤더를 추가하고, CORS는 응답 읽기 출처를 제한하며, ValidationPipe는 입력의 형식과 허용 필드를 제한한다.

res.cookie("session", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});

httpOnly는 JavaScript 접근을 막아 XSS 피해를 줄인다. secure는 HTTPS에서만 쿠키를 보내게 한다. sameSite는 cross-site 요청에 쿠키가 자동 첨부되는 범위를 줄인다.

7-3. 선택 심화: Rate limit의 최소 사고 모델

섹션 제목: “7-3. 선택 심화: Rate limit의 최소 사고 모델”
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: "draft-8",
legacyHeaders: false,
});

이 숫자는 예시다. express-rate-limit에서 standardHeaders: true는 버전에 따라 draft-6으로 해석될 수 있다. 이 예시는 라이브러리가 지원하는 결합형 헤더인 "draft-8"을 명시해 wire format을 고정한다. IETF 최신 초안의 형식을 그대로 구현한다는 뜻은 아니며, 패키지 업그레이드 때 지원 draft와 실제 응답 헤더를 함께 확인해야 한다. 실제 값은 정상 p95/p99 요청 수, 경로 비용, NAT 가능성, 인증 여부로 정한다. 로그인, 비밀번호 재설정, 인증번호 발송은 기본 API보다 더 엄격한 별도 정책이 필요하다.

7-4. 의존성 변경 리뷰 최소 체크

섹션 제목: “7-4. 의존성 변경 리뷰 최소 체크”
패키지를 추가할 때:
1. package.json뿐 아니라 lockfile diff를 본다.
2. transitive dependency가 과도하게 늘었는지 본다.
3. install/postinstall script가 있는지 본다.
4. CI secret을 볼 수 있는 단계에서 실행되는지 본다.
5. 대체 가능한 기존 의존성이나 표준 API가 있는지 본다.

이 체크는 npm audit을 대체하지 않는다. audit은 알려진 취약점을 찾고, 리뷰는 새로운 실행 권한과 공급 경로를 평가한다.


직접 확인은 공격 도구 사용법을 익히는 것이 아니라, 방어가 어떤 신호로 관찰되는지 보는 데 목적이 있다. 반드시 본인 소유의 로컬 환경이나 명시적 허가를 받은 시스템에서만 수행한다.

실습 1. 파라미터 바인딩 로그 확인

섹션 제목: “실습 1. 파라미터 바인딩 로그 확인”

TypeORM 로깅을 켜고 두 코드를 비교한다. 취약한 코드는 사용자 입력이 SQL text 안에 그대로 들어간다. 안전한 코드는 SQL text와 parameter가 분리된다. 민감 예시는 필터나 WAF가 오해하지 않도록 일부 문자를 obfuscation한 형태로 읽는다. 학습 포인트는 문자열 자체가 아니라 SQL text와 parameter가 분리되는지다.

const email = "test@example.com' O​R '1'='1";
// 관찰 포인트:
// 1. SQL text에 입력이 직접 들어가는가?
// 2. 입력이 $1, ? 같은 parameter로 분리되는가?
// 3. DB 로그에 민감 입력이 과도하게 남지는 않는가?

실습 2. bcrypt 또는 Argon2id latency 측정

섹션 제목: “실습 2. bcrypt 또는 Argon2id latency 측정”

패스워드 해싱은 보안 비용과 사용자 latency가 만나는 지점이다. 개발 노트북 수치가 운영 서버 수치와 같다고 가정하지 말고, 운영과 비슷한 환경에서 측정한다.

const start = Date.now();
await bcrypt.hash("testPassword123!", 12);
console.log(Date.now() - start);

관찰 포인트는 단일 평균이 아니라 p95/p99다. 로그인 요청이 몰릴 때 CPU가 포화되면 정상 사용자도 느려진다. rate limiting과 해싱 비용은 함께 설계해야 한다.

앞의 Nest 예제는 평문 HTTP 서버이므로 먼저 그 서버에서 CSP 같은 일반 보안 헤더와 HTTPS redirect 구성을 확인한다.

Terminal window
curl --head http://localhost:3000/

HSTS는 TLS가 실제로 구성된 staging 또는 production 주소의 HTTPS 응답에서 별도로 확인한다.

Terminal window
curl --head https://app.example.com/

로컬에서 TLS listener와 자체 서명 인증서를 별도로 구성했다면 그때만 curl --insecure --head https://localhost:3443/처럼 인증서 검증을 생략할 수 있다. --insecure는 평문 서버에 TLS를 추가하지 않는다. 배포 환경에서는 이 옵션 없이 정상 인증서 검증을 통과해야 한다.

확인할 헤더는 다음과 같다.

Content-Security-Policy
Content-Security-Policy-Report-Only
Strict-Transport-Security
X-Content-Type-Options
X-Frame-Options 또는 frame-ancestors
Set-Cookie의 HttpOnly, Secure, SameSite

헤더가 있다는 사실만으로 충분하지 않다. Content-Security-Policy-Report-Only는 위반을 보고만 하고 차단하지 않는다. 실제로 브라우저가 차단하려면 Content-Security-Policy 헤더가 내려가야 한다. HSTS는 HTTPS 응답에서 확인한다. HTTP 응답에 포함된 HSTS는 브라우저가 무시하므로, HTTP에서는 HTTPS로의 redirect 여부를 별도로 관찰한다. 쿠키 속성이 환경별로 빠지지 않는지도 함께 확인한다.

브라우저 개발자 도구의 Application 또는 Storage 탭에서 쿠키 속성을 본다. SameSite=Lax, Secure, HttpOnly가 기대대로 붙는지 확인한다.

Network 탭에서는 상태 변경 요청이 어떤 출처에서 시작되었고, 쿠키가 함께 갔는지 본다. CSRF를 이해할 때 핵심 관찰은 “공격자가 쿠키를 읽었는가”가 아니라 “브라우저가 쿠키를 자동으로 보냈는가”다.

의존성을 하나 추가한 뒤 lockfile diff를 읽는다. 직접 추가한 패키지 하나만 보지 말고, transitive dependency가 얼마나 늘었는지 본다.

관찰 포인트는 다음이다.

- 새로 추가된 패키지 수
- resolved URL과 integrity hash
- install script 유무
- 기존 패키지 버전 변경 여부
- 보안 패치인지 기능 추가인지

첫 회독 완료 조건

  • 보호할 자산, 공격 주체와 진입점, 예상 실패와 영향을 묶어 간단한 위협 모델을 설명할 수 있다.
  • 인증과 인가를 분리하고, 인증된 요청의 입력도 신뢰 경계를 통과해야 한다고 설명할 수 있다.
  • 입력 검증, 파라미터 바인딩, 출력 인코딩이 맡는 경계와 서로 대체할 수 없는 이유를 설명할 수 있다.
  • 쿠키 자동 첨부와 브라우저의 코드 해석을 기준으로 CSRF와 XSS를 구분하고 SameSite, CSRF 토큰, CSP의 역할을 설명할 수 있다.
  • 네트워크·연결·애플리케이션 중 어느 자원이 고갈되는지 구분하고, WAF와 애플리케이션이 각각 요청량 완화와 비즈니스 정책을 맡는다고 설명할 수 있다.

DDoS/WAF의 임계값과 배포 모드, 해싱 파라미터와 공급망 운영 항목은 첫 회독 완료 조건이 아니다. 실제 도입 또는 운영 판단이 필요할 때 아래 점검으로 돌아온다.

입력과 해석 경계

  • TypeORM 쿼리에 문자열 보간 대신 파라미터 바인딩을 사용한다.
  • Raw 쿼리는 두 번째 인자로 파라미터 배열을 전달한다.
  • DTO에 class-validator 데코레이터로 입력 유효성 검사를 적용한다.
  • ValidationPipe에 whitelist와 forbidNonWhitelisted를 설정한다.
  • 사용자 입력을 HTML에 출력할 때 문맥별 encoding 또는 sanitizer를 적용한다.
  • innerHTML, eval, 문자열 기반 setTimeout 같은 위험 sink를 리뷰한다.

브라우저와 세션 경계

  • helmet 또는 동등한 방식으로 기본 보안 헤더를 적용한다.
  • CSP가 Report-Only에만 머물지 않고 실제 enforcement 되는지 확인한다.
  • 쿠키에 HttpOnly, Secure, SameSite가 설정되어 있다.
  • 쿠키 기반 인증의 상태 변경 요청에 CSRF 토큰 또는 동등 방어가 있다.
  • CORS를 CSRF 방어로 오해하지 않는다.
  • LocalStorage 장기 토큰 저장을 피한다.

운영 비용과 인프라 경계

  • 프로덕션 환경에서 HTTPS가 강제되고 HSTS가 설정되어 있다.
  • 인증, 비밀번호 재설정, 인증번호 발송에 별도 rate limit이 있다.
  • WAF Count/Block 지표와 애플리케이션 429 지표를 함께 본다.
  • 패스워드 저장은 Argon2id를 우선 검토하고, bcrypt라면 운영 유사 환경의 p95/p99와 로그인 SLO로 cost를 선택한다.
  • bcrypt 사용 시 72바이트 입력 경계를 정책으로 다룬다.
  • 해싱 latency와 로그인 부하가 바뀌면 cost 선택을 다시 측정한다.

공급망과 배포 경계

  • lockfile을 커밋하고 lockfile diff를 리뷰한다.
  • 새 의존성의 transitive dependency와 install script를 확인한다.
  • CI job 권한과 secret 노출 범위를 최소화한다.
  • audit 0건을 공급망 보안 완료로 해석하지 않는다.
  • CDN 스크립트는 버전 고정, SRI, CSP 출처 제한을 함께 검토한다.
  • 환경 변수와 secret이 코드, 로그, 에러 응답에 노출되지 않는다.

Web Security Trust Boundary Input Validation Output Encoding Parameterized Query SQL Injection XSS Stored XSS Reflected XSS DOM XSS CSP Content Security Policy CSRF SameSite Cookie CSRF Token CORS MITM TLS HTTPS HSTS CA Certificate DDoS L3/L4/L7 SYN Flood WAF AWS Shield AWS WAF Rate Limiting bcrypt Argon2id Salt Stretching 72-byte truncation Supply Chain Security package-lock.json Integrity Hash Least Privilege Defense in Depth



웹 보안의 핵심은 “어떤 값을 어디까지 믿을 것인가”를 계속 묻는 것이다. SQL Injection과 XSS는 데이터와 명령어 경계가 무너진 사례다. CSRF는 브라우저가 쿠키를 자동으로 붙이는 자격 경계의 문제다. MITM은 통신 채널을 믿을 수 없을 때 생기는 문제다. DDoS와 brute force는 공격자와 방어자의 비용 비대칭 문제다. 공급망 공격은 외부 코드가 내부 권한으로 실행되는 출처 경계의 문제다.

한 가지 방어책은 충분하지 않다. 입력 검증, 출력 인코딩, parameterized query, 쿠키 속성, CSRF 토큰, TLS, HSTS, WAF, rate limiting, 안전한 패스워드 해싱, lockfile, 최소 권한이 서로 다른 실패 지점을 나눠 막는다.

가장 중요한 실무 기준은 다음 한 문장이다.

외부에서 들어온 값, 자동으로 붙는 자격, 외부에서 받은 코드는 모두 신뢰 경계를 통과해야 하며, 한 겹이 실패해도 다음 겹이 피해를 줄여야 한다.