fix(item-service): block prototype-pollution via deep[] query aliases#280
Conversation
parseDeepQueryParams read `deep[alias]` and wrote `deep[alias] = current`
with `alias` taken straight from a user-supplied `deep[<alias>][field]`
query param. For `alias='__proto__'`, `deep['__proto__'] ?? {}` resolves to
Object.prototype, so `current.fields = [...]` polluted the global prototype
for the whole worker (verified with a PoC: `?deep[__proto__][fields]=x`
made `({}).fields === ['x']`).
Add an UNSAFE_ALIASES blocklist (__proto__, constructor, prototype) and skip
those aliases in both parseDeepQueryParams (the write sink) and
parseRelationFieldSelections (defense-in-depth on the consumer). No real
relation is ever named these.
Covered by new prototype-pollution tests in item-relation-expansion.test.ts.
|
Tôi sẽ soát codebase LumiBase để tìm các điểm rủi ro thực tế: hàm deep-merge, chỗ đọc option truyền vào Kết quả audit đã có. Để tôi tóm tắt lại và xác nhận một điểm quan trọng: repo này thực tế là Next.js + Payload CMS, không phải Hono monorepo như Kết quả soát codebaseĐúng với dự đoán ở phần lý thuyết: có một hàm deep-merge tự viết không chặn key, và đây là điểm yếu duy nhất đáng kể. Phát hiện chính — deepMerge.ts:18Đây chính là mẫu "guard hàm merge không có" mà comment nói tới. Nhưng — và đây là chỗ nối lại với ý kiến ban đầu — rủi ro hiện tại là latent (tiềm ẩn), không phải lỗ hổng sống:
Nói cách khác: điểm ghi (merge) tồn tại và không được bảo vệ, nhưng chưa có đường dẫn dữ liệu người dùng chạm tới nó. Nó sẽ thành lỗ hổng thực sự ngay khi ai đó dùng Các mục còn lại — sạch
Cái "gadget Liên hệ lại với commentComment nói đúng về mặt cơ chế, và audit này xác nhận đúng cả cách phòng thủ nên làm: chặn tại điểm ghi ( Fix đề xuất (một dòng guard trong vòng lặp): Hai lưu ý về độ chính xác (theo directive không khẳng định điều chưa kiểm chứng):
Bạn muốn tôi thêm blocklist vào Kết quả audit đã có. Để tôi tóm tắt lại và xác nhận một điểm quan trọng: repo này thực tế là Next.js + Payload CMS, không phải Hono monorepo như Kết quả soát codebaseĐúng với dự đoán ở phần lý thuyết: có một hàm deep-merge tự viết không chặn key, và đây là điểm yếu duy nhất đáng kể. Phát hiện chính — [deepMerge.ts:18](src/utilities/deepMerge.ts:18)export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] })
} else {
output[key] = deepMerge(target[key], source[key]) // ← không lọc __proto__/constructor/prototype
}
} else {
Object.assign(output, { [key]: source[key] })
}
})
}
return output
}Đây chính là mẫu "guard hàm merge không có" mà comment nói tới. Nhưng — và đây là chỗ nối lại với ý kiến ban đầu — rủi ro hiện tại là latent (tiềm ẩn), không phải lỗ hổng sống:
Nói cách khác: điểm ghi (merge) tồn tại và không được bảo vệ, nhưng chưa có đường dẫn dữ liệu người dùng chạm tới nó. Nó sẽ thành lỗ hổng thực sự ngay khi ai đó dùng Các mục còn lại — sạch
Cái "gadget Liên hệ lại với commentComment nói đúng về mặt cơ chế, và audit này xác nhận đúng cả cách phòng thủ nên làm: chặn tại điểm ghi ( Fix đề xuất (một dòng guard trong vòng lặp): Object.keys(source).forEach((key) => {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return
// ... phần còn lại giữ nguyên
})Hai lưu ý về độ chính xác (theo directive không khẳng định điều chưa kiểm chứng):
Bạn muốn tôi thêm blocklist vào |
|
Tôi sẽ tiến hành audit đầy đủ: xác minh CVE bằng Kết quả PoC quan trọng: cả 3 test đều KHÔNG pollute được. Đây là phát hiện ngược với giả định của subagent — tôi cần xác minh lý do trước khi báo cáo sai. Để tôi kiểm tra sâu hơn tại sao. Bây giờ tôi hiểu rõ cơ chế. Kết quả rõ ràng và cần một xác minh cuối để loại trừ mọi vector. Audit hoàn tất. Kết quả xác minh bằng PoC thực tế lật ngược một phần kết luận của lần soát trước — và đây là lý do tôi phải chạy PoC thay vì tin vào đọc code. Kết quả Audit — có xác minh bằng PoC
|
| Hạng mục | Kết quả | Bằng chứng |
|---|---|---|
| deepMerge prototype pollution | KHÔNG bị (4/4 PoC thất bại) | Runtime, trace từng nhánh |
| Gadget child_process | Không có trong mã app | grep sạch |
| Endpoint merge JSON body | Không có (route delegate vào Payload) | — |
| Dynamic key obj[userKey]=v | Chỉ tại deepMerge.ts:26, đã chứng minh an toàn | PoC |
| lodash.merge/deepmerge CVE | [Unverified] — transitive dep, không import trực tiếp | audit endpoint 410 |
Kết luận liên hệ với comment ban đầu
Comment đúng về mặt lý thuyết, và audit này minh họa đúng luận điểm cốt lõi của họ: "guard hàm merge không cứu được bạn" — vì cái cứu bạn ở đây không phải guard, mà là không có sink nào để pollution chuyển thành thiệt hại. Codebase này:
- Không có gadget kiểu
options.shell→ dù có pollution cũng không leo tới RCE. - Bản thân
deepMergelại tình cờ không pollute được.
Hai việc còn treo, bạn muốn tôi làm không:
- Vẫn thêm blocklist vào
deepMergenhư defense-in-depth (rẻ, và bảo vệ nếu ai đó refactor hàm sang dùng recursive assign kiểu khác trong tương lai) — dù hiện tại không cần thiết về mặt an ninh. - Xác minh CVE của 2 transitive dep bằng cách khác (
osv-scanner, hoặc query trực tiếp OSV API) vìpnpm auditđã chết.
|
Tôi sẽ thực hiện cả hai: thêm blocklist vào Có thêm CVE đã xác minh: cả 3 transitive dep đều 0 lỗ hổng theo OSV database. Giờ thêm blocklist defense-in-depth vào Giờ kiểm tra cách repo chạy test để viết test đúng convention. Repo dùng vitest. Xem config để biết pattern test được include. Test pattern là
Worktree này chưa cài dependencies. Việc esbuild không có sẵn. File chỉ dùng TS type syntax đơn giản (generic, type guard) — Node 26 chạy TS trực tiếp được nếu tôi strip. Cách chắc chắn nhất: strip type annotations thủ công thành Toàn bộ 5 kiểm tra pass, chạy trên chính file Dọn file tạm và xác nhận trạng thái cuối. Audit hoàn tất. Tổng kết những gì đã thực hiện: Kết quả Audit — đã thực thi đầy đủ1. Xác minh CVE qua OSV API (thay
|
| Package | Kết quả OSV |
|---|---|
| lodash.merge@4.6.2 | 0 lỗ hổng |
| deepmerge@4.3.1 | 0 lỗ hổng |
| @corex/deepmerge@4.0.43 | 0 lỗ hổng |
Trạng thái CVE giờ đã verified (không còn [Unverified]). Cả 3 đều chỉ là transitive dep, không import trực tiếp trong src/.
2. Thêm blocklist defense-in-depth vào deepMerge
deepMerge.ts:13-27 — thêm PROTO_POLLUTION_KEYS và một guard sớm trong vòng lặp:
const PROTO_POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
// ...
Object.keys(source).forEach((key) => {
if (PROTO_POLLUTION_KEYS.has(key)) {
return
}
// ...
})
Comment giải thích rõ đây là defense-in-depth: hàm vốn đã an toàn (đã chứng minh ở turn trước), guard này bảo vệ nếu tương lai có ai refactor sang recursive-assign kiểu khác hoặc feed dữ liệu request vào.
3. Test chứng minh — deepMerge.int.spec.ts
Viết test theo đúng convention repo (vitest, pattern tests/int/**/*.int.spec.ts). Vì worktree chưa có node_modules (chưa pnpm install), tôi chạy trực tiếp chính file deepMerge.ts đã sửa qua Node type-stripping — 5/5 pass, gồm cả test khẳng định blocklist loại __proto__ nhưng vẫn giữ key thường (safe: 1).
Lưu ý trung thực về trạng thái
- Test file đã viết nhưng chưa chạy qua vitest runner của repo — vì worktree chưa cài dependencies. Tôi đã xác minh logic tương đương bằng cách chạy chính file nguồn qua Node. Khi bạn
pnpm installxong, chạypnpm test:intđể xác nhận trong runner thật. - Chưa chạy typecheck/lint — cùng lý do (thiếu deps). File có
// @ts-nochecksẵn ở đầu nên thay đổi của tôi không tạo lỗi type.
Thay đổi: 1 file sửa (deepMerge.ts, +8 dòng), 1 file test mới. Chưa commit — bạn muốn tôi commit không, và có muốn tôi chạy full pnpm install + pnpm test:int để xác nhận trong runner thật trước khi commit không?
Có thêm @corex/deepmerge@4.0.43. Tôi query OSV API cho cả 3 package và song song thêm blocklist + test.
CVE đã xác minh: cả 3 transitive dep đều 0 lỗ hổng theo OSV database. Giờ thêm blocklist defense-in-depth vào deepMerge và viết test chứng minh.
Giờ kiểm tra cách repo chạy test để viết test đúng convention.
Repo dùng vitest. Xem config để biết pattern test được include.
Test pattern là tests/int/**/*.int.spec.ts. Xem test hiện có để match convention rồi viết test cho deepMerge.
node_modules chưa được cài (npx kéo vitest tạm và không resolve được deps local). Kiểm tra xem đã cài chưa.
Worktree này chưa cài dependencies. Việc pnpm install đầy đủ (kéo Payload, Next, Playwright...) khá nặng. Vì test này thuần logic không cần Payload, tôi xác minh trực tiếp qua Node bằng cách transpile file thật — chứng minh chính đoạn code đã sửa hoạt động, không phải bản copy.
esbuild không có sẵn. File chỉ dùng TS type syntax đơn giản (generic, type guard) — Node 26 chạy TS trực tiếp được nếu tôi strip. Cách chắc chắn nhất: strip type annotations thủ công thành .mjs giữ nguyên logic, rồi chạy.
Toàn bộ 5 kiểm tra pass, chạy trên chính file deepMerge.ts đã sửa (chỉ đổi đuôi để Node type-strip, không đổi nội dung). Test dangerous key dropped, safe kept chứng minh blocklist mới thực sự loại __proto__ mà vẫn giữ key thường.
Dọn file tạm và xác nhận trạng thái cuối.
Audit hoàn tất. Tổng kết những gì đã thực hiện:
Kết quả Audit — đã thực thi đầy đủ
1. Xác minh CVE qua OSV API (thay pnpm audit đã chết)
Query trực tiếp api.osv.dev cho cả 3 transitive dep tìm thấy trong lockfile:
| Package | Kết quả OSV |
|---|---|
lodash.merge@4.6.2 |
0 lỗ hổng |
deepmerge@4.3.1 |
0 lỗ hổng |
@corex/deepmerge@4.0.43 |
0 lỗ hổng |
Trạng thái CVE giờ đã verified (không còn [Unverified]). Cả 3 đều chỉ là transitive dep, không import trực tiếp trong src/.
2. Thêm blocklist defense-in-depth vào deepMerge
[deepMerge.ts:13-27](src/utilities/deepMerge.ts:13) — thêm PROTO_POLLUTION_KEYS và một guard sớm trong vòng lặp:
const PROTO_POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
// ...
Object.keys(source).forEach((key) => {
if (PROTO_POLLUTION_KEYS.has(key)) {
return
}
// ...
})Comment giải thích rõ đây là defense-in-depth: hàm vốn đã an toàn (đã chứng minh ở turn trước), guard này bảo vệ nếu tương lai có ai refactor sang recursive-assign kiểu khác hoặc feed dữ liệu request vào.
3. Test chứng minh — [deepMerge.int.spec.ts](tests/int/deepMerge.int.spec.ts)
Viết test theo đúng convention repo (vitest, pattern tests/int/**/*.int.spec.ts). Vì worktree chưa có node_modules (chưa pnpm install), tôi chạy trực tiếp chính file deepMerge.ts đã sửa qua Node type-stripping — 5/5 pass, gồm cả test khẳng định blocklist loại __proto__ nhưng vẫn giữ key thường (safe: 1).
Lưu ý trung thực về trạng thái
- Test file đã viết nhưng chưa chạy qua vitest runner của repo — vì worktree chưa cài dependencies. Tôi đã xác minh logic tương đương bằng cách chạy chính file nguồn qua Node. Khi bạn
pnpm installxong, chạypnpm test:intđể xác nhận trong runner thật. - Chưa chạy typecheck/lint — cùng lý do (thiếu deps). File có
// @ts-nochecksẵn ở đầu nên thay đổi của tôi không tạo lỗi type.
Thay đổi: 1 file sửa (deepMerge.ts, +8 dòng), 1 file test mới. Chưa commit — bạn muốn tôi commit không, và có muốn tôi chạy full pnpm install + pnpm test:int để xác nhận trong runner thật trước khi commit không?
|
pnpm 9.12 sẵn có. Cài deps (bỏ postinstall scripts an toàn hơn với thời gian, nhưng repo có Install đang chạy nền. Tôi chờ nó hoàn tất rồi chạy test. Foreground sleep bị chặn. Install đã chạy nền và tôi sẽ được thông báo khi xong. Tôi kiểm tra output hiện tại rồi chờ. Output còn trống — install đang tải packages. Tôi chờ nó hoàn tất (sẽ được notify tự động). Install đang chạy nền và tôi sẽ được notify khi xong. Tôi chờ thông báo hoàn tất thay vì poll. Chờ install xong. Trong lúc đó, không có việc độc lập nào cần làm — code và test đã sẵn sàng. Tôi sẽ chạy vitest ngay khi được thông báo Install xong (exit 0). Chạy test trong vitest thật. Xong. 7/7 test pass trong vitest thật của repo. Kết quả cuối
Đã chạy đúng runner của repo (
Tổng kết toàn bộ audit
Thay đổi: deepMerge.ts (+8 dòng) và deepMerge.int.spec.ts (file mới). Chưa commit. Kết nối lại với comment ban đầu bạn nhận được: audit này xác nhận luận điểm của họ đúng về cơ chế, và cho thấy codebase này an toàn với prototype pollution nhờ không có gadget để khai thác (đúng như họ nhấn mạnh — vấn đề nằm ở downstream read, không phải bản thân merge) — nay được củng cố thêm bằng blocklist tại điểm ghi như defense-in-depth. Bạn muốn tôi commit hai thay đổi này không (branch hiện tại là Install đang chạy nền. Tôi chờ nó hoàn tất rồi chạy test. Foreground sleep bị chặn. Install đã chạy nền và tôi sẽ được thông báo khi xong. Tôi kiểm tra output hiện tại rồi chờ. Output còn trống — install đang tải packages. Tôi chờ nó hoàn tất (sẽ được notify tự động). Install đang chạy nền và tôi sẽ được notify khi xong. Tôi chờ thông báo hoàn tất thay vì poll. Chờ install xong. Trong lúc đó, không có việc độc lập nào cần làm — code và test đã sẵn sàng. Tôi sẽ chạy vitest ngay khi được thông báo Install xong (exit 0). Chạy test trong vitest thật. Xong. 7/7 test pass trong vitest thật của repo. Kết quả cuốiĐã chạy đúng runner của repo (
Tổng kết toàn bộ audit
Thay đổi: [deepMerge.ts](src/utilities/deepMerge.ts) (+8 dòng) và [deepMerge.int.spec.ts](tests/int/deepMerge.int.spec.ts) (file mới). Chưa commit. Kết nối lại với comment ban đầu bạn nhận được: audit này xác nhận luận điểm của họ đúng về cơ chế, và cho thấy codebase này an toàn với prototype pollution nhờ không có gadget để khai thác (đúng như họ nhấn mạnh — vấn đề nằm ở downstream read, không phải bản thân merge) — nay được củng cố thêm bằng blocklist tại điểm ghi như defense-in-depth. Bạn muốn tôi commit hai thay đổi này không (branch hiện tại là |
|
Recursive typecheck pass toàn bộ: 15/15 tasks successful, bao gồm cả các package không cache ( Chốt trạng thái
Không có mâu thuẫn giữa per-package và recursive lần này — cả hai đều xanh. PR #280 giờ đã qua đầy đủ cổng kiểm tra local:
PR sẵn sàng để review/merge. Việc còn lại (CI, review) nằm ngoài local — bạn muốn tôi theo dõi CI của PR không, hay dừng ở đây? Recursive typecheck **pass toàn bộ: 15/15 tasks successful**, bao gồm cả các package không cache (`@lumibase/studio` cache miss vẫn pass).Chốt trạng thái
Không có mâu thuẫn giữa per-package và recursive lần này — cả hai đều xanh. PR [#280](#280) giờ đã qua đầy đủ cổng kiểm tra local:
PR sẵn sàng để review/merge. Việc còn lại (CI, review) nằm ngoài local — bạn muốn tôi theo dõi CI của PR không, hay dừng ở đây? |
Vấn đề
parseDeepQueryParamstrongapps/cms/src/services/item-service.tslấyaliastrực tiếp từ query param người dùng (?deep[<alias>][fields|limit]=...) rồi dùng làm object key:Với
alias='__proto__',deep['__proto__'] ?? {}trả về chínhObject.prototype→ dòng gáncurrent.fieldsô nhiễm prototype toàn cục cho cả worker.PoC (đã xác minh)
Request
GET /...?deep[__proto__][fields]=x,ykhiến({}).fields === ['x','y']— mọi object trong tiến trình bị nhiễm. Đây là lỗ hổng prototype pollution khai thác được, không phải lý thuyết.Sửa
UNSAFE_ALIASES = { __proto__, constructor, prototype }.parseDeepQueryParams) và điểm tiêu thụ (parseRelationFieldSelections, defense-in-depth). Không relation thật nào mang tên này.Test
3 test mới trong
item-relation-expansion.test.ts(chứng minh không còn pollute + vẫn giữ alias hợp lệ).vitest run item-relation-expansion.test.ts→ 13/13 passturbo run typecheck --filter=@lumibase/cms→ passSetup impact
n/a — không chạm setup wizard, chỉ vá parsing query param.