Environment: Joomla 6.1.2, PHP 8.4.17.
Steps to reproduce
limit, e.g. index.php?option=com_tags&view=tags&limit[]=5 or index.php?option=com_contact&view=featured&limit[]=5. The page renders normally (HTTP 200).global.list.limit as an array: s:6:"global";...s:4:"list";...s:5:"limit";a:1:{i:0;i:5;}.Expected: getUserStateFromRequest($key, $request, $default, 'uint') either returns an unsigned int or falls back to the default. It should never persist an array under a key filtered as a scalar.
Actual: CMSApplication::getUserStateFromRequest() (libraries/src/Application/CMSApplication.php:771-784) stores whatever $this->input->get($request, null, $type) returned, without checking that the filter actually produced a scalar:
$new_state = $this->input->get($request, null, $type);
if ($new_state === null) {
return $cur_state;
}
// Save the new value only if it was set in this request.
$this->setUserState($key, $new_state);The filter cannot produce a scalar here, because Joomla\Filter\InputFilter::clean() branches on is_array($source) (libraries/vendor/joomla/filter/src/InputFilter.php:227-235) and recurses, returning an array, long before cleanInt/cleanUint (lines 728-763) are reached. Confirmed directly against the shipped library on PHP 8.4.17:
$input = new Joomla\Input\Input(['limit' => ['5']]);
$input->getUint('limit', 0); // array(1) { [0]=> int(1) }
$input->get('limit', 0, 'uint'); // array(1) { [0]=> int(1) }
(new Joomla\Filter\InputFilter())->clean(['5'], 'UINT'); // array(1) { [0]=> int(1) }Why this matters beyond one component
global.list.limit is not namespaced per component. It is written by com_contact (FeaturedModel.php:169), com_tags (TagsModel.php:142, TagModel.php:224), com_newsfeeds (CategoryModel.php:252) and the B/C branch of ListModel::populateState() (ListModel.php:552), and read by every component that uses the same key. So a request to one component silently changes the type of a value another component will read.
Core itself does not crash, because the value is eventually coerced by DatabaseQuery::setLimit() (libraries/vendor/joomla/database/src/DatabaseQuery.php:1648, $this->limit = (int) $limit;), and (int) ['5'] is 1 in PHP 8.4 with no notice at all. So core silently paginates by one row instead of failing loudly. Third-party components that do arithmetic on the value before it reaches the query builder get a hard TypeError instead, and because the poisoned value lives in the session, every subsequent request from that visitor fails too - a persistent per-session denial of service triggered by a single link. We hit exactly this with a commercial component (reported separately to its vendor).
Note that the same recursion also makes Input::getInt() / getUint() return arrays generally, so the usual "just use getUint()" advice does not protect calling code.
Suggested fix (any one of these, in order of preference)
InputFilter::clean(), do not recurse for scalar filter types - return the default/zero value for a non-scalar source when the requested type is INT, UINT, FLOAT, BOOL or WORD.CMSApplication::getUserStateFromRequest(), only persist $new_state when it is a scalar and the requested $type is a scalar type; otherwise fall back to $cur_state / $default.This is not a data-disclosure issue, but it is a remote, unauthenticated, persistent DoS primitive against any site running a component that trusts the documented return type. A session cookie is sent on top-level navigation under the default SameSite=Lax, so a single shared link is enough to brick a victim's session.
| Labels |
Removed:
?
|
||
| Labels |
Added:
No Code Attached Yet
|
||