Public property của Livewire là client-writable
Người mới học Livewire hay nghĩ thế này: "component viết bằng PHP, chạy trên server, nên nó an toàn". Đó là hiểu sai lớn nhất và tốn nhất.
Sự thật: mọi public property của component là một ô input
mà client ghi được. Nó không khác gì một <input name="...">
ẩn trong form. Ai cầm được DevTools là ghi được.
Bài này đọc code thật trong project laravel-livewire — hai component
app/Livewire/CategoryBrowse.php và app/Livewire/ProductDetail.php
— để hiểu tại sao chúng phải viết theo cách đó.
Từ khoá dùng suốt bài
- hydrate (dựng lại state từ dữ liệu đã gửi lên) — Livewire đọc snapshot từ request rồi gán ngược vào các public property.
- dehydrate (đóng gói state để gửi về client) — cuối mỗi request, Livewire biến các public property thành JSON gửi về trình duyệt.
- snapshot (ảnh chụp state) — cục JSON đó, kèm một checksum (chữ ký chống sửa) do server ký.
- update endpoint —
POST /livewire/update, nơi mọi tương tác (click, gõ,wire:model) gửi request về.- allow-list (danh sách được phép) — chỉ nhận đúng những giá trị có trong danh sách, còn lại bỏ.
1. Nó là gì / chạy thế nào
1.1. Hai loại request, không phải một
Một trang Livewire sống qua hai loại request khác hẳn nhau. Đây là chỗ mà người mới bỏ sót.
sequenceDiagram
autonumber
participant B as Trình duyệt
participant L as Livewire server
participant DB as Database
rect rgb(30,50,70)
Note over B,DB: Request 1 — mở trang (GET /laptops-c.42)
B->>L: GET, có route param {category}
L->>DB: resolveRouteBinding: tìm category active
DB-->>L: Category row (hoặc null → 404)
L->>L: mount(Category $category) CHẠY
L->>L: render()
L-->>B: HTML + snapshot + checksum
end
rect rgb(70,40,40)
Note over B,DB: Request 2..n — tương tác (POST /livewire/update)
B->>L: snapshot + checksum + mảng updates
L->>L: kiểm checksum của snapshot
L->>L: hydrate: gán property từ snapshot
L->>L: áp mảng updates lên public property
L->>L: mount() KHÔNG CHẠY
L->>L: render() chạy
L-->>B: HTML mới + snapshot mới
end
Điểm chết người nằm ở đây:
mount()chỉ chạy một lần duy nhất, ở request đầu.render()chạy ở mọi request.- Mảng
updatestrong request 2 là tính năng, không phải lỗ hổng:wire:model="min"hoạt động được chính là nhờ nó. Nhưng nó không phân biệt được "client tử tế gõ vào ô input" với "client viết tayfetch()set thẳng property".
1.2. Checksum không cứu bạn
Nhiều người tưởng checksum là lớp bảo vệ. Nó bảo vệ đúng một thứ: client không sửa được phần snapshot đã dehydrate (ví dụ không đổi được id component, không nhét thêm property lạ).
Nhưng ghi giá trị mới vào một public property đã tồn tại thì hoàn toàn hợp lệ theo thiết kế — đó là cách Livewire làm việc.
// Client hoàn toàn có thể gửi tay request này, không cần đụng UI:
fetch('/livewire/update', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-TOKEN': token},
body: JSON.stringify({
components: [{
snapshot: snapshotHopLe, // lấy từ HTML của trang, checksum đúng
updates: {
sort: ['best-rating'], // mảng, không phải string
quantity: 'abc', // không phải số
selections: {'Color': 'MauCuaProductKhac'},
category: {id: 99} // thử đổi cả model
},
calls: []
}]
})
});
Nói gọn cho phỏng vấn: checksum chống sửa snapshot, không chống ghi property. Ghi property là API công khai của Livewire. Vì vậy mọi guard đặt trong
mount()đều vô dụng — request update không đi qua đó.
1.3. Trust boundary nằm ở đâu
flowchart TB
subgraph UNTRUSTED["Vùng KHÔNG tin được (client viết được)"]
Q["Query string: ?min= ?max= ?sort= ?page="]
U["Mảng updates ở POST /livewire/update"]
A["Tham số của action: selectValue(axis, value)"]
end
subgraph SERVER["Component"]
P["public property"]
R["render() — chạy mọi request"]
M["mount() — chỉ chạy 1 lần"]
end
subgraph TRUSTED["Vùng đã sạch"]
QRY["Query builder / view"]
end
Q --> P
U --> P
A --> P
M -.->|"guard đặt ở đây là VÔ DỤNG"| P
P --> R
R -->|"sanitise / allow-list / clamp / abort"| QRY
2. Bốn cách xử lý trong project — và tại sao phải vậy
2.1. #[Locked] cho model đã bind route
Cả hai component đều khoá model chính:
// app/Livewire/CategoryBrowse.php
#[Locked]
public Category $category;
// app/Livewire/ProductDetail.php
#[Locked]
public Product $product;
Tại sao cần? Model này được resolve bằng implicit route binding (Laravel tự tìm model từ id trên URL), và binding đó có scope riêng:
// app/Models/Category.php
public function resolveRouteBinding($value, $field = null): ?self
{
return static::query()->active()->where($this->getRouteKeyName(), $value)->first();
}
Chỉ row is_active = true mới resolve được. Id lạ hoặc category đã tắt
thì 404 trước khi component mount.
Vấn đề: đường resolve đó chỉ chạy ở request GET đầu tiên. Ở request
update, Livewire hydrate model bằng cách tra id từ snapshot rồi find() lại
— không đi qua resolveRouteBinding(). Nếu id đó client sửa được thì
scope active() bị đi vòng qua.
flowchart LR
subgraph GET["GET /laptops-c.42"]
G1["route binding"] --> G2["scope active()"] --> G3["404 nếu inactive"]
end
subgraph POST["POST /livewire/update"]
P1["hydrate model từ snapshot"] --> P2["find(id) — KHÔNG có scope active"]
end
P2 -.->|"nếu id ghi được → lộ category đã tắt"| X["Lỗ hổng"]
L["Thuộc tính Locked"] ==>|"chặn ghi id, ném lỗi"| P2
Điểm cần nói thẳng cho đúng: Livewire 3 trở lên đã tự khoá
id của property kiểu Eloquent model. Vậy #[Locked] ở đây là thừa? Không —
nó vẫn đáng viết vì ba lý do:
- Ghi rõ ý định. Người đọc code sau này biết ngay "cái này không được phép đến từ client", không phải đi tra hành vi ngầm của framework.
- Chống refactor làm hỏng. Ngày nào đó có người đổi
public Category $categorythànhpublic string $categoryIdcho "nhẹ snapshot" — lúc đó auto-lock biến mất, còn#[Locked]thì ở lại và vẫn chặn. - Ném lỗi rõ ràng. Client cố ghi vào property đã
#[Locked]thì Livewire ném exception, không âm thầm bỏ qua.
Tại sao không dùng
protectedcho gọn? Vì Livewire chỉ giữ state qua các request bằngpublicproperty.protectedkhông nằm trong snapshot nên request sau sẽ mất.#[Locked]sinh ra chính để giải bài toán đó: vẫnpublicđể tồn tại qua request, nhưng client không ghi được.
2.2. Option value được allow-list theo chính product đó
Đây là ý quan trọng nhất, và cũng là ý người mới hay làm sai nhất.
Trang product detail có bộ chọn biến thể (variant): Color, Size, Wattage… Selection được giữ trong một public property:
// app/Livewire/ProductDetail.php
/** @var mixed */
public $selections = []; // ['Color' => 'Black', 'Size' => 'M']
Cách làm sai (validate "đúng định dạng"):
// SAI — chỉ kiểm hình dạng dữ liệu
$this->validate([
'selections' => 'array',
'selections.*' => 'string|max:50',
]);
Payload ['Color' => 'Titanium Rose'] qua được validate này. Nó là
array, value là string, dưới 50 ký tự — hợp lệ hoàn toàn. Nhưng "Titanium Rose" là màu
của một product khác. Product đang xem không hề có màu đó.
Cách làm đúng — allow-list theo dữ liệu đã load của chính product này:
// app/Support/VariantCatalogue.php
public function sanitise(mixed $raw): array
{
if (! is_array($raw)) {
return []; // 'not-even-an-array' → []
}
$clean = [];
foreach ($this->axisNames() as $axis) { // chỉ các axis của product NÀY
$value = $raw[$axis] ?? null;
if (is_string($value) && isset($this->valueIds()[$axis][$value])) {
$clean[$axis] = $value; // chỉ value có thật trong ma trận
}
}
return $clean; // mọi thứ khác bị BỎ, không phải bị sửa
}
Khác biệt là bản chất, không phải mức độ chặt:
| Validate "đúng định dạng" | Allow-list theo product | |
|---|---|---|
| Câu hỏi đặt ra | "Chuỗi này có hợp lệ không?" | "Product này có value đó không?" |
| Nguồn sự thật | Regex / rule viết tay | Ma trận variant đã load sẵn trong request |
['Color' => 'Titanium Rose'] | Qua ✗ | Bị bỏ ✓ |
| Chi phí | 0 query | 0 query — dữ liệu đã ở sẵn trong bộ nhớ |
flowchart TB IN["selections từ client
Color = Titanium Rose, Storage = bad"] IN --> C{"is_array?"} C -- "không" --> E["trả về mảng rỗng"] C -- "có" --> LOOP["duyệt từng axis CỦA PRODUCT NÀY"] LOOP --> Q{"value có trong valueIds[axis] không?"} Q -- "có" --> KEEP["giữ"] Q -- "không" --> DROP["bỏ axis đó, không lỗi"] KEEP --> OUT["selection sạch"] DROP --> OUT OUT --> USE["dựng ma trận, tra giá, tra tồn kho"]
Tại sao "bỏ" chứ không "báo lỗi"? Comment trong code trả lời thẳng: selection là vị trí của người mua trong bộ chọn. Câu trả lời trung thực cho một value không nhận ra là "bạn chưa chọn gì trên trục đó", chứ không phải một trang 500.
Tại sao allow-list bằng option value chứ không dùng id? Vì id của
option value không bao giờ ra khỏi server. Selection gửi qua lại là
cặp tên trục → chuỗi value, vì trong DB đã có
UNIQUE (option_id, value). Client không bao giờ thấy, và cũng không bao giờ
gửi lên, một product_option_values.id nào.
Cùng allow-list đó được áp ở cả hai cửa — property và action:
public function selectValue(mixed $axis, mixed $value): void
{
if (! is_string($axis) || ! is_string($value)) {
return; // tham số action cũng không tin được
}
$before = $this->catalogue()->sanitise($this->selections);
$after = $this->catalogue()->select($axis, $value, $before);
// ...
}
Nguyên tắc rút ra: khi input là "một trong các lựa chọn của một thực thể cụ thể", thì thứ để đối chiếu phải là chính thực thể đó, không phải một rule về hình dạng chuỗi. Cùng logic này áp cho: id chi nhánh trong tài khoản của user, mã khuyến mãi áp cho đơn hàng, quyền trong role. "Đúng định dạng" ≠ "thuộc về bạn".
2.3. sort cố ý không khai kiểu
/**
* Deliberately untyped: a typed `string` would make `?sort[]=x` a TypeError
* during hydration, i.e. a 500 on hostile input, before the allow-list ever ran.
*
* @var mixed
*/
#[Url]
public $sort = '';
Nhìn qua thì trông như code lười, thiếu type. Thật ra ngược lại — đây là quyết định có chủ ý.
Nếu khai public string $sort = '', khi client gửi
?sort[]=best-rating, PHP nhận một array và cố gán vào property
kiểu string:
sequenceDiagram participant B as Client participant H as Livewire hydration participant G as Allow-list guard rect rgb(70,40,40) Note over B,G: Nếu khai public string $sort B->>H: ?sort[]=best-rating (array) H->>H: gán array vào string → TypeError H-->>B: HTTP 500 Note right of H: Guard chưa kịp chạy dòng nào end rect rgb(30,60,45) Note over B,G: Thực tế: public $sort không khai kiểu B->>H: ?sort[]=best-rating (array) H->>H: gán bình thường, không lỗi H->>G: CategoryBrowseSort::fromRequest(mixed) G-->>B: HTTP 200, dùng sort mặc định end
Guard nhận mixed và luôn trả về một sort có thật:
// app/Support/CategoryBrowseSort.php
public static function fromRequest(mixed $value): self
{
return is_string($value)
? self::tryFrom($value) ?? self::default()
: self::default();
}
Tại sao 500 là kết quả tệ? Ba lý do, xếp theo mức nghiêm trọng:
- Là vector DoS rẻ tiền. Một URL gửi được là một exception. 500 thường tốn hơn 200: dựng stack trace, ghi log, có khi bắn lên Sentry. Kẻ tấn công tốn 1 request, bạn tốn nhiều hơn thế.
- Làm nhiễu monitoring. Error rate tăng vọt vì rác từ bên ngoài. Cảnh báo lúc 3 giờ sáng, mở lên xem thì là bot quét — nhưng lỗi thật đang trốn trong đống nhiễu đó.
- Lộ thông tin. Stack trace cho biết framework, version, cấu trúc thư mục — miễn phí cho người dò.
Type ở đâu thì đúng chỗ? Đúng chỗ là ngay sau guard, không phải ở property:
flowchart LR RAW["public sort — mixed, sát biên client"] --> G["fromRequest mixed trả về enum"] G --> TYPED["CategoryBrowseSort enum — kiểu chặt từ đây"] TYPED --> SQL["apply Builder — ORDER BY viết cứng"]
Nhờ vậy dữ liệu client không bao giờ trở thành một identifier trong câu
SQL. Enum chỉ có 5 case, mỗi case map sang một ORDER BY viết cứng
sẵn. Có test chứng minh:
// tests/Feature/CategoryBrowse/CategoryBrowseSortTest.php
Livewire::test(CategoryBrowse::class, ['category' => $this->category])
->set('sort', 'min_price_cents asc; drop table products --')
->assertOk()
->assertSeeInOrder($newestFirst) // vẫn là thứ tự mặc định
->set('sort', ['best-rating'])
->assertOk()
->assertSeeInOrder($newestFirst);
$this->assertDatabaseCount('products', 3); // bảng vẫn còn
Cùng lý do đó, ProductDetail để mixed cho tất cả property
client ghi được, kèm một helper duy nhất:
/** @var mixed */ public $quantity = 1;
/** @var mixed */ public $selectedImage = 0;
/** @var mixed */ public $imagePinned = false;
private static function asInt(mixed $value): int
{
return is_numeric($value) ? (int) $value : 0; // array, object, chữ → 0
}
2.4. Sanitise chạy trong render(), không phải mount()
Đây là hệ quả trực tiếp của phần 1.1. Guard đặt trong mount() chỉ chạy
một lần, ở request mà kẻ tấn công không cần dùng.
// app/Livewire/ProductDetail.php — render() chạy ở MỌI request
public function render(CategoryTree $tree)
{
$catalogue = $this->catalogue();
// Gán ngược vào property luôn: state bẩn không sống sót qua request này.
$selections = $this->selections = $catalogue->sanitise($this->selections);
$selected = $this->selectedImage = $this->clampToGallery($this->selectedImage);
$pinned = $this->imagePinned = (bool) $this->imagePinned;
$quantity = $this->quantity = $this->clampQuantity($this->quantity, $ceiling);
// ...
}
Để ý chi tiết $this->selections = ...: không chỉ dùng bản sạch để
render, mà ghi đè luôn property. Vì snapshot dehydrate ở cuối request
sẽ mang giá trị property đi. Nếu chỉ dùng bản sạch cục bộ mà không ghi lại, giá trị bẩn
vẫn nằm trong snapshot và quay lại ở request sau.
CategoryBrowse làm cùng kiểu, bằng một private method mà
cả mount() lẫn render() gọi:
private function scopedCatalog(CategoryBrowseCatalog $catalog, ?PriceRange $price): CategoryBrowseCatalog
{
$scoped = $catalog->forCategory($this->category)->withPrice($price);
if ($this->page < 1) {
$this->page = 1; // clamp
}
abort_if($this->page > $scoped->lastPage(), 404); // ?page=400000 → 404, không phải trang rỗng
return $scoped;
}
flowchart TB
subgraph BAD["Guard chỉ ở mount()"]
B1["GET đầu → mount() → guard chạy → sạch"]
B2["POST update → mount() KHÔNG chạy"]
B3["property bẩn → thẳng vào query"]
B1 --> B2 --> B3
end
subgraph GOOD["Guard ở render() (project làm thế này)"]
G1["GET đầu → mount() + render() → guard chạy"]
G2["POST update → render() → guard chạy LẠI"]
G3["property được ghi đè bằng bản sạch"]
G4["snapshot mới cũng sạch"]
G1 --> G2 --> G3 --> G4
end
Vì sao mount() vẫn gọi scopedCatalog() nếu
render() đã gọi? Không phải để bảo mật — mà để 404 xảy ra sớm,
ngay ở request GET, trước khi làm việc thừa. Guard thật nằm ở render().
Comment trong code nói đúng một câu: "a mount-only guard is no guard at all against
the update endpoint".
2.5. Bảng tổng kết bốn kỹ thuật
| Kỹ thuật | Chặn cái gì | Phản ứng khi gặp input xấu | Vì sao chọn phản ứng đó |
|---|---|---|---|
#[Locked] |
Đổi model đang xem | Ném exception | Không có lý do lương thiện nào để client ghi vào đây. Không có "ý định tốt" để đoán. |
sanitise() allow-list |
Value của product khác | Bỏ âm thầm | Selection là vị trí trong bộ chọn. Value lạ = "chưa chọn trục đó". |
fromRequest(mixed) |
SQL injection, mảng, typo | Về mặc định | Link cũ / typo không đáng nhận 404. Thứ tự lạ = "không yêu cầu thứ tự nào". |
appliedRange() (min/max) |
Giá không hợp lệ, đảo ngược | 404 cứng | Nếu clamp thì URL nói một đằng, kết quả một nẻo. Không được nói dối người dùng. |
page |
?page=400000 |
Clamp dưới, 404 trên | Chặn soft-404 farm (hàng loạt URL rác trả 200) và chặn cạn tài nguyên. |
Không có một phản ứng đúng cho mọi trường hợp. Bốn loại input ở trên nhận bốn phản ứng khác nhau, và mỗi lựa chọn đều viết rõ lý do trong code. Đây chính là chỗ interviewer đào sâu: "sao chỗ này bỏ mà chỗ kia 404?"
3. Lợi ích
- Không tồn tại "đường vòng qua UI". Guard nằm ở đúng nơi dữ liệu
được đọc (
render()), nên client gọi thẳng API cũng không hơn gì click chuột. - Input xấu ra 200 chứ không ra 500. Error rate phản ánh lỗi thật của mình, không phản ánh mức độ bị quét. Log sạch thì on-call mới tin được cảnh báo.
- Dữ liệu client không bao giờ là identifier trong SQL. Cả sort lẫn option value đều đi qua allow-list. SQL injection không có mặt đường để đi.
- Không lộ dữ liệu chéo giữa các product. Ma trận variant chỉ dựng được từ value của chính product đang xem.
- Không lộ tồn kho. Ma trận tính ở server, view chỉ nhận boolean (dựng được / còn hàng). Một request lộ số lượng của một cấu hình, không phải của cả tám.
- Rẻ.
sanitise()chạy trên ~8 dòng đã nằm trong bộ nhớ. Không thêm query nào. - Test được. Guard là hàm thuần, viết test một dòng là xong.
4. Tác hại / hạn chế
- Chi phí mỗi request. Sanitise chạy ở mọi request, kể cả khi người dùng chỉ bấm mũi tên tăng số lượng. Ở đây rẻ vì dữ liệu có sẵn — nhưng nếu allow-list cần query DB thì đây là N query cho N lần click.
- Bỏ âm thầm thì khó debug. Selection biến mất không kèm thông báo. Nếu lỗi là do bug thật của mình (đổi tên trục, sai chính tả trong seeder), triệu chứng trông y hệt input độc: chọn xong không thấy gì đổi.
mixedlàm mất khả năng kiểm tra tĩnh. PHPStan không giúp được gì ở các property này. Đổi lại phải viết test cho từng loại input xấu.- Đọc code khó hơn. Người mới vào nhìn
public $quantity = 1;không kiểu, dễ tưởng là ẩu và "sửa cho đẹp" — làm hỏng đúng cái bảo vệ. Đây là lý do docblock trong project dài đến vậy: comment đang giữ cho quyết định không bị sửa ngược. - Không thay được authorization. Những kỹ thuật này chống tampering (sửa input). Câu hỏi "user này có quyền xem không" vẫn phải hỏi riêng bằng policy / gate.
5. Trade-off
5.1. So với các phương án khác
| Phương án | Được | Mất | Khi nào chọn |
|---|---|---|---|
Sanitise trong render() (project chọn) |
Bọc kín mọi đường vào; không thêm query | Chạy lại mỗi request | Allow-list vốn đã nằm trong bộ nhớ của request |
updated() / updatedFoo() hook |
Chỉ chạy khi property thật sự đổi | Không chạy khi state bẩn đến từ snapshot hoặc query string | Chuẩn hoá cho tiện dùng (trim, lowercase), không để bảo mật |
protected + boot() dựng lại |
Client tuyệt đối không ghi được | Phải dựng lại mỗi request; state người dùng chọn thì không giữ được | Dữ liệu suy ra được, không phải lựa chọn của người dùng |
| Ký (sign) selection rồi kiểm chữ ký | Không cần tra allow-list | Thêm khoá bí mật, thêm chỗ hỏng, vẫn không chặn được replay từ product khác | Gần như không bao giờ ở tình huống này |
| Validate ở client (JS) | Phản hồi nhanh | Không phải bảo mật — client bỏ qua được | Chỉ như lớp UX phủ lên guard server |
5.2. Bỏ âm thầm hay báo lỗi?
flowchart TB
Q1{"Client có lý do lương thiện
để gửi giá trị này không?"}
Q1 -- "Không bao giờ
(model đã bind route)" --> A1["Ném lỗi — thuộc tính Locked"]
Q1 -- "Có, nhưng URL sẽ nói dối
(khoảng giá sai)" --> A2["404 cứng"]
Q1 -- "Có: link cũ, typo, bookmark
(sort, index ảnh)" --> A3["Về mặc định, 200"]
Q1 -- "Có: người dùng đang chọn dở
(selection)" --> A4["Bỏ giá trị lạ, 200"]
Tiêu chí quyết định — đây là câu trả lời cho "sao chỗ này 404 mà chỗ kia không":
- Có URL nào lương thiện dẫn tới đây không? Link cũ và bookmark là lương thiện → không được 404.
- Nếu sửa giá trị lại, trang có nói dối không? Clamp
?min=abcthành 0 nghĩa là URL nói "lọc từ abc" mà kết quả là "không lọc" → phải 404, không được clamp. - Nếu 404, SEO có mất gì không? 404 vì một tham số
sortcũ sẽ giết luôn link mà crawler đang giữ.
5.3. Type chặt hay mixed?
Đây là trade-off thật, và trả lời phỏng vấn phải nói được cả hai phía:
- Chỉ dùng
mixedở đúng biên client — public property và tham số của action. Đó là nơi PHP type system trở thành vector tấn công chứ không phải lớp bảo vệ, vì nó ném lỗi trước khi code của mình chạy được. - Sau guard là type chặt ngay.
CategoryBrowseSortlà enum,PriceRangelà value object,ChosenVariantcó kiểu rõ. Vùngmixedchỉ dày đúng một hàm. - Nếu để
mixedlan sâu vào service và query thì đó là code tệ thật, không phải bảo mật.
6. Khó khăn khi vận hành thực tế
- Không nhìn thấy được. Guard chạy đúng thì không để lại dấu vết
nào. Không log, không metric. Bạn không biết hôm nay bị dò bao nhiêu lần. Muốn biết
thì phải chủ động đếm — nhưng log mỗi lần bỏ giá trị thì lại tự tạo kênh cho người
ngoài làm phình log của mình.
Cách cân bằng: đếm bằng counter metric (chỉ tăng số, không ghi nội dung), sampling hoặc rate-limit phần log. - Rất dễ hỏng khi refactor. Ba thao tác trông vô hại đều phá guard:
thêm type vào property, chuyển sanitise từ
render()sangmount()"cho khỏi chạy lại", đổi#[Locked] public Product $productthànhpublic $productId. Cả ba đều qua được code review nếu người review chưa biết cơ chế này.
Cách chống: test là thứ giữ, không phải comment. Nhưng comment giải thích tại sao mới ngăn được người ta xoá test. - Debug lúc 3 giờ sáng khó. Báo cáo "chọn màu xong giá không đổi"
có thể là (a) input độc bị bỏ đúng như thiết kế, (b) seeder ghi sai chính tả tên trục,
(c) cache ma trận variant cũ. Cả ba cho ra cùng một triệu chứng.
Cách rút ngắn: một artisan command in rasanitise()nhận vào gì và trả ra gì cho một product cụ thể. Không cần dashboard. - Người mới trong team. Cơ chế này không phải kiến thức mặc định.
Cần dạy một lần: mở
_livewirepayload trong tab Network, sửa tay, xem server phản ứng. Hiểu 10 phút, đọc tài liệu không hiểu được. - Không tự phát hiện lỗ hổng mới. Thêm một public property mới là thêm một cửa vào. Không có linter nào cảnh báo. Cách rẻ nhất: checklist khi review — "property mới này ai ghi được, và nó được làm sạch ở đâu?"
7. Case thực tế và cách xử lý
Case 1 — Bug bounty: đọc được biến thể của product khác
Báo cáo: "Ở trang product A, tôi set
selections = {'Color': 'Titanium Rose'} qua update endpoint. Trang trả về
200 và hiện một SKU không thuộc product A."
Vì sao xảy ra (giả định code cũ): component chỉ validate
selections.* => string, rồi tra thẳng vào bảng
product_option_values theo value — không giới hạn theo product.
sequenceDiagram
participant A as Kẻ tấn công
participant C as Component code cũ
participant DB as Database
A->>C: set selections = {Color: 'Titanium Rose'}
C->>C: validate: là string → qua
C->>DB: tìm variant theo value, KHÔNG lọc theo product
DB-->>C: variant của product KHÁC
C-->>A: 200 + SKU, giá, tồn kho của product khác
Xử lý theo từng bước:
- Chặn máu trước. Thêm
->where('product_id', $this->product->id)vào query đang rò. Deploy ngay. Đây là vá triệu chứng, chưa phải vá gốc. - Tìm gốc.
grepmọi chỗ dùng$this->selections. Nếu có 5 chỗ dùng thì có 5 lỗ, không phải 1 — vá từng chỗ là sai. Guard phải nằm ở nơi tất cả đường đi qua. - Vá gốc. Thêm
VariantCatalogue::sanitise()và gọi ở đầurender(), kèm gán ngược vào property. Mọi chỗ đọc$this->selectionstừ đây đọc bản đã sạch. - Test khoá lại.
$this->matrix() ->set('selections', ['Color' => 'Silver', 'Wattage' => '100W', 'Storage' => 'bad']) ->assertSet('selections', ['Color' => 'Silver']) // giữ cái có thật, bỏ cái lạ ->set('selections', 'not-even-an-array') ->assertSet('selections', []) ->assertOk(); - Quét chỗ khác. Cùng lỗi này thường lặp ở: chọn địa chỉ giao hàng (address id của user khác), áp mã giảm giá (mã của campaign khác), đổi biến thể trong giỏ. Cùng một câu hỏi: "allow-list đối chiếu với thực thể nào?"
Case 2 — Cảnh báo 3 giờ sáng: error rate 500 tăng vọt
Triệu chứng: Sentry bắn ~2.000 TypeError trong 10 phút.
Message: Cannot assign array to property App\Livewire\CategoryBrowse::$sort of type string.
Traffic không tăng. Không ai deploy gì.
Chẩn đoán: một con bot đang quét lỗ hổng, gửi hàng loạt
?sort[]=.... Mỗi request là một exception. App vẫn "chạy", nhưng:
- Quota Sentry cháy trong đêm → lỗi thật sáng mai không được ghi.
- Cảnh báo error-rate bắn liên tục → on-call mất niềm tin vào cảnh báo.
- Dựng stack trace tốn CPU hơn hẳn một response 200 bình thường.
Xử lý theo từng bước:
- Ngay lập tức (phút thứ 1): chặn tạm ở WAF / rate limit theo IP. Cầm máu để ngủ tiếp.
- Sáng hôm sau — sửa gốc: bỏ type khỏi property, đẩy nó vào guard.
- public string $sort = ''; + /** @var mixed */ + public $sort = ''; - $sort = CategoryBrowseSort::from($this->sort); + $sort = CategoryBrowseSort::fromRequest($this->sort); // nhận mixed - Test khoá lại:
public function test_a_non_string_sort_falls_back_to_the_default_rather_than_erroring(): void { $newestFirst = $this->seedThreeProducts(); $this->get($this->path('?sort[]=best-rating')) ->assertOk() // 200, không phải 500 ->assertSeeInOrder($newestFirst); // dùng thứ tự mặc định } - Quét cả repo: tìm mọi public property có kiểu chặt trong
app/Livewire/. Mỗi cái là một 500 chờ sẵn.grep -rnE 'public (int|string|bool|float|array) \$' app/Livewire/ - Gỡ chặn WAF sau khi deploy. Bot vẫn quét, nhưng giờ nó nhận 200 và không tốn gì của mình.
Case 3 — Người dùng thật báo "sao trang tự 404?"
Báo cáo: khách lọc giá xong, bấm sang trang 2, bị 404. Trước đó không sao.
Chẩn đoán: đây không phải bug. scopedCatalog()
tính lastPage() sau khi đã áp bộ lọc giá. Danh sách hẹp lại
thì số trang cũng ít đi. Đang ở trang 5, áp bộ lọc còn 2 trang → trang 5 không tồn tại
nữa → 404 đúng theo thiết kế.
Nhưng UX thì tệ. Xử lý đúng nằm ở tầng link, không phải bỏ guard:
- Link đổi bộ lọc phải bỏ tham số
page— trang 5 của tập kết quả cũ không liên quan gì tới tập mới. Trong project, đổi sort đã làm đúng điều này rồi. - Không sửa thành clamp về trang cuối. Clamp nghĩa là URL
?page=5trả nội dung trang 2 — bookmark sai, và crawler sẽ index nhiều URL cùng một nội dung (soft-404 farm). - Giữ nguyên 404 cho URL gõ tay và cho
?page=400000: chặn cả rác SEO lẫn việc bắt DB đếm qua hàng triệu row.
Bài học chung: khi guard làm phiền người dùng thật, thường lỗi nằm ở chỗ sinh link, không phải ở guard. Hạ guard xuống để chiều UX là cách nhanh nhất mở lại lỗ hổng vừa vá.
8. Câu hỏi phụ interviewer hay hỏi tiếp
Hỏi: Checksum của Livewire không đủ chống sửa dữ liệu à?
Không. Checksum ký snapshot để client không sửa được state đã dehydrate. Nhưng
ghi giá trị mới vào public property là API công khai của Livewire —
wire:model chạy chính bằng đường đó. Client viết tay request cũng dùng đúng
đường đó, với checksum hợp lệ.
Hỏi: Dùng protected cho hết đi, có phải xong không?
Không. Livewire chỉ giữ state qua request bằng public property.
protected không nằm trong snapshot nên request sau là mất. Muốn vừa giữ
state vừa chặn client thì dùng #[Locked]. Nếu dữ liệu suy ra được
(không phải lựa chọn của người dùng) thì mới dùng protected + dựng lại
trong boot().
Hỏi: #[Locked] có chặn code server ghi không?
Không. Nó chỉ chặn client. $this->product = $x trong component vẫn chạy
bình thường. Nên vẫn phải tự đảm bảo không gán dữ liệu bẩn vào property đã locked.
Hỏi: Livewire 3 đã tự khoá id của property kiểu model rồi mà, sao còn viết
#[Locked]?
Đúng, nên nó là belt-and-braces (thắt lưng kèm dây đeo). Ba lý do vẫn viết: nói rõ ý
định cho người đọc code; giữ được bảo vệ nếu sau này ai đó đổi thành
public string $productId; và ném lỗi rõ ràng thay vì im lặng. Chi phí là
một dòng.
Hỏi: Sao không validate hết bằng $rules cho gọn?
Vì validate() trả lời câu "dữ liệu này có đúng hình dạng không", còn câu
cần hỏi là "product này có giá trị đó không". 'selections.* => string'
cho 'Titanium Rose' đi qua thoải mái. Allow-list đối chiếu với dữ liệu đã
load của chính product mới là lớp kiểm soát thật. Project vẫn dùng
validate() — nhưng cho khoảng giá của form, nơi người dùng cần thấy thông
báo lỗi.
Hỏi: Bỏ type để tránh 500 nghe như đánh đổi type safety lấy bảo mật?
Vùng mixed chỉ dày đúng một hàm. Property là mixed, guard nhận
mixed và trả về kiểu chặt — enum, value object, int — rồi từ
đó trở đi type chặt hết. Type ở property không bảo vệ gì cả, vì nó ném lỗi trước
khi code mình chạy. Type sau guard mới có tác dụng.
Hỏi: Vậy 500 do TypeError thì có gì nghiêm trọng? Có bị lộ dữ liệu đâu.
Không lộ dữ liệu, nhưng: một URL đổi lấy một exception là DoS rẻ tiền; error rate bị
nhiễu làm cảnh báo mất giá trị; và stack trace lộ framework, version, đường dẫn. Đủ tệ
để không đáng đánh đổi.
Hỏi: updated() hook dùng để sanitise được không?
Không đủ. updated() chỉ chạy khi property thay đổi trong request đó. State
bẩn đến từ snapshot cũ hoặc từ query string (#[Url]) không kích hoạt nó.
Dùng updated() cho chuẩn hoá tiện dụng (trim, lowercase), không dùng làm
lớp bảo mật.
Hỏi: Sanitise chạy mọi request thì không tốn hiệu năng à?
Ở đây thì không — allow-list là ~8 dòng variant đã nằm trong bộ nhớ của request, và
chúng cần cho việc render dù sao. Không thêm query. Nếu allow-list phải query DB thì
đúng là tốn, và cách xử lý là cache theo request (như $this->catalogue ??=
trong ProductDetail), chứ vẫn không được chuyển guard về
mount().
Hỏi: Bỏ âm thầm thì làm sao biết mình đang bị tấn công?
Không biết, đúng vậy. Muốn biết thì đếm bằng counter metric (chỉ tăng số, không ghi nội
dung). Không log full payload — làm thế là tự cho người ngoài một cách làm phình log
của mình.
Hỏi: Cái này thay được authorization không?
Không. Đây là chống tampering. "User này có được xem không" vẫn phải hỏi bằng policy /
gate riêng. Ở project này catalogue là dữ liệu công khai nên không có tầng đó — trang có
dữ liệu riêng của user thì phải thêm.
Hỏi: Làm sao ngăn người sau vô tình phá những guard này?
Test là thứ giữ thật: mỗi guard có một test gửi input xấu và khẳng định 200 + hành vi
mặc định. Comment giải thích tại sao thì ngăn người ta xoá test. Chỉ có comment
mà không có test thì trước sau gì cũng hỏng.