저장값과 화면 출력 맥락
70분 안팎
학습 목표
HTML 텍스트 출력과 입력 검증의 역할을 구분합니다.
개념
저장된 문자열은 화면에서 다른 의미를 얻습니다
개인 자료의 제목에 태그처럼 보이는 문자가 들어갈 수 있습니다. DB에 저장할 때는 단순한 문자열이었지만 HTML 응답에 그대로 붙이면 브라우저가 마크업으로 해석할 수 있습니다. 입력이 DB를 바꾸지 않았다는 사실은 화면 해석의 안전성을 증명하지 않습니다. 이번 목표는 자료를 원문으로 보관하고 화면의 HTML 텍스트 위치에서만 변환하여 제목과 본문을 문자 그대로 표시하는 것입니다.
교재 fixture는 무해한 b 태그 모양 제목과 A&B 같은 텍스트입니다. 태그처럼 보이는 문자열을 넣어 출력 형태를 비교하지만 외부 사이트를 호출하거나 공격 스크립트를 실행하지 않습니다. 문자열 검사에서 마크업이 그대로 나타났다는 관찰과 실제 브라우저에서 스크립트가 실행되었다는 관찰은 다릅니다. 보고서에는 이번 자동 검사가 HTML 문자열을 비교한다는 범위를 명시합니다.
출력 맥락이 변환 규칙을 결정합니다
renderDocument는 고정 h1 태그 사이에 title, 고정 p 태그 사이에 content를 넣습니다. 두 값은 HTML 텍스트 맥락입니다. escapeText는 앰퍼샌드·작은꺾쇠·큰꺾쇠·따옴표를 엔티티로 바꿉니다. 고정 태그는 작성자가 정한 구조이며 자료 값만 변환합니다. 완성된 HTML 전체를 변환하면 태그도 문자로 보이므로 의도한 제목·본문 구조를 잃습니다. 사용자 자료를 처리하는 함수와 페이지 틀을 분리합니다.
이 함수는 HTML 텍스트 위치를 위한 도구입니다. JavaScript 코드 안의 문자열, CSS 값, URL이나 이벤트 속성에 같은 결과를 붙여도 해당 맥락의 안전성이 보장되지 않습니다. 링크를 만들려면 URL 스킴과 대상 허용 정책부터 정해야 하고 속성 구문도 검토해야 합니다. 이번 앱은 자료를 링크나 스크립트 안에 넣지 않습니다. 출력 위치를 먼저 찾고 그 위치가 바뀔 때 새 규칙과 테스트를 만드는 습관을 익힙니다.
원문 저장과 출력 변환을 분리합니다
DB의 title에 b 태그 모양 입력을 넣어도 저장값은 그대로입니다. JSON 조회는 원문 문자열을 반환하며 view 응답만 엔티티로 바꿉니다. 여러 소비자가 같은 자료를 사용하므로 HTML용 표현을 DB에 저장하지 않습니다. DB에 이미 변환된 문자열을 저장하고 화면에서 다시 변환하면 앰퍼샌드가 한 번 더 바뀌어 엔티티 문자 자체가 보입니다. 저장 값의 계약과 응답 형식의 계약을 각각 테스트합니다.
사용자가 원문으로 <를 적었다면 화면에서도 그 네 문자가 보여야 합니다. 출력용으로는 앰퍼샌드를 바꾸어 &lt;를 만듭니다. 원문이 엔티티처럼 생겼다고 먼저 디코딩하면 사용자가 적은 자료가 바뀌고 이후 처리 순서에 따라 태그로 해석될 수도 있습니다. 이미 변환했는지 추측해서 건너뛰지 않습니다. 한 출력 경로에서 정확히 한 번 적용하고 이전 결과를 다시 입력으로 삼지 않는 구조를 택합니다.
DOM을 만드는 앱이라면 텍스트 API를 씁니다
브라우저에서 화면을 구성할 때는 title 요소의 textContent에 원문 문자열을 넣는 방법이 있습니다. innerHTML은 HTML 구문을 해석하므로 제목 표시의 기본 선택으로 적합하지 않습니다. textContent에 HTML용 엔티티를 넣으면 엔티티가 문자 그대로 보여 사용자 자료가 달라집니다. 서버에서 HTML 문자열을 만드는 방식과 DOM 텍스트를 지정하는 방식은 각각 하나의 출력 전략이며 두 변환을 겹쳐 적용하지 않습니다.
이번 Node 테스트에는 실제 DOM이 없습니다. 따라서 텍스트 API 설명을 테스트로 실행했다고 주장하지 않습니다. 자동 검사는 escapeText와 renderDocument의 결과 문자열, 저장된 원문의 보존, view 응답의 text/html 헤더를 확인합니다. 수동 브라우저 검토에서는 제목에 b 태그 모양 원문이 문자로 보이는지, 개발자 도구에서 자료로부터 b 요소가 생성되지 않았는지 확인합니다. 소켓 없는 자동 관찰과 브라우저 관찰은 증거 항목을 나눕니다.
인가된 자료만 출력 경로에 들어갑니다
안전하게 렌더된 자료도 타인에게 전달하면 정보 노출입니다. secure-app의 GET /documents/ID/view는 세션을 확인하고 기존 canAccess를 통과한 다음 렌더합니다. 타인의 view는 JSON 조회와 마찬가지로 404입니다. HTML 출력 경로를 별도 라우트로 만들면서 소유자 검사를 잊는 것이 흔한 회귀입니다. 렌더링 단위 검사와 HTTP 어댑터의 타인 접근 검사를 같이 유지하여 값 해석과 권한 문제를 각각 잡습니다.
출력 응답의 Content-Type은 text/html; charset=utf-8이며 JSON 응답은 application/json입니다. JSON이 원문을 담는 것은 정상 계약이지만 그 값을 받는 프런트엔드가 다시 innerHTML에 넣으면 새로운 위험이 생깁니다. no-store와 nosniff는 응답 취급에 관한 보완이며 출력 변환을 대체하지 않습니다. CSP 역시 보완 정책으로 검토할 수 있으나 이 교재의 문자열 시험에서 CSP 효과를 입증하지 않습니다.
실습을 수정하고 실패를 읽습니다
security-text-output starter의 escapeText는 입력을 그대로 돌려줍니다. output-test.cjs는 빈 문자열, 일반 문자, 앰퍼샌드, 태그 모양 문자, 따옴표, 엔티티 모양 문자, 한글과 이모지를 비교합니다. escapeText의 매핑을 완성하고 renderDocument의 제목·본문 위치에 적용합니다. bash check.sh가 일곱 fixture와 고정 HTML 구조, 원문 객체 보존을 확인합니다. 새 패키지를 설치하거나 fixture를 삭제하지 않습니다.
AssertionError의 actual에 태그 구문이 있고 expected에 엔티티가 있으면 어느 자료 필드에 변환을 빠뜨렸는지 찾습니다. A&B 같은 결과가 예상보다 한 단계 더 변환되었다면 저장 단계에서 이미 변환했거나 출력 함수를 두 번 호출했는지 확인합니다. 한글이 변했다면 문자 인코딩이나 불필요한 전역 치환을 살펴봅니다. 정상 텍스트도 그대로 보존되는 테스트가 있어야 과한 삭제를 찾아낼 수 있습니다.
출력 정책을 리뷰할 때 먼저 값이 들어가는 모든 위치를 적습니다. 현재는 제목과 본문 두 텍스트 위치뿐입니다. 이후 자료 미리보기 링크나 검색 강조가 생기면 그 위치의 정책을 추가합니다. 임의 HTML을 허용하는 편집 기능은 이번 계약에 없으며 텍스트 변환 함수를 없애서 구현하지 않습니다. 보고서에는 원문 보존, 출력 시점의 맥락 처리, 타인 view 거절을 각 근거와 연결하고 실제 브라우저 확인은 별도 미검증으로 남깁니다.
이 기법의 공식 참고: OWASP 출력 맥락별 방어 안내. 서재 더 읽기에서는 같은 원리를 다른 언어와 운영 맥락에서 확장합니다.
따라하기
변환 전후의 문자열을 비교합니다
무해한 태그 모양 자료와 앰퍼샌드를 처리합니다. 실제 태그 실행이 아니라 HTML 응답 문자열의 변환 결과를 관찰합니다.
function escapeText(value) {
return value.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
for(const value of ['<b>demo</b>','A&B','한글 😀'])console.log(escapeText(value));실행 결과
<b>demo</b> A&B 한글 😀
저장 객체를 바꾸지 않고 화면을 만듭니다
변환할 대상은 두 텍스트 값입니다. 렌더 뒤 원문 제목을 출력하여 저장값이 유지되는지 확인합니다.
function escapeText(value) {
return value.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
const item={title:'<b>demo</b>',content:'A&B'};
console.log(`<h1>${escapeText(item.title)}</h1><p>${escapeText(item.content)}</p>`);
console.log(item.title);실행 결과
<h1><b>demo</b></h1><p>A&B</p> <b>demo</b>
엔티티 모양 원문을 그대로 표시할 준비를 합니다
한 번 변환한 결과를 다시 변환하면 표현이 달라집니다. 출력 경로에 함수를 한 번 적용해야 하는 이유를 비교합니다.
function escapeText(value) {
return value.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
const raw='<';const once=escapeText(raw);
console.log(raw);console.log(once);console.log(escapeText(once));실행 결과
< &lt; &amp;lt;
로컬 과제에서 수정 전후를 검사합니다
이 레슨의 starter ZIP을 풀어 ZIP 루트에서 bash check.sh를 실행합니다. starter의 실패를 읽은 뒤 본문에서 지정한 함수나 질의를 수정합니다. solution ZIP에서도 같은 명령을 실행하여 정상 대조를 확인합니다. 출력 시간 등은 실행마다 달라질 수 있으며 마지막 PASS 행과 종료 코드 0을 확인합니다.
확인 문제
실습
security.cjs의 escapeText를 HTML 텍스트 맥락에 맞게 완성합니다. 원문 객체는 보존하고 고정 제목·본문 태그 내부에만 변환값을 넣습니다. 빈 값·앰퍼샌드·태그 모양·따옴표·엔티티 모양·한글과 이모지 fixture가 모두 통과해야 합니다. JavaScript·URL·이벤트 속성용 함수로 확대하지 않습니다.
실행 명령
bash check.sh
기대 결과
PASS: seven text fixtures, HTML context, raw value preserved
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 취약점 수정 전후의 테스트 내용을 설명합니다.