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.phpapp/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 endpointPOST /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:

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:

  1. 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.
  2. Chống refactor làm hỏng. Ngày nào đó có người đổi public Category $category thành public string $categoryId cho "nhẹ snapshot" — lúc đó auto-lock biến mất, còn #[Locked] thì ở lại và vẫn chặn.
  3. 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 protected cho gọn? Vì Livewire chỉ giữ state qua các request bằng public property. protected không nằm trong snapshot nên request sau sẽ mất. #[Locked] sinh ra chính để giải bài toán đó: vẫn public để 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ậtRegex / rule viết tayMa trận variant đã load sẵn trong request
['Color' => 'Titanium Rose']Qua ✗Bị bỏ ✓
Chi phí0 query0 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:

  1. 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ế.
  2. 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 đó.
  3. 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ậtChặn cái gìPhản ứng khi gặp input xấuVì 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

4. Tác hại / hạn chế

5. Trade-off

5.1. So với các phương án khác

Phương ánĐượcMấtKhi 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":

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:

6. Khó khăn khi vận hành thực tế

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:

  1. 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.
  2. Tìm gốc. grep mọ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.
  3. Vá gốc. Thêm VariantCatalogue::sanitise() và gọi ở đầu render(), kèm gán ngược vào property. Mọi chỗ đọc $this->selections từ đây đọc bản đã sạch.
  4. 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();
    
  5. 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:

Xử lý theo từng bước:

  1. 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.
  2. 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
    
  3. 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
    }
    
  4. 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/
  5. 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:

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