목록으로

정화 데코레이터 13개를 붙이고도 남은 구멍 4곳

4 조회
개발 도구 신뢰성
#NestJS#XSS#ValidationPipe#백엔드 검증

저장 시점 정화를 넣는 작업이었다. 사용자가 올린 HTML 이 정화 없이 DB 에 들어가고 그대로 나오는 경로가 두 군데 있어서 프런트 렌더 정화만으로는 부족하다고 보고 백엔드 DTO 에 @SanitizeHtmlContent() 를 붙였다. HTML 본문을 받는 저장 DTO 를 전수 조사해 9개, 파생 클래스까지 13개에 붙였다. 테스트도 13개 전수로 돌렸다. 다 막았다고 생각했다.

전수 조사에서 빠진 저장 경로

소일장 참가작 저장 경로가 목록에 없었다. 이 경로가 만드는 행은 일반 Post 다. 화면 렌더 지점에는 프런트 정화가 걸려 있어 브라우저에서 바로 터지지는 않지만, API 응답을 직접 쓰는 클라이언트에는 원문이 그대로 나간다. 저장 시점 정화가 필요한 자리인데 조사에서 안 걸렸다.

DTO 파일을 찾아봤더니 없었다. 컨트롤러가 이렇게 받고 있었다.

@Post(':id/posts')
async createContestPost(
  @Param('id') contestId: string,
  // ...
  @Body() body: { title: string; content: string; selectedRoundPromptId?: string },
) {

DTO 클래스 대신 인라인 타입이다. 내 조사 방법이 "DTO 파일에서 HTML 본문 필드 찾기" 였으니 DTO 파일이 없는 경로는 처음부터 후보에 안 들어갔다.

인라인 타입이 검증을 건너뛰는 이유

여기서는 데코레이터 하나 빠진 게 문제가 아니었다. TypeScript 는 design:paramtypes 메타데이터에 인라인 타입을 Object 로 방출한다. NestJS ValidationPipe.toValidate() 는 네이티브 타입을 건너뛴다. 그래서 이 경로에서는 검증도, 허용목록 밖 필드 제거도, 정화도 하나도 안 걸린다.

전역 파이프와 같은 설정으로 확인해 봤다.

const transformed = await pipe.transform(
  { content: COMBINED_XSS_PAYLOAD, extraField: "허용목록 밖 필드" },
  { type: "body", metatype: Object },
);
// content 도 extraField 도 입력 그대로 나온다

파이프는 에러를 내지 않는다. 값을 그대로 돌려주고 끝난다. 컨트롤러 입장에서는 통과한 요청과 구분되지 않는다.

데코레이터가 못 잡는 자리

DTO 로 바꿔 그 경로는 닫았다. 그런데 같은 형태가 또 있으면 똑같이 조용히 빠진다. 데코레이터는 붙인 곳만 지키고 안 붙인 곳은 모른다.

그래서 컨트롤러 파일을 정적으로 훑어 인라인 @Body() 를 찾는 검사를 넣었다. 돌렸더니 세 곳이 더 나왔다. 커스터마이징 지급, 이모티콘 지급, 카테고리 순서 변경. 셋 다 HTML 본문이 아니라 저장된 스크립트가 실행되는 문제는 아니었지만 타입도 필수값도 허용목록도 검사되지 않고 있었다.

앞의 둘은 DTO 클래스로 바꾸면 끝났다. 카테고리 순서 변경은 본문이 배열이었다.

@Body() orders: { id: string; order: number }[]

배열은 Array 로 방출된다. @Body() orders: ReorderCategoryItemDto[] 로 타입만 바꿔도 방출되는 값은 여전히 Array 라 파이프가 똑같이 건너뛴다. 원소 타입을 따로 알려줘야 한다.

@Body(new ParseArrayPipe({
  items: ReorderCategoryItemDto,
  whitelist: true,
  forbidNonWhitelisted: true,
}))
orders: ReorderCategoryItemDto[]

개발 서버 응답에서 나온 두 번째 문제

배포하고 개발 서버에서 실제로 확인했다. 관리자 계정으로 잘못된 값을 넣어 400 이 나오는지 봤다.

PUT /api/categories/reorder  [{"id":"cat-1","order":"첫번째"}]
→ 400 {"status":"fail","message":"property 0 should not exist"}

400 은 맞는데 메시지가 이상했다. order 필드를 짚는 메시지가 나와야 하는 자리다. property 0 은 배열을 객체로 놓고 검사했다는 뜻이다.

컨트롤러를 다시 봤다. @Put(':id') 가 위에, @Put('reorder') 가 아래에 있었다. NestJS 는 선언 순서로 찾으므로 PUT /categories/reorder 요청은 update(id = 'reorder') 로 들어간다. 배열이 UpdateCategoryDto 로 검사되면서 저 메시지가 나온 것이다.

순서 변경 기능은 그때까지 한 번도 동작한 적이 없었다. 라우트를 처음 넣은 커밋부터 순서가 뒤집혀 있었다. 관리자 화면은 실패 토스트를 띄우고 있었으니 화면에서는 보였고, 서버 쪽에는 500 이 남지 않아 오류 지표로는 잡히지 않았다.

처리와 검증

@Put('reorder')@Put(':id') 위로 올렸다. 컨트롤러 테스트에서 실제 Nest 앱을 띄워 요청이 어느 핸들러로 들어가는지 고정했고 저장소의 모든 컨트롤러 파일을 훑어 같은 형태를 찾는 검사도 넣었다. 한 파일 안에서의 선언 순서만 보는 검사라 여러 컨트롤러가 같은 prefix 를 나눠 가진 경우는 못 잡는다. 지금 기준으로는 한 건도 없다.

저장 시점 정화가 실제로 도는지는 개발 서버에 요청을 보내 확인했다.

입력  <p>정상 문단</p><script>alert(1)</script><img src=x onerror=alert(2)><a href="javascript:alert(3)">링크</a>
저장  <p>정상 문단</p><img src="x" /><a>링크</a>

이미 저장된 데이터도 확인이 필요했다. 정화는 새로 쓰는 값만 바꾸기 때문이다. 다섯 개 컬럼에 공격 패턴 쿼리를 돌렸다. 개발 서버는 게시글 22행, 작가노트 5행 중 0건이었고 운영 서버도 0건이었다. 되돌릴 데이터가 없어 별도 마이그레이션은 만들지 않았다.

고친 뒤 값

검사 통과 수는 24개에서 33개가 됐다. 인라인 @Body() 검사 3개, 라우트 선언 순서 검사 3개, 그 사이 다른 작업에서 들어온 3개가 늘었다.

백엔드 테스트는 65개 묶음 774개에서 70개 묶음 806개가 됐다. 늘어난 32개 중 21개가 이번 작업 것이다. 정화 데코레이터 전수 검사 5개, 지급 DTO 두 벌 7개, 순서 변경 원소 검사 5개, 컨트롤러 요청 경로 검사 4개. 나머지 11개는 그 사이 다른 작업에서 들어온 본문 파싱 검사다.

카테고리 순서 변경은 이제 실제로 저장된다. 같은 요청(order 에 문자열)을 다시 보내면 order must not be less than 0 이 돌아온다. 원소를 실제로 검사한 결과다. 이전에는 property 0 should not exist 였다.

남은 것

style 속성 값에 대한 허용 목록(allowedStyles)은 아직 걸지 않았다. 정화기가 못 하는 일이 아니라 설정을 안 한 것이다. 목록을 잘못 좁히면 기존 글의 서식이 조용히 사라지는데, 에디터가 실제로 무슨 속성을 쓰는지 세어 보지 않아 미뤘다.

sanitize-html 버전도 고정해 두었다. 상위 버전이 ESM 전용 파서를 요구하는데 백엔드가 CommonJS 라 로드되지 않는다. 고정한 버전을 그대로 쓰는 판단은 허용 목록이 좁다는 전제 위에 서 있고, 전제가 맞는지는 공격 벡터를 직접 던져 확인했다. 다만 그 판단은 버전이 아니라 목록에 기대고 있다. 목록을 넓히는 변경이 생기면 판단부터 다시 해야 한다.


댓글

댓글 작성