FDE PulseViệc làm FDE đang mở 618Mới trong 7 ngày 33Công ty đang tuyển 94Nhận làm từ xa 24%Lương trung vị (Mỹ) $209kTuyển nhiều nhất Databricks 121
EN

Tờ báo của nghề Forward Deployed Engineer

Bách khoa

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.

Kỹ sư ngồi trước laptop hiển thị code trong văn phòng, kiểm tra việc tích hợp một ứng dụng web.
Ảnh: Arnold Francisca / Unsplash
Chia sẻLinkedInFacebookX

Tóm tắt nhanh

  • Iframe bị chặn thì xem hai chỗ: frame-src (hoặc default-src) ở trang khách và frame-ancestors ở trang widget. frame-ancestors chỉ có tác dụng khi gửi bằng HTTP header.
  • Iframe cách ly widget khỏi trang khách. Web Component với Shadow DOM chỉ cách ly style và ID, còn code vẫn chạy dưới CSP của khách.
  • Dùng nonce mới cho mỗi request thay vì unsafe-inline, gửi postMessage tới targetOrigin cụ thể và luôn kiểm tra origin của tin nhắn nhận được.
Sơ đồ hai khối. Bên trái là trang khách (app.khach.example), cổng 1 là frame-src: quy định trang được tải iframe từ đâu; nếu thiếu, trình duyệt lùi về default-src. Bên phải là widget của bạn (widget.vendor.example), cổng 2 là frame-ancestors: quy định trang cha nào được nhúng nó; chỉ có hiệu lực khi gửi bằng HTTP header. Một mũi tên đứt nét từ widget sang trang khách, ghi "nhúng vào".
Iframe chỉ hiện khi cả hai phía cho phép: frame-src ở trang khách và frame-ancestors ở widget. Khi gặp ô trắng, việc đầu tiên là xác định bên nào đang từ chối.

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ẻ <meta>, 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.

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:

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:

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:

<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.

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.

Bài này có hữu ích không?

Dùng cùng trợ lý AIHỏi Claude ↗Hỏi ChatGPT ↗
8 nguồn
Đọc tiếp trên lộ trình · Chặng 2: Kỹ thuật rộngTypeScript cho FDE quen Python: kiểu, async và npm đủ để sửa code Node của khách trong một tuầnPhần lớn những gì bạn cần đã có sẵn trong đầu từ Python. Phần còn lại là ba thói quen mới, cộng với một lệnh phải chạy trước mỗi lần merge.