Skip to content

fix(api): only real relations can be requested through with, expand and counts - #303

Merged
roncodes merged 3 commits into
release/v1.6.69from
fix/request-relation-allowlist
Oct 13, 2026
Merged

roncodes merged 3 commits into
release/v1.6.69from
fix/request-relation-allowlist

Conversation

@roncodes

@roncodes roncodes commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Needs human review. This is a security fix that changes which relations a client can request. The repo has no needs-human-review label, so I'm flagging it here. Do not merge until the extension PRs listed below are reviewed alongside it.

The bug

List, find, create and update endpoints pass the client's with / expand / with_count / count names to Eloquent as eager loads and counts, and nothing checks that the names are relations. To resolve each name, Eloquent's Builder::getRelation() runs Relation::noConstraints(fn () => $model->newInstance()->$name()). So a request can run any public method that takes no required arguments, on a blank model:

  • with[]=saveInstance inserts an empty row. Fleetbase\Models\Model::saveInstance() exists on every Fleetbase model.
  • On fleetops orders, with[]=dispatch runs Order::dispatch(). It saves a blank order and queues OrderDispatched, which fires webhooks.
    • Example: GET /int/v1/orders?with[]=dispatch, or the public v1 API with an API key.
    • cancel() and notifyDriverAssigned() work the same way, as do methods on other models across extensions.
  • with_count calls the method while the query is built, so it runs even when the result set is empty.

This reached Eloquent through these paths:

  • applyCustomFilters()
  • withRelationships() (nested paths were not checked at all)
  • withCounts()
  • the load() / loadCount() after create and update
  • mutateModelWithRequest()
  • TemplateQuery::execute(), whose stored with list comes unvalidated from the template builder and template-queries CRUD

The first commit adds a failing test that shows it. A model whose dispatch() writes a row is requested through list, find and create; with with as an array and as a string, expand, a nested path, with_count, count, and saveInstance. Before the fix, dispatch() ran 3 times per request (list, find, create), wrote junk rows, and every case ended in an exception (a 500):

Tests: 8 failed (12 assertions)
Failed asserting that 3 is identical to 0.   (dispatched count)
Failed asserting that two arrays are identical.   (errors: BadMethodCallException / RelationNotFoundException / TypeError / QueryException)

The fix

Fleetbase\Support\RequestRelations decides which names reach Eloquent. It reads the method by reflection and never calls an unknown one.

A segment is accepted only when the method is public, not static, and needs no arguments, and one of these holds:

  • it declares an Eloquent Relation return type, for example BelongsTo or ?HasMany;
  • it declares a union return type with at least one Relation member, for example BelongsTo|Builder (fleetops Order::driverAssigned(), vehicleAssigned(), facilitator(), and Driver::currentJob(), jobs() use this);
  • it is an Expandable closure that declares such a return type;
  • the model lists the name in a new protected $withable = [...] array. This is for third-party extensions that can't add return types yet.

Nested paths: each segment is checked against the related model, and the path is cut at the first segment that fails, so children.dispatch loads children. Nothing after a morphTo is kept, because its related model is only known per result. A morphTo is never counted. Depth is capped at 5 segments and the list at 50 paths.

Names come back as declared. subfleets now loads as subFleets, the key Fleet resources read with whenLoaded('subFleets'). Before, it loaded under subfleets, which never rendered.

Malformed input is dropped: anything outside [A-Za-z_][A-Za-z0-9_]* per segment, such as relation:col,col, spaces or non-strings. No client uses the column-select syntax.

Rejected names are dropped without an error. This matches the Postman contract for GET /v1/vehicles/:id?with[]=vendor&with[]=not_a_relation.

Also in this PR:

  • withCounts() now accepts an array; before, an array caused a TypeError.
  • without uses makeHidden() instead of setHidden(). setHidden() replaced the model's own $hidden list, so without[]=x could un-hide password-like columns on responses that serialize the model.
  • normalizeRequestRelations() is removed. It was protected and had no callers outside the trait.

#301 adds a similar private helper, ApiModelCache::relationMethod() / declaresRelationReturn(). #301 is still open, so this PR doesn't depend on it. Once both merge, ApiModelCache can call RequestRelations::relationMethod() and drop its copy. That's a one-line follow-up I can do on whichever lands second.

Relations that stop being requestable

A survey of every client that sends with / with_count (console, every engine, navigator-app, storefront-app, fleetbase-js, storefront-js, fleetbase-php) found no untyped core-api relation being requested. Company::users (with_count on the admin organizations list) is already typed.

The untyped relations that clients request are all in extensions. Each has a companion PR adding return types. Those must merge with or before this one, or the screens below lose the nested data. They won't error.

Package Relations (untyped today) Screens / API
fleetops Fleet: serviceArea, zone, parentFleet, subFleets, drivers, vehicles, vendor, photo · Issue: reporter, assignee, vehicle, driver · FuelReport: driver, vehicle, reporter · ServiceRate: rateFees, parcelFees, serviceArea, zone · Driver: vehicle · Payload: entities, pickup, dropoff, return, waypoints · Expansions: User.driver/driverProfiles/customer/contact, Company.drivers fleets list/details/sidebar, issues, fuel reports, service rates, scheduler (driverAssigned.vehicle), order payload; public v1 documented fleets?with=service_area,zone,vendor,parent_fleet,photo,subfleets,drivers,vehicles and drivers?with=vehicle
storefront Store: networks · Product: addonCategories, variants, files, hours · ProductAddonCategory: category · ProductVariant: options · StoreLocation: hours store switcher, product edit, locations
pallet none on dev-v0.0.2: c56365d9 already typed all 157 relations. Every pallet console with set passes RequestRelations unchanged (checked against this branch). The released pallet code, 55247cf9 and earlier, is untyped, so pallet must ship from dev-v0.0.2 before or with this release. every pallet list screen

Companion PRs:

These are not covered by a companion PR:

  • billing UserExpansion::billableCompany, registry-bridge CategoryExpansion::registryExtensions and samsara CompanyExpansion::samsaraCredentials are untyped Expansion closures. No client requests them as with, so nothing breaks. Typing them is a follow-up.
  • Fliit ServiceItem requests asset, issue and supplier, but those methods don't exist. They're dropped today and stay dropped.

Docs / API spec impact

  • fleetbase/postman: only with is documented, with expand as an alias, on Fleets, Drivers, Vehicles and Trailers. Every documented value stays requestable once the fleetops PR lands: Vehicle and Trailer were already typed, and Fleet and Driver::vehicle are typed by that PR. The "unknown names are ignored" contract is unchanged, so no spec change is needed.
  • fleetbase/fleetbase.io (needs-docs): content/docs/extension-development/backend/expansions-and-observers.mdx:120-149 says an Expansion relation is usable in with(). It now needs a declared return type on the closure (return function (): HasOne { ... }). The extension model docs should also say that a relation is only client-requestable when it declares a Relation return type or is listed in $withable. The API reference pages (fleets.mdx, vehicles.mdx, drivers.mdx, trailer guides) need no change.

Not in this PR

  • NotificationRegistry::resolveNotifiable() turns a stored dynamic:<property> from company notification settings into $subject->{$property} / load($property). That calls the method on the real subject model, and a class definition is passed to app(). Company-admin input only, and the existing tests rely on unregistered definitions resolving. I've split it into its own task.
  • fleetops ResolvesPublicRelationFields checks with with only method_exists before loadMissing(). The fleetops companion PR fixes it with this helper.

Validation

  • php -d memory_limit=1G vendor/bin/pest, full suite on PHP 8.4: 2079 passed, 0 failed. A rerun of the touched files on the final commits: 79 passed.
  • New tests/Unit/Support/RequestRelationsTest.php covers:
    • typed, nullable and union returns; $withable, case-insensitively;
    • side-effect methods and base Model methods (save, push, touch, delete, fresh, replicate), on both the root and the related model;
    • static, protected and argument-taking methods;
    • nested cuts and the depth cap; morphTo; typed vs untyped Expandable closures; malformed input and the path cap.
  • New trait tests: the side-effect data set across 8 request shapes and the makeHidden regression. A TemplateQuery test covers a stored side-effect name.
  • php-cs-fixer fix --dry-run on the changed files: clean.
  • phpstan (level max) on the new file: it flags only the dynamic Expandable static calls ($model::isExpansion()), the same pattern the existing code uses. The trait and model already carry many pre-existing level-max findings, so phpstan isn't a gate here.

CI note

PHP CI's coverage gate requires 100% coverage of every file. 56f94208 brings RequestRelations.php and this PR's lines in HasApiModelBehavior.php to 100%.

One gap remains that this PR didn't cause: src/Support/ApiModelCache.php:519, the return [] in getSessionScope() when the request has no session. It came in with #300 and makes PHP CI fail on every branch cut from release/v1.6.69 (#301 and the storefront-logging PR as well). I left it alone because #301 rewrites ApiModelCacheTest.php, and a fix here would conflict with it. It needs a one-line test there or in #301.

with/expand/with_count/count names reach Eloquent's eager loader, which
calls $model->newInstance()->$name() for any public method with no
required arguments. The new case uses a model whose dispatch() writes a
row (as fleetops' Order::dispatch() does) and requests it through every
path: list, find and create; with as array and string, expand, a nested
path, with_count, count, and the base Model's saveInstance(). It fails
today: dispatch() runs on each path and every request ends in a 500.
…nd counts

Eloquent resolves an eager load or a count by calling
$model->newInstance()->{$name}(), so every request-supplied relation name
ran whatever public no-argument method it named on a blank model:
`with[]=saveInstance` inserted an empty row on any model, and on fleetops
orders `with[]=dispatch` saved an order and queued OrderDispatched.

Fleetbase\Support\RequestRelations now decides which names reach
Eloquent. A segment is accepted when the method, or the Expandable
closure registered under that name, declares an Eloquent Relation return
type (a union counts when one member is a Relation), or when the model
lists it in a new `$withable` array. The method must be public,
non-static, and need no arguments. Each segment of a nested path is
checked against the related model, and the path is cut at the first
segment that fails. Nothing after a morphTo is kept, since its related
model is only known per result, and a morphTo is never counted. Names
come back as declared, so `subfleets` loads as `subFleets`. Rejected
names are dropped without an error.

The guard covers every place a client's names reach Eloquent:
- applyCustomFilters()
- withRelationships() and withCounts()
- the load()/loadCount() after create and update
- mutateModelWithRequest()
- TemplateQuery::execute(), whose stored `with` list is user-editable

withCounts() also accepts an array now; before, it was a TypeError.

`without` in mutateModelWithRequest() now uses makeHidden() instead of
setHidden(). setHidden() replaced the model's own hidden attributes
rather than adding to them.

Fixtures with untyped relations gain return types. The nested-path
expectation drops `childItems.grandChildren`, a relation that never
existed.
The coverage gate needs 100% per file. A typed relation that throws on a
blank model is now tested (it is dropped), as is mutateModelWithRequest()
on a non-model. The Expandable branch loses a Closure check that could
never fail: isExpansion() only holds for a registered Closure.
@roncodes
roncodes merged commit fce4242 into release/v1.6.69 Oct 13, 2026
2 of 3 checks passed
@roncodes
roncodes deleted the fix/request-relation-allowlist branch October 13, 2026 06:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant