Sơ đồ tầng cũng là dữ liệu không tin được: làm sạch SVG trong một panel Grafana

Sensor Floor Map vẽ phòng từ SVG do người dùng dán vào hoặc chép từ một khung chat AI. Panel bỏ những gì trước khi trình duyệt đọc tới markup, và test nào giữ từng luật.

Phan Minh Đăng Khoa
5 phút đọcAI viết · AI soát
Mục lục

dkhoa-dev/grafana-sensor-floor-map là plugin panel Grafana mã nguồn mở, giấy phép MIT. Panel vẽ sơ đồ tầng từ SVG rồi tô màu từng phòng theo số đo cảm biến. Phần tử nào có thuộc tính data-room, hoặc có id dạng room-<name>, là một phòng. Bài này ghi lại panel làm gì với đoạn SVG đó trước khi trình duyệt vẽ nó ra.

Markup đến từ đâu

Sơ đồ là một đoạn SVG nằm trong option của panel. Người dùng có thể vẽ bằng editor có sẵn, dán vào ô SVG source, hoặc nhờ AI vẽ. Trợ lý AI chép ra một prompt để người dùng dán vào khung chat AI nào cũng được. Sau đó người dùng dán câu trả lời của AI ngược vào panel. Nếu admin Grafana đã cài app Grafana LLM, trợ lý cũng gọi AI qua app đó. Cách nào thì panel cũng vẽ một đoạn markup không do nó viết, cho bất kỳ ai mở dashboard.

Script và event handler là rủi ro ai cũng thấy, và DOMPurify mặc định đã gỡ chúng. Rủi ro khó thấy hơn là trình duyệt tự đi tải một tài nguyên.

Trước PR #14: Một sơ đồ dán vào hoặc do AI sinh ra, có style="fill:url(https://…)", làm trình duyệt của mọi người xem gọi tới server đó. Không có script nào chạy. URL chỉ đơn giản là do người viết sơ đồ chọn.

PR #14 chặn chỗ này ngày 25/10/2026.

Những thẻ không bao giờ có trong sơ đồ

Hàm sanitizeFloorPlan trong src/svg/floorPlan.ts chạy DOMPurify với hai profile SVG và SVG filter. Hàm dùng một instance DOMPurify riêng, nên hook bên dưới không đụng tới code khác trên trang. Danh sách thẻ bị cấm: script, foreignObject, iframe, style, image, a, animate, animateMotion, animateTransform và set.

Có hai mục cần giải thích. Khối <style> sẽ áp luật CSS lên cả trang Grafana. Còn các thẻ animation có thể sửa thuộc tính sau khi bộ lọc đã kiểm xong.

Một hook cho mọi thuộc tính

Thuộc tính nào qua được danh sách thẻ đều đi qua cùng một hook:

// src/svg/floorPlan.ts
purifier.addHook('uponSanitizeAttribute', (_node, data) => {
  const name = data.attrName.toLowerCase();
  const value = data.attrValue.trim();
  if ((name === 'href' || name.endsWith(':href')) && !value.startsWith('#')) {
    data.keepAttr = false;
  } else if (loadsExternalResource(value)) {
    data.keepAttr = false;
  }
});

href hay xlink:href chỉ được giữ khi trỏ tới một id trong chính tài liệu. Các thuộc tính khác bị bỏ nếu giá trị của nó có thể tải tài nguyên.

  1. inputMarkup sơ đồeditor, dán vào hoặc AI trả về
  2. tagsDOMPurifyprofile SVG, cấm 10 thẻ
  3. attributesHookhref chỉ #id, bỏ CSS tải ngoài
  4. idsscopeIdstiền tố sfm-<panelId>-
  5. panelVẽ phòngtô màu theo số đo
Đường đi của một sơ đồ trước khi panel vẽ nó. Mọi bước chạy trong trình duyệt của người xem.

CSS có nhiều cách để tải

loadsExternalResource trong src/svg/externalRefs.ts đánh dấu giá trị nào có image(, image-set(, cross-fade(, element(, src( hoặc @import. Dạng có tiền tố trình duyệt như -webkit-image-set( cũng kết thúc bằng các tên đó, nên cùng một pattern bắt được luôn. url( có luật riêng: giá trị chỉ được giữ khi mọi url( trong đó đều trỏ tới #id.

Trước khi so khớp, hàm giải mã escape của CSS. Trong CSS, \75 rl( chính là url(, nên bộ lọc chỉ tìm chữ url( sẽ bỏ sót. Test end-to-end trong tests/panel.spec.ts dán vào ô SVG source một sơ đồ dùng cả hai mẹo:

<!-- tests/panel.spec.ts -->
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" style="background-image:image-set('https://example.invalid/a.png' 1x), \75 rl(https://example.invalid/b.png)"><rect data-room="Probe" width="200" height="100" fill="#ccc"/></svg>

Test đợi phòng hiện ra, chờ thêm 2 giây, rồi kiểm tra Chromium không gửi request nào tới example.invalid. Comment trong test ghi rằng Chromium sẽ tải cả hai lớp background nếu bộ lọc không bỏ thuộc tính style.

Tham chiếu nội bộ vẫn phải chạy

Chặn hết mọi url() thì bản vẽ bình thường sẽ hỏng. fill="url(#gradient)" và <use href="#shape"> trỏ vào bên trong tài liệu. Các phần mềm vẽ vector cũng dùng <use> cho hình lặp lại. Vì vậy panel thêm use lại vào danh sách thẻ cho phép và giữ các tham chiếu #id.

Nhưng id nội bộ lại sinh ra chuyện khác. Hai panel trên cùng dashboard có thể cùng định nghĩa #gradient, và sơ đồ này lấy nhầm gradient hay clip path của sơ đồ kia. scopeIds thêm tiền tố sfm-<panelId>- vào mọi id. Hàm cũng sửa theo mọi url(#…) và href="#…" trỏ tới các id đó.

Bấm vào một phòng có thể mở một URL ghép từ biến dashboard cùng ${room}, ${sensor} và ${location}. Chỉ khi điền biến xong mới có URL cuối cùng. isSafeLink kiểm tra chuỗi cuối đó bằng chính bộ phân tích URL của trình duyệt, và chỉ cho http:, https: hoặc link tương đối. Trình duyệt vẫn đọc java\tscript: thành javascript:, nên cách viết đó bị từ chối. Link data: cũng vậy.

Sau đó, đợt review plugin của Grafana cho bản 0.2.0 bắt lỗi truy cập thẳng window.location. Bản 0.2.1 ra ngày 26/10/2026 chuyển sang phân giải link theo document.baseURI. Link ra ngoài Grafana trong cùng tab giờ đi qua một thẻ <a rel="noopener noreferrer"> tạm. Lần thử đầu với window.open(url, '_self') lại vướng một luật review khác, luật bắt buộc có noopener (PR #29).

Gỡ một luật thì phải có test đỏ

PR #13 thêm test cho từng cách một sơ đồ có thể tải tài nguyên. Test phủ href và xlink:href trên use, feImage, image, pattern, linearGradient, radialGradient, filter và textPath. Ngoài ra còn @import trong <style> và các hàm tải như -webkit-cross-fade(). Sau PR này bộ unit test có 226 test.

PR ghi lại kết quả khi xoá từng điều kiện. Bỏ điều kiện :href thì 9 test fail. Bỏ cả luật href thì 16 test fail. Bỏ ngoại lệ #id thì 4 test fail.

Cũng PR đó sửa README, vốn đang ghi ngược với code. Giờ README ghi <use> được giữ với tham chiếu #id, còn <image>, link và animation bị bỏ.

Bài học

  • Markup người dùng dán vào hay AI trả về đều không tin được, dù nó chỉ vẽ vài hình chữ nhật.
  • SVG không có script vẫn khiến trình duyệt đi tải được, qua hàm CSS hoặc qua href.
  • Giải mã escape của CSS trước khi so tên hàm.
  • Giữ tham chiếu #id và gắn tiền tố id theo từng panel, nếu không bản vẽ bình thường sẽ hỏng.
  • Kiểm tra link sau khi đã điền biến, bằng bộ phân tích URL của trình duyệt.
  • Xoá thử từng điều kiện rồi chạy test. Không test nào đỏ nghĩa là điều kiện đó chưa có test.
Phan Minh Đăng Khoa
Principal Engineer · AI, IoT và tự động hoá công nghiệp.
← Bài trướcPoint-adjusted F1 tô hồng detector phát hiện bất thường (TSB-AD)