# Nhúng widget AI vào web của khách: chọn iframe hay Web Component, rồi đọc đúng CSP

> Chạy tốt trên staging nhưng lên production của khách thì widget chỉ còn một ô trống. Lỗi gần như luôn nằm ở ranh giới giữa hai trang, ít khi nằm trong code của bạn.

Bản gốc: https://fdetimes.net/vi/bach-khoa/nhung-widget-ai-vao-ung-dung-web-cua-khach-iframe-web-components-csp/

Thử hình dung cảnh này. Buổi demo trên staging chạy trơn tru, khách gật đầu, lịch go-live đã chốt. Sáng hôm deploy lên cổng khách hàng thật, chỗ của trợ lý AI chỉ còn một khung trắng, còn console thì đỏ một dòng "Refused to frame".

Không có dòng code nào của bạn thay đổi. Cái thay đổi là trang chứa nó: production của khách có Content Security Policy chặt, staging thì không.

Ai đọc nhanh được ranh giới giữa hai trang thì sửa xong trong một buổi sáng. Ai không đọc được thì mất một tuần gửi email qua lại với đội bảo mật của khách.

Bài này dạy cách đọc ranh giới đó: chọn iframe hay Web Component, mỗi cách cần khách mở những gì, và làm sao để hai trang nói chuyện với nhau mà không mở thêm lỗ hổng.

## Một iframe có hai người gác cổng

Tài liệu về CSP của Chrome for Developers xuất phát từ một thực tế: mặc định, trình duyệt chạy mọi đoạn mã mà trang yêu cầu. Vì vậy trang phải khai báo allowlist các nguồn nó tin. Với iframe, có hai allowlist nằm ở hai phía.

Ở trang khách, `frame-src` quy định trang đó được tải iframe từ đâu. Ở trang widget của bạn, `frame-ancestors` quy định trang cha nào được phép nhúng nó. MDN nói rõ hai chỉ thị này khác chiều nhau. Khi gặp ô trắng, câu hỏi đầu tiên là: bên nào đang từ chối?

Có hai cái bẫy hay khiến người ta chẩn đoán sai. Bẫy thứ nhất: nếu CSP của khách không có `frame-src`, trình duyệt lùi về `child-src`, rồi tiếp về `default-src`. Một dòng `default-src 'self'` trông vô hại đủ để chặn iframe của bạn, dù khách khẳng định họ "không chặn frame nào cả".

Bẫy thứ hai nằm ở phía bạn: `frame-ancestors` không có tác dụng khi khai báo trong thẻ ``, nó phải được gửi bằng HTTP header. Nếu widget đặt trên một static host và chính sách nằm trong HTML, coi như bạn chưa khai báo gì. Hãy cấu hình nó ở CDN hoặc server.

## Iframe hay Web Component: chọn ranh giới trước, công nghệ sau

Web Components là bộ ba custom elements, Shadow DOM và HTML templates. Theo MDN, mục tiêu của bộ ba này là đóng gói để ít va chạm tên và style với trang chủ. Shadow DOM giữ style và ID của widget tách biệt với phần còn lại của trang, nên CSS của khách không làm vỡ nút bấm của bạn, và ngược lại.

Cần nói rõ chỗ dễ hiểu lầm: đó là cách ly về giao diện, không phải về bảo mật. Một custom element chạy ngay trong tài liệu của khách.

Hệ quả kéo theo khá thẳng: đã nằm trong trang của khách thì script và các lời gọi API của widget cũng phải đi qua CSP của trang đó, như mọi script khác ở đấy. Iframe trên một origin riêng thì nạp widget thành một tài liệu riêng, và hai bên trao đổi qua `postMessage`, một kênh mà bạn kiểm soát được cả hai đầu.

**Điểm mấu chốt:** Widget nhúng chạy theo luật của trang khách, không theo luật của bạn.

Từ đó có một quy tắc chọn dễ dùng. Widget cần hòa vào giao diện và đọc được trạng thái trang, còn khách chấp nhận chạy code của bạn trong trang của họ, thì Web Component hợp lý. Widget giữ token, lịch sử hội thoại hay dữ liệu người dùng cuối mà bạn không muốn script nào khác trên trang chạm tới, thì chọn iframe.

## Ví dụ: trợ lý giọng nói trong cổng khách hàng

Giả sử bạn đưa một trợ lý AI có nhận giọng nói vào `https://app.khach.example`. Widget nằm ở `https://widget.vendor.example`, API ở `https://api.vendor.example`. Bạn chọn iframe vì widget giữ phiên hội thoại. Việc đầu tiên là khai báo phía bạn bằng header:

```http
Content-Security-Policy: frame-ancestors https://app.khach.example
```

Phía khách, giả sử header hiện tại chỉ có `default-src 'self'`. Theo quy tắc lùi, iframe sẽ bị chặn. Bạn đề nghị khách thêm đúng một chỉ thị chứ không nới `default-src`:

```http
Content-Security-Policy: default-src 'self';
  script-src 'self' 'nonce-{giá-trị-mới-mỗi-request}' 'strict-dynamic';
  frame-src https://widget.vendor.example
```

Đoạn snippet nạp widget là script nội tuyến, nên nó cần nonce. Infobip, trong tài liệu về widget Live Chat, khuyên dùng nonce và không khuyến khích `unsafe-inline` trừ khi chỉ thị của khách vốn đã có nó. Chrome for Developers nhắc thêm: nonce phải sinh mới cho mỗi request và không được đoán ra. Một nonce viết cứng trong template thì không bảo vệ được gì.

Vì sao có `strict-dynamic`? Atlassian giải thích trong tài liệu widget của họ: script nạp widget được cập nhật thường xuyên, nên CSP dựa trên hash không phù hợp, và hãng khuyên dùng `strict-dynamic`.

Atlassian cũng yêu cầu khách mở `frame-src` tới host của widget, đúng như ví dụ trên.

Đến phần giọng nói. Thẻ iframe cần thuộc tính `allow`:

```html
<iframe src="https://widget.vendor.example/chat?tenant=khach"
        allow="microphone"
        sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
```

MDN lưu ý: `allow` chỉ siết thêm trên chính sách mà header `Permissions-Policy` của trang đã đặt, nó không thay thế header đó. Nếu khách gửi `Permissions-Policy: microphone=()`, nút micro của bạn sẽ im lặng, dù thẻ iframe có ghi gì đi nữa. Câu hỏi này nên có trong buổi discovery chứ đừng để đến ngày go-live mới hỏi.

Thuộc tính `sandbox` thì phải đọc kỹ. MDN cảnh báo mạnh về việc dùng chung `allow-scripts` và `allow-same-origin` khi nội dung nhúng cùng origin với trang cha, vì khi đó trang nhúng có thể tự gỡ sandbox.

Cấu hình ở trên chỉ chấp nhận được vì widget nằm trên origin riêng. Nếu một ngày khách đề nghị proxy widget về `app.khach.example/assistant` cho "gọn", đó là lúc phải bỏ một trong hai cờ.

## postMessage là cửa sau nếu bạn quên kiểm tra origin

Trang khách thường cần truyền ngữ cảnh vào widget, ví dụ người dùng đang xem đơn hàng nào. Widget thì cần báo ra ngoài, ví dụ đã chuyển cuộc hội thoại cho nhân viên. Kênh chuẩn cho việc này là `postMessage`, và MDN đặt ra hai quy tắc cho nó.

Khi gửi, luôn ghi `targetOrigin` cụ thể, đừng dùng `"*"`, vì cửa sổ đích có thể đã bị điều hướng sang địa chỉ khác và dữ liệu rơi vào tay người lạ. Khi nhận, phải kiểm tra `origin` và nên kiểm tra cả `source`. MDN nói thẳng rằng bỏ qua bước này là mở đường cho tấn công cross-site scripting.

```js
const WIDGET = 'https://widget.vendor.example';
const frame = document.querySelector('iframe');

frame.contentWindow.postMessage({ type: 'context', orderId: 'A123' }, WIDGET);

window.addEventListener('message', (e) => {
  if (e.origin !== WIDGET) return;
  if (e.source !== frame.contentWindow) return;
  if (e.data?.type === 'handoff') openAgentPanel(e.data.ticketId);
});
```

Phía widget cũng làm đúng như vậy theo chiều ngược lại, và danh sách origin hợp lệ nên lấy từ cấu hình của từng tenant, đừng viết cứng.

## Những lỗi khiến go-live trễ một tuần

Lỗi phổ biến nhất là sửa sai phía: xin khách nới CSP trong khi người chặn lại là `frame-ancestors` của chính bạn, hoặc ngược lại. Lỗi thứ hai là xin quá tay. Khách sẽ từ chối `default-src *` hay `unsafe-inline`, và có lý do để từ chối.

Lỗi thứ ba là nghĩ rằng chọn iframe thì chỉ cần xin `frame-src`. Infobip ghi rõ với widget Live Chat, vốn tải qua iframe, khách vẫn phải mở cùng lúc `script-src`, `connect-src`, `frame-src` và `style-src`.

Lý do là kể cả khi widget nằm trong iframe, phần nạp nó trên trang khách vẫn phải qua CSP của khách. Vì thế hãy đưa danh sách đầy đủ ngay từ đầu, đừng để họ mở từng chỉ thị một sau mỗi lần lỗi.

## Tài liệu nào nên mang vào cuộc họp với đội bảo mật?

Danh sách đó nên thành một tài liệu viết sẵn: "Yêu cầu CSP để nhúng widget", ghi từng chỉ thị, từng domain và lý do cần nó. Có nó, cuộc họp đi thẳng vào từng dòng thay vì hỏi đáp vòng vo.

Tài liệu này cũng là bằng chứng tốt nhất cho CV. Thay vì "tích hợp frontend", hãy ghi rằng bạn đã thiết kế cấu hình nhúng và bộ yêu cầu CSP đưa widget lên production trên hệ thống có chính sách bảo mật chặt.

Đội bảo mật tin người hiểu chính sách của họ hơn người chỉ xin họ mở thêm.

**Thử ngay tuần này:**

- Mở DevTools trên một trang có CSP chặt (trang ngân hàng hoặc SaaS lớn), đọc header Content-Security-Policy và tự trả lời: một iframe từ domain lạ có được tải không, và nếu được thì nhờ chỉ thị nào?
- Viết một trang HTML nhúng iframe từ một origin thứ hai chạy trên localhost, cài postMessage hai chiều có kiểm tra origin, rồi thử gửi tin nhắn từ một origin thứ ba để xem nó có bị loại không.
- Soạn một trang 'Yêu cầu CSP để nhúng widget' cho sản phẩm bạn đang làm, ghi từng chỉ thị, từng domain và lý do.

## Nguồn

- [Content-Security-Policy: frame-ancestors directive - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors)

- [Content-Security-Policy: frame-src directive - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-src)

- [<iframe> HTML inline frame element - HTML | MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe)

- [Web Components - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_components)

- [Window: postMessage() method - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)

- [Content Security Policy (CSP)](https://www.infobip.com/docs/live-chat/content-security-policy-csp-in-live-chat)

- [About the widget security policy | Customer Service Management | Atlassian Support](https://support.atlassian.com/customer-service-management/docs/about-the-widget-security-policy/)

- [Content Security Policy | Privacy & Security | Chrome for Developers](https://developer.chrome.com/docs/privacy-security/csp)
