Apply the site boundary to the API, not just to the management pages - #1229
Merged
Conversation
The site plugin's only filtering hook is registered on HOST_DATA and
GROUP_DATA, and both handlers switch on the global $node/$sub the
management pages set. Nothing under api/ fires those events, and the
plugin's other hook -- API_VALID_CLASSES, API_GETTER, the two data
mappings -- adds `site` as an API class and stamps siteID onto host
payloads without filtering anything.
So a site-restricted user saw their site in the grid and every host on
the server through /fog/host/list, on the same credentials. No API token
and no uAllowAPI needed either: Route skips API auth entirely when a
management session is already valid, so the same browser that shows the
filtered grid returns the unfiltered set one URL away.
Core gains one seam and stays ignorant of sites. API_SCOPE_IDS asks
whoever is listening which object ids the acting user may see; with no
plugin, or an unrestricted user, or nobody logged in, the answer stays
null and every read behaves exactly as before.
listem(), search() filter the rows -- neither route has a LIMIT, so
this is exact and keeps 'count' honest
names(), ids() fold it into the WHERE, since those two only ever
produce ids
runMatches() gates indiv/update/delete/task/cancel in one
place, beside the uType check and for the same
reason
The return is a tri-state and that is the whole design: null means no
boundary, an array narrows, and an EMPTY array is a real answer meaning
"nothing". `if (!$ids)` is true for both null and array(), so a caller
written that way shows all 2079 hosts to the one user entitled to none.
An empty intersection is passed to _buildWhere() as an empty array on
purpose -- that compiles to `WHERE 1=0` rather than dropping the term.
The membership rule moves to Site as static methods and BOTH hooks call
it, because two statements of who may see what is a boundary that is
decorative the first time they disagree. The management hook's helpers
are now thin wrappers, and its search branch intersects against the same
host set instead of asking SiteHostAssociation a second question of its
own. Two behaviour changes fall out, both toward the safe answer:
groupIDsForSites() returns nothing for a user with no hosts rather than
querying GroupAssociation with an empty hostID set, and isRestricted()
on a user with no restriction row returns false instead of a notice.
Verified against the 1.5 lab -- 2079 hosts, a user entitled to 2:
shipped patched admin
list host 126 2 126
search host "a" 68 2 68
names host 2079 2 2079
ids host 2079 2 2079
ids host name 2079 2 2079
A caller's own filter is intersected, not replaced: ids?id=1,99999
returns [1] for both users. A restricted user belonging to NO site gets 0
on every route, not 2079. With nobody logged in -- the daemons and the
status endpoints, which reach Route::ids() constantly -- _scopeIDs()
returns null and ids('host') still answers 2079. The per-object gate
allows host 1 and denies host 7075.
Driver, fixture and teardown:
scripts/background_scripts/{probe,fixture,teardown}_site_api_scope.*
tests/site-api-scope.test.php pins the shape statically, so it runs in
the pre-commit hook: each read route still consults the boundary, the
dispatcher still gates per-object routes, the null comparisons keep both
halves, the deny-all branch still returns an array, and the membership
rule is stated once. Seven mutations checked, all caught.
Co-Authored-By: Claude <noreply@anthropic.com>
mastacontrola
pushed a commit
that referenced
this pull request
Aug 19, 2026
#1229 built the boundary on dev-branch. Updates the scan's dev-branch section from "flagged, no fix proposed" to what landed and what it was verified against. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The
siteplugin's only filtering hook is registered onHOST_DATAandGROUP_DATA. Both handlers open withglobal $node; global $sub;and switch on them — the management pages. Nothing underapi/fires those events.The plugin's other hook (
addsiteapi.hook.php) is registered onAPI_VALID_CLASSES,API_GETTERand the two data mappings. It addssiteandsitehostassociationas API classes and stampssiteIDonto host payloads. It filters nothing.So a site-restricted user saw their site in the grid and every host on the server through
/fog/host/list.Reachable without an API token and without
uAllowAPI:Route::__construct()skips_testToken()/_testAuth()entirely whenself::$FOGUser->isValid(), so the same browser session that renders the filtered grid returns the unfiltered set one URL away. With API credentials,_requireAuthorized()allows a non-adminlist,listdetails,search,names,ids,indivandactiveonhostandgroup. Both requireFOG_API_ENABLED, which is off by default.Not a regression — the API was never site-scoped on this line. 1.6 does not share it: object scope there lives in the query and the per-object routes are gated at dispatch.
The fix
Core gains one seam and stays ignorant of sites.
API_SCOPE_IDSasks whoever is listening which object ids the acting user may see. With no plugin, an unrestricted user, or nobody logged in, the answer staysnulland every read behaves exactly as before.listem(),search()LIMIT, so this is exact and keepscounthonestnames(),ids()WHERErunMatches()indiv/update/delete/task/canceluTypecheck and for the same reasonThe return is a tri-state and that is the design.
null= no boundary, an array narrows, an empty array is a real answer meaning "nothing".if (!$ids)is true for bothnullandarray(), so a caller written that way shows all 2079 hosts to the one user entitled to none. An empty intersection is handed to_buildWhere()as an empty array on purpose — that compiles toWHERE 1=0rather than dropping the term.The membership rule moves to
Siteas static methods and both hooks call it. Two statements of who may see what is a boundary that is decorative the first time they disagree.Behaviour changes, both toward the safe answer
getGroupIDbySite()returns nothing for a user with no hosts, instead of queryingGroupAssociationwith an emptyhostIDset.isRestricted()on a user with no restriction row returnsfalseinstead of an undefined-index notice.Verification
Against the 1.5 lab — 2079 hosts, a user entitled to 2:
list hostsearch host "a"names hostids hostids host(field=name)Also verified:
ids?id=1,99999→[1]for both users.0on every route, not 2079.status/*.php, which reachRoute::ids()constantly —_scopeIDs()returnsnullandids('host')still answers 2079.Driver, fixture and teardown:
scripts/background_scripts/{probe,fixture,teardown}_site_api_scope.*Tests
tests/site-api-scope.test.php— 28 checks, static so it runs in the pre-commit hook. Pins that each read route still consults the boundary, the dispatcher still gates per-object routes, thenullcomparisons keep both halves, the deny-all branch still returns an array,_buildWhere()still compiles an emptyINto1=0, and the membership rule is stated once.Eight mutations checked, all caught:
listem()ignoring the boundary,names()dropping theWHEREnarrowing, the dispatch gate removed, the tri-state tested for falsiness (two forms), deny-all returningnull, the plugin no longer answering the event, and the boundary applied with nobody logged in.Full suite: 16 passed, 0 failed.
Downstream
None.
API_SCOPE_IDSis a new event with one listener;Route::$validClassesis unchanged, so FogApi's hardcoded class list needs no sync.🤖 Generated with Claude Code
https://claude.ai/code/session_01GN7hADN5QLzoxXWDA6xeUk