Security in Joomla Extensions
|
This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production. This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages. |
Security in a Joomla extension is a short list of habits repeated in every entry point: filter what comes in, escape what
goes out, prove the request was intended (token), prove the user is allowed (ACL), never build SQL from strings, treat
uploads and outbound URLs as hostile, and ship assets and files in the places Joomla expects. This page collects those
habits for the Tallpine Hiking Club package pkg_tallpine (component com_tallpine, namespace
Tallpine\Component\Tallpine, module mod_tallpine_nexthikes, plugins plg_task_tallpine_trailsync and
plg_webservices_tallpine). It ends with what the 2026 core security advisories teach, because they show the same few
mistakes in code written by the core team itself.
The page was written on 2026-10-30 against Joomla 6.2.x (6.2.1 source tree) and PHP 8.3 or later. Joomla 5.4 is in its security-only period (security fixes until 2027-10-12); the APIs below exist there too unless a version note says otherwise. Joomla 4 and 3 are end of life, and extensions that still target them carry the bugs this page describes.
Related pages: the admin-side defaults (MFA, passkeys, HTTP headers, permissions of the install) are in Security hardening, MFA and passkeys; the permission model is in ACL in extensions; query building is in The database API; forms and their filters are in Forms and form fields; and the generic PHP background (hashing, escaping, files) is in PHP: cryptography, hashing and passwords.
The shape of the defences
A request to an extension passes several independent checks. Each one stops a different class of attack, and none of them replaces another: a valid CSRF token says nothing about permission, and a permission check says nothing about whether a string is safe to print.
The Developer Manual states the underlying principles in five lines, and they are the best checklist for design reviews:
treat every input as untrusted (POST, GET, cookies, $_REQUEST, parts of $_SERVER and HTTP headers); never count
client-side checks as security; do not write your own cryptography; do not rely on obscurity; and reduce the attack
surface by preferring Joomla’s own classes to custom code.
Keeping files from running outside Joomla: the _JEXEC guard
Every PHP file of an extension that is not an entry point starts with the guard below, directly after the namespace and
use statements. The administrator and site entry points define _JEXEC before they load anything, so a file reached by
requesting its URL directly stops instead of running with no framework, no session and no access control.
<?php
/**
* @package Tallpine.Administrator
* @subpackage com_tallpine
*
* @copyright (C) 2026 Tallpine Hiking Club
* @license GNU General Public License version 2 or later
*/
namespace Tallpine\Component\Tallpine\Administrator\Controller;
use Joomla\CMS\MVC\Controller\AdminController;
// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects
class TrailsController extends AdminController
{
}
Points to know:
-
Files that only declare a class or a function are the ones that most need it, because the attack is "request the file and exploit what it does when run". A file with no side effects is harmless, but the guard is required by the JED standards and by the coding standard, and costs nothing.
-
JPATH_PLATFORMis no longer defined on a new Joomla 6 install. Extensions written for 3.x and 4.x often usedefined('JPATH_PLATFORM') or die;. With the "Behaviour - Backward Compatibility 6" (BC6) plugin disabled, that line makes the page stop with a blank screen. Replace it withdefined('_JEXEC') or die;._JEXECis the constant core uses everywhere. -
The guard does not protect the entry files themselves (
tallpine.phpof legacy components, installerscript.php, AJAX endpoints). A legacy component that loads throughindex.php?option=com_tallpineis always inside Joomla; an extension that ships its own standalone.phpURL for AJAX bypasses the framework and must not exist. Use a component task, a module?option=com_ajaxcall or a Web Services route instead. -
Files that must never be reachable (templates of the installer, data files,
*.sql) are best kept out of the web root entirely or covered by the web server configuration; see Installation and the public folder.
Reading input safely
The Input object
Controllers, dispatchers and models read request data through an instance of Joomla\Input\Input (the class of the
joomla/input framework package). In a controller it is $this->input; elsewhere take it from the application with
$app->getInput(). The CMS subclass \Joomla\CMS\Input\Input was removed in 6.0 and is available only through BC6,
so type hints and use statements written for 3.x and 4.x must name \Joomla\Input\Input.
<?php
namespace Tallpine\Component\Tallpine\Site\Controller;
use Joomla\CMS\MVC\Controller\BaseController;
\defined('_JEXEC') or die;
class TrailController extends BaseController
{
public function display($cachable = false, $urlparams = [])
{
// Default filter of get() is CMD: letters, digits, underscore, dot, dash
$layout = $this->input->get('layout', 'default');
// Typed shortcuts
$id = $this->input->getInt('id', 0); // first integer found, may be negative
$limit = $this->input->getUint('limit', 20); // unsigned integer
$search = $this->input->getString('q', ''); // decoded and stripped of tags
$season = $this->input->get('season', [], 'array');
// Per-method access: only a value really sent in the query string
$fromGet = $this->input->get->getInt('id', 0);
$fromPost = $this->input->post->getString('title', '');
// The request body of an API call sent as application/json
$payload = $this->input->json->getArray();
return parent::display($cachable, $urlparams);
}
}
Source: Joomla Manual: Input
What the Manual’s chapter and the code establish:
-
get($name, $default, $filter)defaults to theCMDfilter, so a missing filter is a conservative choice.getArray()defaults toSTRINGfor every element. -
$input->get,$input->post,$input->files,$input->jsonand$input->serverare sub-inputs bound to a single source. The Manual points out that the combined$input->get()also contains routing parameters set by SEF parsing, while$input->get->get()and$input->post->get()do not. Readpostfor state-changing data so a link cannot supply it. -
set()always writes a value into the input, anddef()writes only if the parameter is absent. Writing a default withdef()is a common source of surprising "the request said otherwise" bugs.
The filters
The filter name is the third argument of get() or the name of a typed shortcut. These are the names, as described by
the Developer Manual and implemented by the joomla/filter package that Joomla\CMS\Filter\InputFilter extends:
| Filter | What it returns | Use for |
|---|---|---|
|
The first integer found in the value (can be negative). Arrays are cast element by element. |
Identifiers from a trusted range check, |
|
An unsigned integer: the sign is discarded |
Primary keys, page numbers, limits |
|
The first floating-point number |
Coordinates, distances |
|
A boolean. Any non-empty string, including |
Avoid for text input. Use |
|
Letters and underscore only |
Identifiers of fixed vocabularies |
|
Letters and digits only |
Tokens, short codes |
|
Letters, digits, underscore, dot and dash; leading dots are removed |
Task, view, layout and format names (the default) |
|
Letters, digits, |
Data that is really base64 |
|
Decodes HTML entities, then strips all tags |
Plain-text fields (a title, a search term) |
|
Strips tags with a whitelist, without decoding entities first |
Rich text, then still escape or run the text filters |
|
No filtering, only the cast to an array |
Collections, then filter each element |
|
A relative file or directory path. Absolute paths and trailing slashes yield an empty string |
File names below a known folder |
|
Removes characters not allowed in a Joomla user name |
Do not use for authentication decisions |
|
No filtering at all |
Only when the value is handled by something else, such as |
Source: Joomla Manual: Input filters
Pitfalls specific to the filters:
-
A filter is not validation.
getInt('id')turns"7 UNION SELECT ..."into7and"abc"into0; neither is an error. After filtering, check the range or existence (if ($id < 1) throw new \InvalidArgumentException(...)). -
STRINGremoves markup; it does not make the result safe in every context. Text that reaches an attribute, a JavaScript string or a URL still needs the right escaping on output (see below). -
RAWis a decision, not a default. Reading the request raw is correct for a password the code hashes or an upload descriptor passed to the upload helpers; it is wrong for anything that is later printed or stored for printing. -
Filter arrays element by element.
get('cid', [], 'array')returns whatever the client sent. Preferget('cid', [], 'int'), which casts every element, then drop the zeros witharray_filter(). -
Names in the SEF path are input too. The router puts
id,catidandviewinto the input; theidof a SEF segment7-lake-trailbecomes7through the router, but the same value reachinggetString()would not.
InputFilter directly
When the data does not come from the request (a value from a remote API, a configuration string, a field the code is
about to store), use the filter without an Input object. Joomla\CMS\Filter\InputFilter::clean() takes the same type
names as the table above. The constructor takes an allow-list or block-list of tags and attributes; the constants
ONLY_ALLOW_DEFINED_TAGS and BLOCK_DEFINED_TAGS (and the attribute counterparts) choose the mode, and $xssAuto
controls whether the filter also cleans tags and attributes that are not blocked.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Helper;
use Joomla\CMS\Filter\InputFilter;
\defined('_JEXEC') or die;
final class TrailTextHelper
{
/**
* A plain-text summary: tags removed, entities decoded, then trimmed.
*/
public static function plain(string $value): string
{
return trim(InputFilter::getInstance()->clean($value, 'string'));
}
/**
* A short HTML note written by a trail warden. Only these tags and no attributes survive.
*/
public static function note(string $html): string
{
$filter = InputFilter::getInstance(
['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li'],
[],
InputFilter::ONLY_ALLOW_DEFINED_TAGS,
InputFilter::ONLY_ALLOW_DEFINED_ATTRIBUTES
);
return $filter->clean($html, 'html');
}
}
Source: joomla-cms 6.2-dev: InputFilter
Two things matter beyond the call itself:
-
The filter is a safety net with a history of bypasses. In 2026 the Security Centre published two advisories on
InputFilteritself: an XSS bypass through whitespace inside HTMLdata:URIs, and an XSS bypass caused by a mismatch in how HTML5 character references are decoded. Both were fixed in 5.4.9 and 6.1.4. The lesson is not to stop using it but to stack defences: filter on input and escape on output, so a bypass of one layer does not become a stored XSS. -
Rich text has its own configuration. Fields declared with
filter="JComponentHelper::filterText"(orComponentHelper::filterText($text)in code) apply the Text Filters configured in the component Options for the current user’s groups, which is how Joomla lets Super Users enter markup that Registered users cannot. Use that function for any rich-text field instead of a private allow-list; see Global configuration for the screen.
Filtering through the form
Fields declared in a form XML file are filtered and validated by the Form class when the model calls validate();
anything not declared in the XML is dropped. Setting the right filter and validate attributes in the XML is the best
place to keep input rules, because the administrator form, the front-end form and the API all share them.
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fieldset name="details">
<field name="title" type="text" label="COM_TALLPINE_FIELD_TITLE"
required="true" maxlength="255" filter="string" />
<field name="distance_km" type="number" label="COM_TALLPINE_FIELD_DISTANCE"
min="0" max="500" step="0.1" filter="float" validate="number" />
<field name="difficulty" type="list" label="COM_TALLPINE_FIELD_DIFFICULTY"
default="easy" filter="cmd">
<option value="easy">COM_TALLPINE_DIFFICULTY_EASY</option>
<option value="moderate">COM_TALLPINE_DIFFICULTY_MODERATE</option>
<option value="hard">COM_TALLPINE_DIFFICULTY_HARD</option>
</field>
<field name="description" type="editor" label="COM_TALLPINE_FIELD_DESCRIPTION"
filter="JComponentHelper::filterText" buttons="true" />
</fieldset>
</form>
Source: Joomla Manual: Forms and validations (pointing to the server-side validation chapter)
A list field’s filter="cmd" does not stop a client from sending difficulty=nonsense; add validate="options" (or
check against a set in the model) to reject values the list does not offer. The full set of rules and filters is in
Forms and form fields.
Escaping output
Escaping is the other half of XSS defence, and it is the half that does not depend on a filter being perfect. Escape at the moment of output, for the context in which it lands. The Manual calls XSS the most common issue in the extension ecosystem.
In views and layouts
Joomla\CMS\MVC\View\HtmlView::escape() calls htmlspecialchars($var, ENT_QUOTES, charset); it returns '' for null.
Since Joomla 4.0 the flag is ENT_QUOTES, so single quotes are escaped too (3.x used ENT_COMPAT), which makes
escape() safe inside attributes delimited by either quote.
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Tallpine\Component\Tallpine\Site\View\Trail\HtmlView $this */
$trail = $this->item;
?>
<article class="trail">
<h2><?php echo $this->escape($trail->title); ?></h2>
<p class="trail-meta">
<?php echo Text::sprintf(
'COM_TALLPINE_TRAIL_META',
(float) $trail->distance_km,
(int) $trail->elevation_gain
); ?>
</p>
<!-- Attribute context: escape() uses ENT_QUOTES, so both quote styles are safe -->
<a href="<?php echo Route::_('index.php?option=com_tallpine&view=trail&id=' . (int) $trail->id); ?>"
title="<?php echo $this->escape($trail->title); ?>">
<?php echo Text::_('COM_TALLPINE_READ_MORE'); ?>
</a>
<!-- Rich text from an editor field: already filtered on input, rendered through the content plugins -->
<div class="trail-description">
<?php echo HTMLHelper::_('content.prepare', $trail->description, '', 'com_tallpine.trail'); ?>
</div>
</article>
Rules that the sample follows:
-
Cast numbers. A value printed as a number is cast with
(int)or(float)instead of escaped; it cannot contain markup after the cast. -
Only trusted markup is echoed raw. The article body goes through
content.prepare(the content plugins), but it was filtered with the Text Filters when saved. A value that came from the database but was originally typed by a visitor is not trusted merely because it is in the database. -
Language strings.
Text::_()returns the language string as written. If a string contains a placeholder filled with user data,Text::sprintf()does not escape the argument; escape the argument first when the result goes to HTML. -
Build URLs with
Route::_()and cast IDs. Do not concatenate unfiltered query parameters into links.
Outside views: modules, plugins, templates and JavaScript
Modules, plugin layouts and ad-hoc helpers have no escape() method. Use htmlspecialchars() with the same arguments:
<?php
\defined('_JEXEC') or die;
/** @var array $hikes provided by the dispatcher of mod_tallpine_nexthikes */
foreach ($hikes as $hike) :
?>
<li>
<a href="<?php echo htmlspecialchars($hike->url, ENT_QUOTES, 'UTF-8'); ?>">
<?php echo htmlspecialchars($hike->title, ENT_QUOTES, 'UTF-8'); ?>
</a>
<time datetime="<?php echo htmlspecialchars($hike->date, ENT_QUOTES, 'UTF-8'); ?>">
<?php echo htmlspecialchars($hike->dateLabel, ENT_QUOTES, 'UTF-8'); ?>
</time>
</li>
<?php endforeach; ?>
Source: Joomla Manual: Common vulnerabilities (Cross-Site-Scripting: htmlspecialchars($value, ENT_QUOTES, 'UTF-8') outside component views)
The JavaScript context is different from HTML. Never write var title = '<?php echo $trail->title; ?>'; in a layout: a
quote or </script> in the title breaks out. Pass data to scripts as script options (the document serialises them as
JSON in a <script type="application/json"> block, which needs no inline code) and read them from JavaScript:
<?php
\defined('_JEXEC') or die;
$wa = $this->getDocument()->getWebAssetManager();
$wa->useScript('com_tallpine.trail-map');
$this->getDocument()->addScriptOptions('com_tallpine.trail', [
'id' => (int) $this->item->id,
'title' => $this->item->title, // serialised as JSON, no manual quoting
'lat' => (float) $this->item->lat,
'lng' => (float) $this->item->lng,
]);
// media/com_tallpine/js/trail-map.js
const options = Joomla.getOptions('com_tallpine.trail');
// Text from the server goes in with textContent, never innerHTML
document.querySelector('.trail-map-title').textContent = options.title;
Source: Joomla Manual: Web Asset Manager
In JavaScript, assigning to textContent (or creating nodes with createElement) is the equivalent of escape();
innerHTML with data from the server is the equivalent of echo without escaping.
Where core itself got it wrong
Several of the 2026 core advisories are plain output-escaping omissions: XSS in the module list, in the link toolbar
layout, in generic media output layouts, in HTMLHelper::link(), in schema.org outputs and in HTML mail templates. None
needed an exotic payload; a value the author considered trusted (a title, a link, a file name) was printed in a context
nobody had thought of. The practical rule is escape every dynamic value at the point of output, including the ones that
"can only be set by an administrator", because the administrator account is exactly what a stored XSS attacks.
Proving the request was intended: CSRF tokens
Cross-site request forgery makes a logged-in user’s browser submit a request the user never meant to send. Joomla’s defence
is a per-session token: the form contains a hidden field whose name is derived from the session, so a page on another
site cannot guess it. The Manual’s example attack forges a user.add request to the administrator.
Form and controller
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
?>
<form action="<?php echo Route::_('index.php?option=com_tallpine&task=report.submit'); ?>"
method="post" name="adminForm" id="trail-report-form" enctype="multipart/form-data">
<?php echo $this->form->renderFieldset('details'); ?>
<button type="submit" class="btn btn-primary"><?php echo Text::_('COM_TALLPINE_SUBMIT'); ?></button>
<input type="hidden" name="task" value="report.submit">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
Source: Joomla Manual: CSRF protection
HTMLHelper::_('form.token') outputs <input type="hidden" name="<token>" value="1">; the name is
Session::getFormToken(), an application hash of the user ID and the session token. Every form that changes state needs
it, including the toolbar-driven list forms of the administrator and any "delete" or "publish" link built as a GET
(prefer POST).
<?php
namespace Tallpine\Component\Tallpine\Site\Controller;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\FormController;
use Joomla\CMS\Session\Session;
\defined('_JEXEC') or die;
class ReportController extends FormController
{
public function submit(): bool
{
// 1. CSRF: first line of every task that changes data
$this->checkToken();
// 2. Permission: a valid token proves intent, not authority
if (!$this->app->getIdentity()->authorise('core.create', 'com_tallpine')) {
throw new \Joomla\CMS\Access\Exception\NotAllowed(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
// 3. Input with filters
$data = $this->input->post->get('jform', [], 'array');
// ... model->validate(), model->save() ...
$this->setRedirect(
\Joomla\CMS\Router\Route::_('index.php?option=com_tallpine&view=trails', false),
Text::_('COM_TALLPINE_REPORT_SAVED')
);
return true;
}
/**
* A task that is not a controller method of its own, e.g. in a plugin or a helper:
*/
public static function tokenOk(): bool
{
return Session::checkToken('post');
}
}
Source: Joomla Manual: CSRF protection
What the code in core does, so you know what you are relying on:
-
BaseController::checkToken($method = 'post', $redirect = true)callsSession::checkToken($method). On failure it queues the message The most recent request was denied because it had an invalid security token and redirects to the HTTP referrer only if that referrer is an internal URL (Uri::isInternal()), otherwise toindex.php. Pass$redirect = falseto get a boolean and decide yourself (an AJAX task). -
Session::checkToken($method)first compares the request headerX-CSRF-Tokenwith the expected token; if it matches, the check passes. Otherwise it looks for the token as a field name in the chosen request method (post,getorrequest). When the session is new (expired), it redirects to the front page with a "session expired" message instead of returningfalse. -
The token is bound to the session and the user, so it changes after login and logout. A page cached with a token inside it (page cache, a CDN, a full-page cache plugin) serves other visitors a stale token. Forms with tokens must be excluded from caching; see Caching and performance.
AJAX and fetch
For JavaScript requests, ask the document to expose the token and send it in the header that Session::checkToken()
recognises, so the body stays free of a token field.
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
// Adds the token to the script options under "csrf.token"
HTMLHelper::_('form.csrf');
$this->getDocument()->getWebAssetManager()->useScript('com_tallpine.condition-report');
// media/com_tallpine/js/condition-report.js
const token = Joomla.getOptions('csrf.token');
async function reportCondition(trailId, condition) {
const response = await fetch('index.php?option=com_tallpine&task=conditions.report&format=json', {
method: 'POST',
headers: {
'X-CSRF-Token': token,
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: trailId, condition }),
});
return response.json();
}
On the server side, a JSON task reads the token with $this->checkToken('post', false) and returns an error response on
failure. The Manual’s CSRF page does not cover AJAX; the behaviour above is read from the source.
What a token does not do
The Manual’s key sentence is "CSRF tokens are unrelated from permission checks". A valid token proves only that the form
was rendered for this session. It does not prove that the user is logged in, nor that they may do the action; it protects
nobody if the same task is also reachable by GET, and it protects nothing against XSS (a script on your own page can read
the token). The Web Services API authenticates by bearer token, not by session, and so does not use form tokens; it needs
the ACL checks in the next section even more.
Proving the user is allowed: ACL in every task
The most repeated finding in the 2026 core advisories is a missing or inconsistent permission check: webservice edit tasks, mutating webservice endpoints, category, custom field and access-level endpoints, workflow stage changes, batch copy, tagged items, the content history comparison view and schema.org contact data. Each was an endpoint that did the work the controller advertised for anyone who could reach it.
The rule that avoids this class of bug: every task and endpoint that reads protected data or changes anything calls
authorise() on the right asset before it does the work, even when the layout hides the button for unauthorised users.
Hiding a button is a courtesy, not a check.
<?php
namespace Tallpine\Component\Tallpine\Administrator\Controller;
use Joomla\CMS\Access\Exception\NotAllowed;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\FormController;
\defined('_JEXEC') or die;
class TrailController extends FormController
{
/**
* Who may add a trail: a global permission, or core.create in at least one category.
*/
protected function allowAdd($data = [])
{
$user = $this->app->getIdentity();
return $user->authorise('core.create', 'com_tallpine')
|| \count($user->getAuthorisedCategories('com_tallpine', 'core.create'));
}
/**
* Who may edit this particular trail: core.edit on the item, or core.edit.own on one's own item.
*/
protected function allowEdit($data = [], $key = 'id')
{
$recordId = (int) ($data[$key] ?? 0);
$user = $this->app->getIdentity();
if (!$recordId) {
return parent::allowEdit($data, $key);
}
$asset = 'com_tallpine.trail.' . $recordId;
if ($user->authorise('core.edit', $asset)) {
return true;
}
if ($user->authorise('core.edit.own', $asset)) {
$owner = (int) ($data['created_by'] ?? 0);
if ($owner === 0) {
$owner = (int) $this->getModel()->getItem($recordId)->created_by;
}
return $owner === (int) $user->id;
}
return false;
}
/**
* A custom task: publish a trail report. Token first, then the permission on the item.
*/
public function publishReport(): void
{
$this->checkToken();
$id = $this->input->post->getInt('id', 0);
if (!$this->app->getIdentity()->authorise('core.edit.state', 'com_tallpine.trail.' . $id)) {
throw new NotAllowed(Text::_('JLIB_APPLICATION_ERROR_EDITSTATE_NOT_PERMITTED'), 403);
}
// ... model->publish([$id], 1) ...
}
}
Source: Joomla Manual: ACL
The pitfalls are in the details:
-
Check against the item, not only the component.
core.editoncom_tallpineis not the same as oncom_tallpine.trail.7. A category-level rule that denies the edit must be honoured; checking only the component lets users edit what an administrator excluded. -
Check state-changing tasks that are not CRUD. Batch operations, ordering (
saveOrderAjax), checkin, feature toggles and "copy" actions were each the subject of an advisory. Each calls its ownauthorise()for the target too: copying an item into a category requirescore.createthere. -
Check the data returned, not only the action. A list that is filtered by the viewing access level must use the user’s authorised levels (
getAuthorisedViewLevels()) in the query, as shown in ACL in extensions; an output that joins a table (tags, contact data in schema.org) must apply the same filter as the table it joins. -
Do not trust IDs from the request. An ID in a POST is a claim. Load the item and check the loaded item’s category and owner.
-
Use
getIdentity(). The 6.x way to reach the current user is the application identity;Factory::getUser()is a legacy alias andJFactory::getUser()works only with BC6. -
A global Super User bypasses everything by design. Test with an ordinary account (a Trail Warden group member and a Registered user), not as the Super User, or the permission code is never exercised.
Web Services endpoints
An API controller extends Joomla\CMS\MVC\Controller\ApiController, which already calls allowEdit(), allowAdd() and the
delete check before the corresponding action, and those use core.manage first and then core.edit, core.create,
core.delete on the component or item. If you override a method or add a custom route, you take on responsibility for
repeating the check:
<?php
namespace Tallpine\Component\Tallpine\Api\Controller;
use Joomla\CMS\MVC\Controller\ApiController;
\defined('_JEXEC') or die;
class TrailsController extends ApiController
{
protected $contentType = 'trails';
protected $default_view = 'trails';
/**
* Custom endpoint: PATCH /api/index.php/v1/tallpine/trails/:id/close
*/
public function close(): static
{
$id = $this->input->getInt('id', 0);
// The base class calls this for edit(); a custom method must call it itself.
if (!$this->allowEdit(['id' => $id], 'id')) {
throw new \Joomla\CMS\Access\Exception\NotAllowed('You are not allowed to close this trail', 403);
}
// ... model->close($id) ...
return $this;
}
}
The mechanics of routes, tokens and plugins are in Extending the Web Services API and The Web Services API. On fresh 6.2 installs the API Token authentication plugin is enabled and the Basic Authentication API plugin is disabled; keep it that way unless a client really requires Basic authentication.
Talking to the database
Never build SQL by concatenating request data. The Manual calls prepared statements the gold standard: the query and the values travel to the server separately, so a value cannot change the query’s structure.
<?php
namespace Tallpine\Component\Tallpine\Site\Model;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\ParameterType;
\defined('_JEXEC') or die;
class TrailsModel extends ListModel
{
protected function getListQuery()
{
$db = $this->getDatabase();
$query = $db->createQuery()
->select($db->quoteName(['a.id', 'a.title', 'a.distance_km', 'a.difficulty']))
->from($db->quoteName('#__tallpine_trails', 'a'))
->where($db->quoteName('a.published') . ' = 1');
// Values: bind() with an explicit type
$difficulty = $this->getState('filter.difficulty');
if ($difficulty !== '' && $difficulty !== null) {
$query->where($db->quoteName('a.difficulty') . ' = :difficulty')
->bind(':difficulty', $difficulty);
}
// Lists: whereIn() with an explicit type
$levels = $this->getCurrentUser()->getAuthorisedViewLevels();
$query->whereIn($db->quoteName('a.access'), $levels, ParameterType::INTEGER);
// Search: put the wildcards into the bound value, and escape the user's own % and _
$search = $this->getState('filter.search');
if ($search) {
$like = '%' . $db->escape($search, true) . '%';
$query->where($db->quoteName('a.title') . ' LIKE :search')
->bind(':search', $like);
}
// Identifiers: never from the request. Whitelist the ordering
$allowed = ['a.title', 'a.distance_km', 'a.id'];
$orderCol = \in_array($this->getState('list.ordering'), $allowed, true)
? $this->getState('list.ordering') : 'a.title';
$orderDir = strtoupper((string) $this->getState('list.direction')) === 'DESC' ? 'DESC' : 'ASC';
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
return $query;
}
}
Source: Joomla Manual: Secure DB queries
Key points (the full treatment, including transactions and the MySQL/PostgreSQL differences, is in The database API):
-
bind()replaces the oldquote()calls for values;quoteName()is for identifiers (table and column names), never for values. -
bind()takes the value by reference. Binding a loop variable and then changing it affects every placeholder bound to it; usebindArray()orwhereIn()for lists. -
Identifiers cannot be bound. Sort columns, table names and field names from a request go through a whitelist, as the sample does (
filter_fieldsin aListModelis the same mechanism). -
The query builder does not make raw install SQL portable; the MySQL and PostgreSQL install scripts remain separate files.
-
Get the database object from
getDatabase()(theDatabaseAwareTrait) or from the container.Factory::getDbo()is a deprecated legacy call.
File uploads
An upload is attacker-controlled bytes, name, size and claimed type arriving in your PHP process. The Manual lists the risks: executable file types, an attacker choosing the storage path, and large uploads filling the disk. The Tallpine "gpx-file" field accepts a GPS track from a member; the sample below shows the checks the core helpers support.
<?php
namespace Tallpine\Component\Tallpine\Site\Model;
use Joomla\CMS\Filter\InputFilter;
use Joomla\CMS\Helper\MediaHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Path;
\defined('_JEXEC') or die;
class UploadModel extends BaseDatabaseModel
{
/**
* Stores a GPX file below images/tallpine and returns the relative path.
*
* @param array $file One entry of $input->files->get('jform', [], 'array')
*/
public function storeGpx(array $file, int $trailId): string
{
// 1. Reject executable extensions, null bytes and PHP tags anywhere in the file
if (!InputFilter::isSafeFile($file)) {
throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_UNSAFE'));
}
// 2. Extension allow-list, size limit and MIME check from the component options
$helper = new MediaHelper();
if (!$helper->canUpload($file, 'com_tallpine', ['gpx'])) {
throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_REJECTED'));
}
// 3. The server chooses the name: never use the client's path or full name
$name = 'trail-' . $trailId . '-' . bin2hex(random_bytes(6)) . '.gpx';
$folder = Path::clean(JPATH_ROOT . '/images/tallpine');
$target = $folder . '/' . $name;
// 4. Stay inside the folder
if (strpos(Path::clean($target), $folder) !== 0) {
throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_PATH'));
}
if (!File::upload($file['tmp_name'], $target)) {
throw new \RuntimeException(Text::_('COM_TALLPINE_UPLOAD_FAILED'));
}
return 'images/tallpine/' . $name;
}
}
Source: Joomla Manual: Common vulnerabilities (Unrestricted file uploads, MediaHelper::canUpload)
What the helpers check, read from the 6.2.1 source:
-
InputFilter::isSafeFile($file)scans an uploaded file descriptor (including nested ones from array-style form names) for a null byte in the name, a forbidden extension anywhere in the name (soshell.php.jpgandshell.jpg.phpare both caught), a<?phptag, short tags in the extensions where they matter, the__HALT_COMPILER()PHAR stub, and.phpin the content of archive types. The forbidden list (FORBIDDEN_FILE_EXTENSIONS) includesphpand its numbered variants,phar,phtml,asp,cgi,py,pland the server-side-include extensionssht,shtm,shtmlandstm. -
MediaHelper::canUpload($file, $component, $allowedExecutables)requires a safe name (File::makeSafe()must not change it), at least one extension, no executable extension buried anywhere in the name (the built-in list also containsjs,html,htm,exeand scripting-host types), an extension in the component’s allow-list, the maximum size, and a MIME type that matches. The defaults read from the component options arerestrict_uploads(on),restrict_uploads_extensions(images, audio and video types,pdf, office types,txt,csv),upload_maxsizeandignore_extensions. The third argument lets an extension permit one otherwise-blocked type deliberately (as thegpxexample does for a type not in the default allow-list; addgpxto the component’s allowed extensions too). -
Allow-list, never block-list. A 2026 advisory (fixed in 5.4.8 and 6.1.3) was about
.shtmlfiles being accepted because they were missing from the extension block-list: the server could execute server-side includes in them. The allow-list makes that class of omission harmless.
Beyond the helpers:
-
Never trust the client’s MIME type or file name.
$file['type']is whatever the browser sent. Generate the stored name yourself. -
Keep uploaded files that are not meant to be public outside the web root, or in a folder where the web server is configured to serve static files only and to execute no scripts (see Web server, reverse proxy and HTTPS), and serve them through a controller that checks ACL.
-
SVG is code. An SVG can contain
<script>and event attributes. Core’sMediaHelperruns uploaded SVG files through an SVG sanitiser (enshrined\svgSanitize); an extension that accepts SVG must do the same or refuse the type. -
Limit sizes in three places: the PHP
upload_max_filesizeandpost_max_size, the web server, and the component option. An emptytmp_nameafter a large upload means PHP discarded it. -
Use the Media Manager for editors. Fields of type
mediastore a path to a file the Media Manager already vetted; see Media Manager. -
Joomla\CMS\Filesystem\*is gone from the default install. File operations useJoomla\Filesystem\File,FolderandPathfrom the framework package; the old CMS wrappers are available only through BC6 and are slated for removal in 7.0.
Outbound requests and SSRF
Server-side request forgery turns your server into the attacker’s browser inside your network. It appears whenever an extension fetches a URL that a user, a form field, a feed, a database row or a remote document can influence: a feed reader, a "fetch metadata from this link" button, an image-by-URL importer, a webhook. The 2026 SSRF advisory, rated High and fixed in 5.4.9 and 6.1.4, covered "various core extensions" for exactly this reason: several places that fetched a user-supplied URL did not restrict where it could point.
The Tallpine task plugin plg_task_tallpine_trailsync fetches trail closures from a fictitious park-authority API, whose
URL is a plugin parameter that an administrator can change. The sample treats that URL as untrusted anyway.
<?php
namespace Tallpine\Plugin\Task\TallpineTrailsync\Extension;
use Joomla\Http\HttpFactory;
\defined('_JEXEC') or die;
final class RemoteFetcher
{
/** Hosts the club agreed to trust. Everything else is refused. */
private const ALLOWED_HOSTS = ['api.parks.example'];
public function fetchJson(string $url): array
{
$parts = parse_url($url);
// Scheme and host allow-list: https only, a known host, no credentials in the URL
if (
!\is_array($parts)
|| ($parts['scheme'] ?? '') !== 'https'
|| !\in_array($parts['host'] ?? '', self::ALLOWED_HOSTS, true)
|| isset($parts['user'])
) {
throw new \InvalidArgumentException('URL is not allowed');
}
// Resolve and reject private, loopback and link-local addresses (DNS may point inward)
foreach (gethostbynamel($parts['host']) ?: [] as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
throw new \InvalidArgumentException('Address is not public');
}
}
// Short timeout, and no automatic redirects to somewhere else
$http = (new HttpFactory())->getHttp(['timeout' => 10, 'follow_location' => false]);
$response = $http->get($url, ['Accept' => 'application/json']);
if ($response->getStatusCode() !== 200) {
throw new \RuntimeException('Unexpected status ' . $response->getStatusCode());
}
$data = json_decode((string) $response->getBody(), true, 8, JSON_THROW_ON_ERROR);
return \is_array($data) ? $data : [];
}
}
Notes:
-
Prefer an allow-list to a deny-list of addresses. The resolved-address check above is a second layer; a DNS answer can change between the check and the request ("DNS rebinding"), so for high-risk features pin the resolved address, or fetch through an egress proxy that forbids internal ranges.
-
Do not follow redirects blindly. A redirect from an allowed host to
http://169.254.169.254/(a cloud metadata service) is the classic bypass. The transport option name for this is read from thejoomla/httppackage; re-check it against the transport in use, and re-run the host check on any redirect target you decide to follow. -
Set a timeout and a size limit. A slow response keeps a PHP worker; an unbounded body fills memory.
-
Use the framework class.
\Joomla\CMS\Http\HttpFactoryis deprecated since 6.0 for removal in 8.0; use\Joomla\Http\HttpFactory, which core’s updater also uses. -
Never return fetched error bodies or headers to the browser. They disclose internal services.
Redirects
An "open redirect" lets an attacker put a link on your domain that sends the visitor elsewhere, which makes phishing look
legitimate. If a redirect target comes from the request (a return parameter after login or after saving), validate it:
<?php
use Joomla\CMS\Uri\Uri;
// $return is a base64url value produced by the form, as com_users does
$return = base64_decode($this->input->post->getBase64('return', ''), true) ?: '';
if (!$return || !Uri::isInternal($return)) {
$return = 'index.php?option=com_tallpine&view=trails';
}
$this->setRedirect($return);
Uri::isInternal() rejects URLs containing quotes, spaces or angle brackets, converts backslashes to slashes, and accepts
only URLs that start with index.php or that point at the site’s own base. The BaseController::checkToken() failure path
uses the same function before redirecting to the referrer.
Assets, the /media folder and Content Security Policy
JavaScript and CSS of an extension belong in media/com_tallpine/ (installed by the <media> element of the manifest),
registered in joomla.asset.json and loaded through the Web Asset Manager. This is a security rule as much as a
performance rule:
-
No inline scripts or styles. Inline
<script>blocks andstyle="..."attributes defeat a Content Security Policy that forbidsunsafe-inline. Files under/mediacan be allowed by source ('self'); data goes in as script options. -
The nonce is added for you. When the HTTP Headers system plugin has a CSP with a nonce enabled, scripts that go through the document renderers receive a
nonceattribute (ScriptsRendereradds it whenever the document has one). An extension that echoes its own<script>tag does not get one and will be blocked. -
Do not load third-party assets from CDNs by default. Each external origin must be listed in the policy, and a compromised CDN can run code on every page. Ship the file in
/mediaor document the origin. -
Do not put code or secrets in
/media. Everything in it is public by design. The folder belongs to static files; PHP in it would be served as source or executed depending on the server. -
Version the files with the asset’s
versionso a security fix to your JavaScript reaches browsers ("version": "auto"uses a hash; 6.1 added"version": "nocache").
The administrator side of the policy (Report-Only mode, directives, nonce) is covered in Security hardening, MFA and passkeys, and the asset registries in The Web Asset Manager.
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "com_tallpine",
"version": "1.4.0",
"assets": [
{
"name": "com_tallpine.trail-map",
"type": "script",
"uri": "com_tallpine/trail-map.min.js",
"attributes": { "defer": true },
"dependencies": ["core"]
},
{
"name": "com_tallpine.condition-report",
"type": "script",
"uri": "com_tallpine/condition-report.min.js",
"attributes": { "defer": true },
"dependencies": ["core"]
}
]
}
Source: Joomla Manual: Web Asset Manager
Secrets, passwords and cryptography
-
Do not write your own cryptography. The Manual recommends established libraries such as PHP’s libsodium functions. For passwords, never store or compare them yourself: use
UserHelper(hashPassword()andverifyPassword()) orpassword_hash(). See PHP: cryptography, hashing and passwords. -
Random values. Use
random_bytes()for tokens and file names; neverrand(),mt_rand(),uniqid()ortime(). -
Credentials for remote services (the park-authority API key, an SMTP password) go in a plugin or component parameter declared as a
passwordfield, or better in the environment and read at runtime. Never hard-code them, never write them intoconfiguration.phpsnippets in documentation, never log them. -
configuration.phpis code. Itssecret, database password and mail credentials never go into a repository. Extension code reads values through$app->get('...'), never from the file. -
Do not log personal data or tokens in
error.phpand the action logs; see Privacy and action logs. -
Error messages. A raw exception message can reveal paths, SQL or hostnames. Show a language string to the visitor and log the details; see Debugging.
Installer scripts and packaging
-
The package is part of the attack surface.
script.phpand the SQL run with the privileges of the installing Super User. Keep installer logic minimal, do not download and execute remote code, and do not leave diagnostic or backup files (.sql,.zip,.bak,.env,.git) in the zip. See Installer scripts and SQL updates. -
Update servers over HTTPS only, and use the download-key mechanism for paid extensions rather than public URLs. Core updates are TUF-verified since 5.1; extension updates carry a
securityseverity flag from 6.2, which lets the administrator see which updates fix vulnerabilities. See Update servers and the JED. -
Plugin groups. Never put your own plugins in the
behaviourgroup, which core uses for compatibility plugins and which the 6.x upgrade path switches on and off. -
Plugin classes get the application and database from the Aware traits (
getApplication(),getDatabase()). Joomla 7.0 removes the auto-detection of$appand$dbproperties.
The JED Checker
The Joomla Extensions Directory (JED) reviews extensions against published standards, and the JED Checker inspects an extension zip for compliance with those standards before you submit it; it is useful even for extensions that are never listed. The JED standards concern the same ground as this page (guards against direct file access, safe handling of input and output, request tokens, and a clean package), so the checklist at the end of this page is a good pre-flight for it. Run it in the CI job that builds the zip, as described in Build and CI for extensions.
Two companion lists are worth knowing: the Vulnerable Extensions List (VEL) kept by the Joomla project records extensions with known flaws, and the Security Centre publishes core advisories. Subscribe to the feed, and treat "compromised" as a case for patching, not a reason for a rewrite.
Lessons from recent core advisories
The Developer Network’s Security Centre publishes every core advisory with the affected and fixed versions. The 2026 advisories between August and September (fixed in 5.4.8/6.1.3 and 5.4.9/6.1.4) fall into a handful of patterns. They are useful here because they are bugs in code written by experienced developers, and the patterns match the checklist above.
| Pattern | Advisories (titles as published) | What the extension author should do |
|---|---|---|
SSRF |
"SSRF vectors in various core extensions" (High) |
Allow-list outbound hosts and schemes, check resolved addresses, control redirects, set timeouts. See the SSRF section. |
ACL gaps in web services |
"Improper ACL checks for various webservice edit tasks", "for access level webservice endpoints", "for category webservice endpoints", "for custom fields webservice endpoints", "Inconsistent ACL checks for mutating webservice endpoints" |
Call |
ACL gaps in other mutation paths |
"Improper ACL checks for workflow stage changes", "for batch copy actions", "Unauthorized user creation via profile.save controller" |
Check each custom task, batch operation and shortcut controller on the item and the target. Do not assume that the form restricts what the controller accepts. |
ACL gaps in reads |
"Improper ACL checks in outputs for tagged items", "in content history comparison view", "when injecting schema.org contact data" |
Apply the viewing access level to every query and every joined table that reaches the output. |
XSS filter bypasses |
"InputFilter XSS bypass via whitespace in HTML data URIs", "XSS filter bypass in InputFilter via HTML5 entity decode mismatch" |
Treat filtering as a first layer. Escape on output. Keep Joomla updated: the fix is in core, not in your code. |
Missing output escaping |
"XSS in module list", "XSS in link toolbar layout", "XSS in generic media output layouts", "XSS in HTMLHelper::link method", "XSS through schema.org outputs", "XSS in HTML mail templates" |
|
Unsafe files |
"Unrestricted uploads of SHTML files", "Arbitrary directory deletion via cache purge action" |
Use allow-lists for file types, build paths from validated names, and keep deletions inside a known folder ( |
Authentication state |
"MFA authentication bypass", "MFA bypass via rememberme cookies", "Improper CORS origin validation" |
Do not roll your own login shortcuts; use the authentication plugins and, for APIs, validate the |
Three habits stand out. Treat every endpoint as public until proven otherwise; the web-services ACL gaps arose because the same action could be reached through a route the human interface did not use. Test the negative: write a test that logs in as a user without the right and expects a 403 (see Testing extensions). Update promptly: every one of these fixes reached sites only through a core update, which is why automated core updates and a documented patch window are part of the security design of a site, not a convenience.
A review checklist for com_tallpine
| Area | Check |
|---|---|
Entry |
Every PHP file starts with |
Input |
Each read has a filter or goes through a form. No |
Output |
|
CSRF |
|
ACL |
|
Database |
|
Files |
|
Network |
Allow-listed hosts, public-address check, timeouts, no blind redirects, |
Assets |
Files in |
Secrets |
No keys, passwords or tokens in the repository; parameters or environment only. |
Package |
No stray files in the zip, HTTPS update server, JED Checker clean, tests and static analysis in CI. |
Common errors and pitfalls
| Symptom or mistake | Cause and fix |
|---|---|
"The most recent request was denied because it had an invalid security token" |
The form lacks |
Blank page after upgrading to 6.0 with no error |
The file starts with |
|
The type hint or |
|
|
Users can edit items they should not |
Only the component asset was checked. Check the item asset and, for |
Search with |
User wildcards were not escaped: use |
Uploaded |
Block-list instead of allow-list, or the upload folder executes scripts. Use |
|
Legacy calls removed from the default install. Use |
A script is blocked by the CSP |
It is inline or loaded from an origin not in the policy. Move it into |
An API client can change data without a permission |
A custom route skips |
A |
The target was not checked with |
Version notes
-
Joomla 6.0 removed
JPATH_PLATFORMfrom the default install (BC6 only), the CMSInputclass, and the defaultJoomla\CMS\Filesystem; deprecated\Joomla\CMS\Http\HttpFactory; and changedAdminModel::getItem()to return astdClass, so code that calls$item->get()on it fails. -
Joomla 5.0 introduced concrete event classes for plugins, which replace generic
Event::getArgument()arrays. -
Joomla 5.1 added TUF verification of core updates; 5.4 and 6.0 added Automated Core Updates; 6.1 added the Proof-of-Work captcha plugin; 6.2 added a security-severity flag to extension updates.
-
Joomla 4.2 moved Multi-Factor Authentication into core and replaced the old two-factor authentication.
-
Joomla 4.0 switched
escape()toENT_QUOTES. -
Joomla 7.0 removes the automatic
$appand$dbproperties of plugins.
Where older books and tutorials are outdated
-
Developing Extensions for Joomla! 5 (Cámara Mora, 2023) has a security checklist and the JED Checker that remain useful. Where it shows a non-namespaced
CMSPlugin, a$dbplugin property, genericEvent::getArgument()arrays,Factory::getLanguage()orget('DatabaseDriver'), the 6.x equivalents are aSubscriberInterfaceplugin with concrete event classes, the Aware traits and the container. It targets Joomla 5.0. -
Joomla! 4 Masterclass (Marzo, 2023) targets Joomla 4, which is end of life since October 2025; check any security advice about the administrator against the 6.x defaults.
-
Wiki tutorials from the 2.5 and 3.x era use
JRequest::getVar(),JFactory::getDbo(),$db->quote()andJSession::checkToken().JRequestwas removed long ago, theJ*names work only through BC6, and the wiki pages mark themselves obsolete. The replacements areInputwith a filter,getDatabase()withbind(), andSession::checkToken().
See Where older books and tutorials are outdated for the full list.