XSS trong React/Next.js: Làm chủ dangerouslySetInnerHTML an toàn
Hướng dẫn ngăn XSS trong React và Next.js: auto-escaping, dangerouslySetInnerHTML, DOMPurify, URL validation, CSP nonce, Trusted Types, JSON-LD, Markdown và checklist production.
ShanverseCập nhật 12 phút đọc— lượt xemĐang tải lượt xem
Next.js
React
XSS
XSS thực sự nguy hiểm ở đâu?
Cross-Site Scripting xảy ra khi browser thực thi nội dung do attacker tác động như code của website. Code đó chạy trong security context của origin hợp lệ và có thể:
Đọc dữ liệu mà JavaScript của trang được phép đọc.
Gọi API bằng session hiện tại của nạn nhân.
Sửa DOM để giả mạo form đăng nhập hoặc giao dịch.
Đọc token trong localStorage.
Gửi dữ liệu tới server do attacker kiểm soát.
Thực hiện hành động thay người dùng dù session cookie là HttpOnly.
Bình luận
HttpOnly giảm nguy cơ đánh cắp cookie nhưng không vô hiệu hóa XSS. Script độc hại vẫn có thể gửi same-origin requests từ browser của nạn nhân.
Ba nhóm XSS cần nhận diện
Stored XSS
Payload được lưu trong database/CMS—ví dụ bio, comment hoặc rich-text article—rồi hiển thị cho nhiều người.
Reflected XSS
Input từ URL, query string hoặc form được phản chiếu trực tiếp vào response mà không xử lý đúng context.
DOM-based XSS
Client-side code lấy dữ liệu từ location, postMessage, storage hoặc API rồi đưa vào sink như innerHTML, insertAdjacentHTML hay dangerouslySetInnerHTML.
React giảm nhiều lỗi output encoding, nhưng escape hatches, URL handling, third-party scripts và direct DOM APIs vẫn tạo sink.
React bảo vệ mặc định như thế nào?
Khi render value bằng JSX:
React coi body là text, không phải HTML. Các ký tự có ý nghĩa markup được encode trước khi đưa vào DOM. Nếu body chứa chuỗi trông giống một thẻ script hoặc event handler, nó được hiển thị như text thay vì được parse thành element.
Đây là lựa chọn an toàn nhất: giữ dữ liệu là dữ liệu.
dangerouslySetInnerHTML làm gì?
React chuyển string tới cơ chế tương đương innerHTML. Browser parse nó như HTML. Tên prop dài và object __html là “speed bump” để buộc developer chú ý, không phải sanitizer.
Nếu html chứa markup có khả năng thực thi—event attributes, URL scheme nguy hiểm, SVG/MathML edge cases hoặc browser parsing tricks—code có thể chạy.
Vì sao blacklist và regex không đủ?
Cách xử lý kiểu này không an toàn:
XSS không chỉ dùng <script>. HTML parser có nhiều namespaces, attributes, malformed markup, URL contexts và encoding variants. Regex không mô phỏng browser parser đầy đủ.
Tương tự, chỉ xóa chuỗi onerror= hoặc javascript: dễ bị bypass bởi cách viết hoa, encoding, whitespace và context khác nhau.
Dùng parser-based sanitizer được duy trì, không tự viết sanitizer.
Chiến lược triệt để: loại bỏ raw HTML trước
Không có một header hay library đơn lẻ “chặn XSS triệt để”. Cách mạnh nhất là thiết kế để untrusted input không bao giờ trở thành HTML.
Thứ tự ưu tiên:
Plain text trong JSX.
Structured rich-text AST với renderer allow-list.
Markdown không cho raw HTML.
Sanitized HTML qua một component trung tâm.
Raw HTML chỉ dành cho content thực sự trusted và đã review.
Render structured content
Chỉ các node được định nghĩa mới có thể xuất hiện. Text vẫn được React escape.
Khi bắt buộc phải render HTML: sanitize bằng allow-list
DOMPurify là lựa chọn phổ biến cho browser DOM. Ví dụ client component:
Policy trên chỉ là điểm bắt đầu. Allow-list phải khớp product requirements:
Blog có cần images không?
Links được phép tới origins nào?
Có cần class hay data-* không?
Có cho SVG/MathML không?
Có cho inline style không?
Có cho iframe từ video provider không?
Policy càng rộng, attack surface càng lớn.
Sanitize ở server hay client?
Ưu tiên sanitize tại trust boundary trước khi nội dung được phân phối, nhưng defense có thể có cả hai phía:
Sanitize khi ingest/update để từ chối hoặc tạo sanitized version.
Re-sanitize khi render nếu policy/library đã thay đổi hoặc raw data đến từ nguồn ngoài.
Client sanitization là lớp cuối cho dữ liệu động, không phải lý do để server bỏ kiểm soát.
DOMPurify cần DOM. Khi chạy server-side Node.js, dùng integration cung cấp DOM implementation tương thích và luôn cập nhật nó; chính DOM emulator cũ cũng có thể có parsing bugs. Một package isomorphic hoặc sanitizer server-side chuyên dụng giúp wiring dễ hơn, nhưng vẫn phải audit version và config.
Đừng mutate sau khi sanitize
Nếu một library khác sửa HTML sau sanitizer, guarantee có thể mất. Sanitize phải là bước cuối trước sink hoặc toàn bộ post-processing phải preserve security invariant.
Tạo một “choke point” duy nhất
Không để hàng chục components tự gọi dangerouslySetInnerHTML:
Dùng branded type để phân biệt raw và sanitized string:
Brand không tạo security lúc runtime; nó làm dataflow rõ hơn trong review và ngăn developer vô tình truyền raw string. Chỉ module sanitizer mới được cast/construct type này.
URL cũng là một context nguy hiểm
React escaping HTML không tự quyết định URL nào được phép theo business policy.
Hãy parse và allow-list scheme:
Production policy thường cần chặt hơn:
Chỉ https:.
Có thể chỉ same-origin hoặc host allow-list.
Quyết định rõ mailto:/tel:.
Reject credentials trong URL nếu không cần.
Không cho javascript: hay arbitrary data:.
Không dùng một encoder cho mọi context. HTML text, attributes, URL, CSS và JavaScript string có quy tắc khác nhau.
Markdown, MDX và CMS
Markdown
Cấu hình parser để raw HTML bị disable. Render AST thành React elements bằng allow-list. Nếu bật raw HTML plugin, sanitize AST/HTML sau parse bằng integration được thiết kế cho pipeline đó.
MDX
MDX có thể chứa và thực thi JavaScript/components. Không compile MDX do user không tin cậy cung cấp trong application runtime. Chỉ dùng MDX cho source đã review ở build/deploy pipeline hoặc chạy trong isolation phù hợp.
CMS
Phân quyền ai được nhập raw HTML.
Sanitize preview và published output như nhau.
Version sanitizer policy.
Khi policy đổi, reprocess content cũ.
Không coi nội dung migration/import là trusted mặc định.
JSON-LD và inline script trong Next.js
JSON-LD thường được render bằng dangerouslySetInnerHTML:
JSON.stringify một mình không phải HTML-safe serializer khi output nằm trong <script>. Ít nhất phải neutralize <; với dữ liệu phức tạp, dùng serializer được thiết kế cho safe script embedding và test closing-tag/Unicode edge cases.
Không đưa secrets vào page source. JSON-LD là public.
CSP trong Next.js: lớp giảm thiểu quan trọng
Content Security Policy giới hạn nguồn script và các khả năng thực thi. Strict policy có thể dùng per-request nonce:
Trong Next.js, nonce phải unique, unpredictable và sinh lại cho từng request. Next.js có thể đọc nonce từ CSP header khi dynamic rendering và gắn nó vào framework scripts. Nonce-based CSP thường buộc route render động vì static build không có request để sinh nonce.
Triển khai thực tế:
Bắt đầu bằng Content-Security-Policy-Report-Only.
Thu thập violations và loại bỏ inline/eval dependencies.
Chuyển sang enforcing policy.
Theo dõi reports sau mỗi deploy.
Không thêm 'unsafe-inline' hoặc 'unsafe-eval' chỉ để “hết lỗi”.
Trusted Types: khóa các DOM XSS sinks
Trusted Types cho phép browser yêu cầu sink nguy hiểm chỉ nhận giá trị do policy đã đăng ký tạo ra. CSP directive:
Mục tiêu là biến việc truyền raw string vào innerHTML thành runtime violation. Policy nên tập trung sanitizer ở một nơi, không được chỉ “return input” vì như vậy Trusted Types trở thành tem tin cậy giả.
DOMPurify có hỗ trợ trả TrustedHTML trong browsers phù hợp. Tích hợp với React/TypeScript và third-party libraries cần test theo phiên bản; rollout bằng report/monitoring trước khi enforce.
Next.js Server Components có tự an toàn không?
Không. Server Components giảm JavaScript client trong nhiều trường hợp, nhưng nếu server tạo raw HTML và client/browser parse nó ở sink nguy hiểm thì XSS vẫn tồn tại.
Tương tự:
Server Actions không sanitize output tự động.
SSR không biến untrusted HTML thành trusted.
Hydration không sửa markup độc hại đã được browser parse.
use client hay server-only là deployment boundary, không phải trust boundary.
Cookies và session hardening
Đặt session cookie:
HttpOnly để JavaScript không đọc trực tiếp.
Secure để chỉ gửi qua HTTPS.
SameSite=Lax hoặc Strict khi phù hợp.
Scope Path/Domain tối thiểu.
Rotate/revoke khi có incident.
Các flag giảm hậu quả nhưng không ngăn script XSS thực hiện authenticated actions. Sensitive operations vẫn cần authorization, re-authentication và transaction confirmation phù hợp.
Những sink cần audit ngoài dangerouslySetInnerHTML
element.innerHTML và outerHTML.
insertAdjacentHTML.
document.write.
eval, new Function, string-based timers.
URL assignments và redirects.
Inline script/style generation.
HTML parser APIs.
Third-party widgets/editors.
srcDoc trong iframe.
User-controlled data đưa vào CSS.
Đừng chỉ grep một tên prop rồi kết luận ứng dụng an toàn.
Automated Guardrails
Lint và static analysis
Cấm dangerouslySetInnerHTML ngoài SafeHtml.tsx.
Cấm direct innerHTML assignments.
Flag eval và dynamic code execution.
Dùng CodeQL/Semgrep rules theo framework.
Dependency scanning cho sanitizer, parser và DOM emulator.
Unit tests cho sanitizer policy
Test behavior, không duy trì “payload blacklist” tự chế:
Bổ sung corpus từ sanitizer vendor và security team. Không sửa output sau sanitize.
Integration và security testing
CSP Report-Only violations.
Browser tests cho stored/reflected/DOM flows.
DAST scanner trên staging.
Manual review các rich-text/editor paths.
Test import, preview, admin và legacy routes.
Security regression test cho lỗ hổng đã sửa.
Incident Response
Nếu phát hiện XSS:
Disable vulnerable rendering path hoặc feature flag.
Xác định source, sink và stored payloads.
Patch sanitizer/policy và dependency liên quan.
Purge hoặc re-sanitize dữ liệu đã lưu.
Rotate sessions/tokens nếu impact yêu cầu.
Kiểm tra CSP reports, logs và hành vi bất thường.
Thêm regression tests và scan toàn bộ sink tương tự.
Viết postmortem tập trung vào trust-boundary failure.
Production Checklist
Untrusted text được render bằng JSX, không phải raw HTML.
Structured rich text/AST được ưu tiên.
Markdown raw HTML bị tắt hoặc pipeline có sanitizer.
User-controlled MDX không được compile/excute.
Chỉ một SafeHtml component được dùng sink.
Sanitizer parser-based, allow-list và luôn cập nhật.
Server DOM implementation/sanitizer dependencies được cập nhật.
Không mutate markup sau sanitize.
URL protocols và origins được allow-list.
JSON-LD/inline JSON dùng safe serializer.
CSP strict được rollout từ Report-Only sang enforce.
Nonce unique cho mỗi request; static/dynamic trade-off đã hiểu.
Trusted Types được đánh giá cho browser/runtime phù hợp.
Session cookies dùng HttpOnly, Secure, SameSite phù hợp.
Lint/SAST chặn sink mới ngoài gateway.
Tests bao phủ stored, reflected và DOM XSS paths.
Sanitizer policy có owner, version và reprocessing plan.
Có playbook xử lý stored payload và session rotation.
Kết luận
Tên dangerouslySetInnerHTML là cảnh báo chính xác: nó chuyển một string từ data context sang HTML parser context. React không thể escape nội dung vì developer đã yêu cầu browser parse nó như markup.
Cách ngăn XSS mạnh nhất là không nhận raw HTML: render text hoặc structured AST. Khi product bắt buộc hỗ trợ HTML, hãy đưa mọi dữ liệu qua sanitizer allow-list tại một trust boundary, chỉ render qua một gateway component, validate URL context riêng, rồi thêm strict CSP, Trusted Types, cookie hardening, automated checks và security tests.
Đó là phòng thủ nhiều lớp—không phải lời hứa “một dòng code chặn triệt để”, mà là kiến trúc khiến raw attacker-controlled string rất khó đi tới executable sink.
1constclean=sanitize(dirty);2constchanged=pluginTransform(clean); // có thể tái tạo unsafe markup
Codetypescript
1// SafeHtml.tsx là nơi duy nhất được phép dùng sink này.2exportfunctionSafeHtml({ html }: { html:SanitizedHtml }) {3return <divdangerouslySetInnerHTML={{ __html:html }} />;4}
Thiết lập CSP Header an toàn cho Next.js App Router: directives quan trọng, nonce động, strict-dynamic, third-party scripts, reporting, kiểm thử và checklist production.
Hướng dẫn Virtual Scrolling và Windowing để hiển thị danh sách 100.000 phần tử trên React/Vue: thuật toán, TypeScript, TanStack Virtual, dynamic height và tối ưu production.
Hướng dẫn từng bước tích hợp đa ngôn ngữ (i18n) cho blog Next.js App Router và Notion API. Tối ưu SEO quốc tế, tự động phát hiện ngôn ngữ trình duyệt và giao diện chuyển đổi mượt mà.