CSP chặt và Trusted Types cho một single-page app dùng Vite

CSP đặt bằng thẻ meta lúc build, những gì nó không làm được, policy Trusted Types mặc định, và phép thử eval của Zod sinh vi phạm mỗi lần tải.

· Đăng ngày · 7 phút đọc · Phiên bản: Vite 8, React 19, Zod 4 · Về dự án →

Content Security Policy (CSP) là lớp phòng thủ cuối cùng của trình duyệt trước cross-site scripting: kể cả khi kẻ tấn công chèn được markup vào trang, script nào chính sách không cho phép sẽ không chạy. Với một single-page app build bằng Vite, phần khó không nằm ở các directive mà ở những chi tiết xung quanh: chính sách đặt ở đâu, nó làm hỏng gì lúc phát triển, và thư viện nào âm thầm thử chạy code từ chuỗi. Bài này đi qua chính sách của một PWA lập kế hoạch tài chính, gồm cả Trusted Types, và một thiết lập của thư viện mà nếu thiếu sẽ sinh một lần vi phạm mỗi khi tải trang.

App là kế hoạch tài chính, một PWA local-first (React, Vite, phục vụ dưới dạng file tĩnh). Dữ liệu nằm trong IndexedDB trên thiết bị, nên XSS kiểu DOM là kiểu tấn công đáng lo nhất: một script chèn được vào trang đọc được mọi thứ mà không cần chạm tới server.

Chính sách, từng directive một

Chính sách được dựng lúc build từ biến môi trường, vì origin của API và Keycloak khác nhau giữa các lần deploy (rút gọn từ vite.config.ts):

const csp = [
  "default-src 'self'",
  "script-src 'self'",
  // Toasts inject a <style> tag and popups position themselves with inline styles.
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: blob:",
  "font-src 'self'",
  `connect-src 'self' ${origins}`,
  // oidc-client-ts may renew the session in a hidden iframe on Keycloak's origin.
  `frame-src ${auth ?? "'none'"}`,
  "worker-src 'self'",
  "object-src 'none'",
  "base-uri 'none'",
  "form-action 'self'",
  "require-trusted-types-for 'script'",
  "trusted-types default",
].join("; ");

Dòng quan trọng nhất là script-src 'self': không 'unsafe-inline', không 'unsafe-eval', không CDN. Chỉ file phục vụ từ chính origin của app mới được chạy. connect-src giới hạn nơi dữ liệu được gửi tới chỉ còn app, API và nhà cung cấp danh tính, nên một script có lọt vào được cũng không dùng fetch để gửi nội dung IndexedDB đi nơi khác.

style-src 'unsafe-inline' là một đánh đổi phổ biến. Thư viện toast chèn một thẻ <style>, và các popover tự định vị bằng thuộc tính style inline. Style inline không chạy được code, nhưng có thể bị dùng cho các mẹo rò rỉ dữ liệu bằng selector CSS. Muốn bỏ nó thì mọi thư viện phải đặt style qua CSSOM thay vì thuộc tính style hay thẻ <style>, vì một app tĩnh không có server để cấp nonce. Một hash 'sha256-...' có thể cho phép một khối inline cố định như thẻ <style> của màn hình chờ trong index.html, hiện cũng đang dựa vào 'unsafe-inline', nhưng không áp dụng được cho style do thư viện chèn hay thuộc tính style. Khi script-src đã chặt và connect-src đã giới hạn, đây là directive ít rủi ro hơn để nới.

Một thẻ meta, chỉ áp dụng cho bản build

App được host dưới dạng file tĩnh, nên chỗ đơn giản nhất để đặt một chính sách phụ thuộc biến lúc build là ngay trong HTML. Một plugin Vite nhỏ chèn nó vào (cũng đã rút gọn):

const contentSecurityPolicy = ({ api, auth }: { api?: string; auth?: string }): Plugin => ({
  name: "content-security-policy",
  apply: "build",
  transformIndexHtml: () => [
    { tag: "meta", attrs: { "http-equiv": "Content-Security-Policy", content: csp }, injectTo: "head-prepend" },
  ],
});

apply: "build" giữ nó ngoài dev server, vốn chèn script inline cho hot module replacement và React Fast Refresh. Chính sách sẽ chặn những script đó. injectTo: "head-prepend" cũng quan trọng: chính sách trong thẻ <meta> chỉ áp dụng cho nội dung đứng sau nó, nên nó phải là thứ đầu tiên trong <head>.

Cái giá của việc chỉ có chính sách ở bản build là một vi phạm có thể lọt qua pnpm dev. Chạy pnpm build && pnpm preview trước mỗi lần phát hành là bước kiểm bắt được nó.

Những gì thẻ meta không làm được

Đặc tả CSP bỏ qua ba directive khi chính sách đến từ một thẻ <meta>: frame-ancestors, report-uri và sandbox. Hai trong số đó quan trọng ở đây.

Chống clickjacking phải là một header. frame-ancestors là thứ chặn một trang khác tải app vào một frame vô hình. Cấu hình của nơi host gửi nó kèm X-Frame-Options cũ hơn, cho các trình duyệt ra đời trước nó:

{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Content-Security-Policy", "value": "frame-ancestors 'none'" }

Một trang có thể có nhiều chính sách, và trình duyệt áp dụng tất cả; mỗi request phải qua được hết. Vì vậy header chỉ thêm frame-ancestors không nới lỏng chính sách trong thẻ meta, nó chỉ thêm đúng một quy tắc mà thẻ meta không mang được.

Vi phạm không có endpoint để báo. Không có report-uri thì trình duyệt chặn trong im lặng. Thay vào đó, trang tự báo: nó lắng nghe sự kiện securitypolicyviolation và gửi directive bị vi phạm qua endpoint báo lỗi của chính app, như mình đã viết trong bài về báo lỗi phía client. Một vi phạm hoặc là một cuộc tấn công, hoặc là một chính sách quá chặt, và cả hai đều đáng biết.

Trusted Types: không có HTML nào được dựng từ chuỗi

CSP chặn script từ origin không tin cậy, nhưng không chặn code mà chính script của app dựng từ chuỗi: element.innerHTML = userInput là ví dụ kinh điển của DOM XSS. Trusted Types lấp khoảng trống đó. Với require-trusted-types-for 'script', trình duyệt từ chối chuỗi thường ở các điểm nguy hiểm (innerHTML, script.src, eval và một số chỗ khác) và chỉ nhận đối tượng do một policy tạo ra.

Directive thứ hai, trusted-types default, quy định những policy nào được phép tồn tại: một, tên là default. Policy mặc định có vai trò đặc biệt: trình duyệt tự gọi nó mỗi khi một chuỗi đi tới điểm nguy hiểm. Policy của app này chỉ cài đúng một phép chuyển đổi:

window.trustedTypes.createPolicy("default", {
  createScriptURL: (url: string) => {
    if (new URL(url, location.href).origin === location.origin) return url;
    throw new TypeError(`Script URL not allowed: ${url}`);
  },
});

URL script cùng origin với app thì qua, đúng thứ cần để đăng ký service worker. Không có createHTML, không có createScript, nên mọi cố gắng biến một chuỗi thành HTML hay code đều ném lỗi. React dựng DOM bằng createElement và text node; chỉ dangerouslySetInnerHTML mới chạm tới điểm nhận HTML, và app không dùng nó, cũng không dùng innerHTML. Nếu có gì đó chạm tới, lỗi được báo như mọi lỗi khác.

Hai chi tiết giúp cách này an toàn để đưa lên production. Policy được tạo trong main.tsx ngay sau các import, trước khi app render, và vì directive chỉ cho phép một policy tên default, mọi lần tạo thêm về sau đều thất bại. Còn hàm khởi tạo thì thoát sớm khi không có window.trustedTypes, nên trình duyệt chưa hỗ trợ Trusted Types chỉ đơn giản bỏ qua directive và chạy app bình thường. Hãy xem mức hỗ trợ hiện tại của các trình duyệt trước khi dựa vào nó như lớp phòng thủ duy nhất.

Thư viện thử eval ở mỗi lần tải trang

Với một chính sách chặt như vậy, có một thư viện sẽ báo vi phạm ở mỗi lần tải trang: script-src chặn một lần eval, dù không chỗ nào trong app gọi eval. Nguồn gốc là Zod 4, thư viện kiểm tra dữ liệu.

Zod có thể biên dịch schema thành parser nhanh bằng new Function. Để biết mình có được phép không, nó thử một lần bằng cách gọi new Function("") trong một khối try. Dưới chính sách chặt, lời gọi ném lỗi, Zod bắt lỗi rồi quay về chế độ thông dịch, và việc kiểm tra dữ liệu vẫn chạy đúng. Nhưng trình duyệt vẫn báo vi phạm cho lần thử đó dù exception đã bị bắt, nên lần tải trang nào cũng gửi về một bản báo không có ý nghĩa.

Zod có thiết lập cho chuyện này, jitless: true, và tài liệu về CSP của Zod gợi ý gọi z.config({ jitless: true }). Vấn đề nằm ở thời điểm. Zod xác định có được biên dịch hay không lúc một schema được tạo, và schema được tạo ngay khi module được nạp. Trong một bản build Vite chia thành nhiều chunk, không có chỗ nào đáng tin cậy trong code của app chắc chắn chạy trước mọi module có định nghĩa schema.

Lời giải nằm ở cách Zod lưu cấu hình: trên globalThis.__zod_globalConfig, dùng chung cho mọi bản Zod, và được đọc khi Zod nạp. Một script thường (classic script) chạy trước các module script của app, nên một file nhỏ trong public/ đặt nó trước:

// public/csp.js, loaded by index.html before the app's code
globalThis.__zod_globalConfig = Object.assign(globalThis.__zod_globalConfig || {}, { jitless: true });

Nó phải là một file, không thể là thẻ <script> inline: chính sách chỉ cho phép script từ origin của app, nên code inline sẽ tự bị chặn.

Chính sách này không bảo vệ được gì

  • Dependency bị chiếm quyền. script-src 'self' tin mọi file do bản build sinh ra, kể cả code từ package npm. Một package độc hại được phục vụ từ chính origin của app và sẽ chạy.
  • Style inline. Như đã nói, 'unsafe-inline' cho style vẫn để ngỏ các mẹo dựa trên CSS.
  • Trình duyệt không có Trusted Types. Ở đó, lớp chống DOM XSS chỉ còn cơ chế escape của React và việc review code.
  • Dev server. Nó chạy không có chính sách, nên chỗ đầu tiên một vi phạm mới lộ ra có thể là bản build production.

Tóm tắt

Vấn đề Cách làm
Origin khác nhau giữa các lần deploy Chính sách dựng lúc build bằng một plugin Vite
HMR cần script inline Plugin chỉ áp dụng cho bản build; kiểm bằng build và preview
frame-ancestors bị bỏ qua trong thẻ meta Gửi bằng response header, kèm X-Frame-Options
Thẻ meta không có report-uri Trang tự báo securitypolicyviolation qua endpoint báo lỗi
DOM XSS qua các điểm nhận chuỗi Trusted Types với một policy default duy nhất cho URL script cùng origin
Phép thử eval của Zod bị báo ở mỗi lần tải Đặt jitless trên globalThis bằng một script chạy trước app

Nếu bạn đang chạy CSP chặt cho một single-page app, mình rất muốn được trao đổi xem thư viện nào đã gây khó cho bạn. Email của mình ở trang chủ.