| name | joomla6-security |
| description | Core Joomla 6 (6.0-era) security skill. Covers secure coding practices for components, modules, plugins, and templates: XSS prevention via output escaping, SQL injection prevention via prepared statements with ParameterType binding, CSRF token validation, input filtering with the Input class, secure file uploads, and path traversal prevention. Targets native Joomla 6 patterns only — no Joomla 3/4 J* classes, no FOF, no jQuery. |
Joomla 6 Security Skill
Comprehensive reference for secure Joomla 6 extension development covering XSS prevention, SQL injection prevention, CSRF protection, input filtering, secure file uploads, and path safety.
When to Use This Skill
Trigger this skill when:
- Preventing Cross-Site Scripting (XSS) — escaping output in views and other contexts
- Writing database queries — must use prepared statements with
bind() and ParameterType
- Implementing CSRF token protection in forms and state-changing handlers
- Handling and validating user input via
Joomla\CMS\Input\Input
- Implementing secure file uploads
- Validating file paths and preventing path traversal
- Reviewing code for security vulnerabilities
- Building or updating Joomla 6 components, modules, plugins, templates, or libraries
- Migrating extensions from Joomla 3/4/5 (or FOF) to native Joomla 6
Hard Rules
Every piece of code produced under this skill must:
- Escape every output rendered to HTML —
$this->escape() in views, htmlspecialchars($x, ENT_QUOTES, 'UTF-8') elsewhere
- Use prepared statements with
bind() and ParameterType::* for every query value
- Validate the CSRF token on every state-changing handler (POST/PUT/PATCH/DELETE)
- Filter every input through the appropriate
Input method (getInt, getCmd, getString, etc.) — never read $_GET / $_POST / $_REQUEST directly
- Enforce ACL with
$user->authorise() separately from CSRF — they are not interchangeable
- Use
password_hash() / password_verify() for passwords; libsodium for symmetric crypto where Joomla doesn't provide a primitive
- Use PHP 8.3/8.4 with
declare(strict_types=1);
- Use
\defined('_JEXEC') or die; (with leading backslash inside namespaces)
Every piece of code must never:
- Concatenate user input into SQL — even after
(int) casting, this is a code-smell that bypasses the audit trail of prepared statements
- Echo a model field, request value, or DB column without escaping
- Trust client-side validation alone — every check must repeat server-side
- Trust file
Content-Type headers, original file names, or file extensions in isolation
- Use
eval(), assert() with strings, or unserialize() on untrusted input
- Use
Factory::getUser() / Factory::getUser($id) — removed in Joomla 6
- Use
Joomla\CMS\Filesystem\* — moved to Joomla\Filesystem\* in Joomla 6
- Use any
J*-prefixed Joomla 3 class, FOF code, or jQuery
- Modify any Joomla core file:
/libraries/, /api/, /includes/, /cli/, /installation/, core components (com_content, com_users, com_config, com_modules, com_plugins, com_templates, com_categories, com_menus, com_media, com_installer, com_admin, com_login, com_cpanel), core plugins, core modules, or core templates (cassiopeia, atum)
Extend Joomla via plugins, event subscribers, service overrides, template overrides (in your own template), and your own component/module/plugin/library.
Security Fundamentals — The Five Golden Rules
- All user input is hostile — never trust POST, GET, cookies, headers, file names, referers, or any client-controlled value.
- Client-side checks are not security — every validation must repeat server-side.
- Don't roll your own crypto — use
password_hash() / password_verify() for passwords, libsodium for symmetric/asymmetric crypto, Joomla's session and token APIs for sessions.
- No security through obscurity — be transparent about issues once patched.
- Reduce your attack surface — use Joomla core classes (
Input, Session, MediaHelper, Path) instead of bespoke implementations.
Quick Reference
XSS Prevention — Escaping Output
In component views (preferred):
<?php echo $this->escape($item->title); ?>
In modules, plugins, helpers, or anywhere outside an HtmlView:
<?php echo htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); ?>
For HTML attributes (single quotes):
<input type="text" value="<?php echo $this->escape($item->title); ?>">
For inline JavaScript values — encode as JSON, never string-interpolate:
<script>
const config = <?php echo json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;
</script>
Better: pass to JS via $document->addScriptOptions('com_example.config', $config) and read with Joomla.getOptions('com_example.config').
❌ VULNERABLE:
<h3><?php echo $comment->subject; ?></h3>
<?php echo $comment->text; ?>
✅ SAFE:
<h3><?php echo $this->escape($comment->subject); ?></h3>
<?php echo $this->escape($comment->text); ?>
SQL Injection Prevention — Prepared Statements
❌ VULNERABLE:
$query->update('#__users')
->set('password = "' . $newPassword . '"')
->where('username = "' . $username . '"');
✅ SAFE — prepared statement with explicit ParameterType:
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true);
$newPassword = password_hash($inputPassword, PASSWORD_DEFAULT);
$query->update($db->quoteName('#__users'))
->set($db->quoteName('password') . ' = :password')
->where($db->quoteName('username') . ' = :username')
->bind(':password', $newPassword, ParameterType::STRING)
->bind(':username', $username, ParameterType::STRING);
$db->setQuery($query);
$db->execute();
SELECT with multiple bindings:
use Joomla\Database\ParameterType;
$query = $db->getQuery(true);
$query->select($db->quoteName(['id', 'title', 'alias']))
->from($db->quoteName('#__content'))
->where($db->quoteName('catid') . ' = :catid')
->where($db->quoteName('state') . ' = :state')
->bind(':catid', $categoryId, ParameterType::INTEGER)
->bind(':state', 1, ParameterType::INTEGER);
$db->setQuery($query);
$results = $db->loadObjectList();
IN () clauses — bind each value separately:
$ids = array_map('intval', $rawIds);
$placeholders = [];
foreach ($ids as $i => $value) {
$placeholder = ':id' . $i;
$placeholders[] = $placeholder;
$query->bind($placeholder, $ids[$i], ParameterType::INTEGER);
}
$query->where($db->quoteName('id') . ' IN (' . implode(',', $placeholders) . ')');
Identifier names (table/column) cannot be bound as parameters — they must be wrapped in $db->quoteName(...) and validated against an allow-list. Never accept a column name from user input without checking it against a whitelist of valid columns.
$db->quote() is acceptable only for static/known-safe literals. Default to prepared statements.
CSRF Protection — Form Tokens
Add the token to every form:
use Joomla\CMS\HTML\HTMLHelper;
?>
<form action="<?php echo Route::_('index.php'); ?>" method="post">
<input type="text" name="title" />
<input type="hidden" name="option" value="com_example" />
<input type="hidden" name="task" value="item.save" />
<?php echo HTMLHelper::_('form.token'); ?>
<button type="submit">Save</button>
</form>
Validate in controllers (state-changing actions only):
declare(strict_types=1);
namespace Vendor\Component\Example\Administrator\Controller;
\defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
final class ItemController extends FormController
{
public function save($key = null, $urlVar = null): bool
{
$this->checkToken();
return parent::save($key, $urlVar);
}
}
Validate outside controllers:
use Joomla\CMS\Language\Text;
use Joomla\CMS\Session\Session;
if (!Session::checkToken()) {
throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
}
if (!Session::checkToken('post')) {
}
CSRF tokens prove the request originated from your site. They do not prove the user is authorized. Always check $user->authorise('core.edit', 'com_example.item.' . $id) separately.
Input Handling — Filtering with Input
use Joomla\CMS\Factory;
$app = Factory::getApplication();
$input = $app->getInput();
$id = $input->getInt('id', 0);
$pageNo = $input->getUint('page', 1);
$qty = $input->getFloat('qty', 0.0);
$name = $input->getString('name', '');
$task = $input->getCmd('task', '');
$verb = $input->getWord('action', '');
$path = $input->getPath('file', '');
$email = $input->get('email', '', 'EMAIL');
$html = $input->get('content', '', 'HTML');
$ids = $input->get('ids', [], 'ARRAY');
$raw = $input->get('payload', '', 'RAW');
Available filters:
| Filter | Method shortcut | Description |
|---|
INT | getInt() | Signed integer |
UINT | getUint() | Unsigned integer |
FLOAT | getFloat() | Float / decimal |
BOOL | getBool() | Boolean |
STRING | getString() | Basic string (strips HTML) |
ALNUM | — | Alphanumeric only |
CMD | getCmd() | Alphanumeric + . + _ + - |
WORD | getWord() | Alphabetic only |
PATH | getPath() | Safe file path token |
EMAIL | — | Email address |
HTML | — | Sanitized HTML (allowed tags only) |
BASE64 | — | Base64-safe characters |
ARRAY | — | Array (values still need filtering) |
RAW | — | No filtering — use only after manual validation |
When using ARRAY, filter each element individually — array only ensures the value is an array, not that its members are safe.
Secure File Uploads
use Joomla\CMS\Factory;
use Joomla\CMS\Helper\MediaHelper;
use Joomla\CMS\Language\Text;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Path;
$app = Factory::getApplication();
$file = $app->getInput()->files->get('upload');
if (!is_array($file) || empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) {
throw new \RuntimeException(Text::_('COM_EXAMPLE_UPLOAD_FAILED'), 400);
}
if (!MediaHelper::canUpload($file)) {
throw new \RuntimeException(Text::_('COM_EXAMPLE_UPLOAD_NOT_ALLOWED'), 403);
}
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'];
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
throw new \RuntimeException(Text::_('COM_EXAMPLE_FILE_TYPE_NOT_ALLOWED'), 415);
}
$maxSize = 2 * 1024 * 1024;
if ($file['size'] > $maxSize) {
throw new \RuntimeException(Text::_('COM_EXAMPLE_FILE_TOO_LARGE'), 413);
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file['tmp_name']);
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
if (!in_array($realMime, $allowedMimes, true)) {
throw new \RuntimeException(Text::_('COM_EXAMPLE_FILE_MIME_NOT_ALLOWED'), 415);
}
$safeFilename = bin2hex(random_bytes(16)) . '.' . $extension;
$uploadDir = JPATH_ROOT . '/media/com_example/uploads';
$destination = Path::clean($uploadDir . '/' . $safeFilename);
File::upload($file['tmp_name'], $destination, false, true);
File upload security checklist:
Path Traversal Prevention
use Joomla\CMS\Language\Text;
use Joomla\Filesystem\Path;
$relative = $input->getPath('file', '');
$absolute = Path::clean(JPATH_ROOT . '/media/com_example/' . $relative);
$base = Path::clean(JPATH_ROOT . '/media/com_example');
if (!str_starts_with($absolute, $base . DIRECTORY_SEPARATOR)) {
throw new \RuntimeException(Text::_('COM_EXAMPLE_INVALID_PATH'), 400);
}
Authorization (ACL) — Always Separate from CSRF
use Joomla\CMS\Factory;
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.edit', 'com_example.item.' . (int) $id)) {
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
Current User (CRITICAL — Factory::getUser() removed in Joomla 6)
$user = Factory::getUser();
$user = Factory::getUser($userId);
$user = $this->getCurrentUser();
$user = Factory::getApplication()->getIdentity();
use Joomla\CMS\User\UserFactoryInterface;
$user = Factory::getContainer()
->get(UserFactoryInterface::class)
->loadUserById((int) $userId);
Filesystem (moved out of Joomla\CMS in Joomla 6)
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Filesystem\Path;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\Filesystem\Path;
Passwords
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
if (!password_verify($plainPassword, $hash)) {
throw new \RuntimeException(Text::_('JLIB_LOGIN_AUTHENTICATE'), 401);
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
}
Never store plaintext passwords. Never email plaintext passwords. Never log them.
Common Vulnerabilities — Prevention Map
| Vulnerability | Prevention |
|---|
| XSS | $this->escape() in views; htmlspecialchars($v, ENT_QUOTES, 'UTF-8') elsewhere; JSON-encode for inline JS |
| SQL Injection | Prepared statements with bind() and ParameterType::* |
| CSRF | HTMLHelper::_('form.token') in forms + $this->checkToken() in controllers |
| Authorization Bypass | $user->authorise('core.action', 'com_example.item.' . $id) — separately from CSRF |
| File Upload | MediaHelper::canUpload() + extension/MIME allow-list + random filename + Path::clean() |
| Path Traversal | Input::getPath() + Path::clean() + base-prefix check |
| Open Redirect | Validate redirect targets against an internal allow-list; use Route::_() for in-site URLs |
| Session Hijacking | Use Joomla's session APIs; Session::checkToken(); HTTPS-only cookies (Joomla config) |
| Insecure Deserialization | Never unserialize() untrusted input — use json_decode() with strict typing |
| Mass Assignment | Restrict bindable fields on Table subclasses via $table->bind($data, $ignore) |
| Information Disclosure | Generic error messages to users; detailed errors only via \Joomla\CMS\Log\Log |
Reference Files
This skill includes detailed documentation in references/:
fundamentals.md — five golden rules of secure development
vulnerabilities.md — XSS, SQLi, file uploads, CSRF overview
database.md — prepared statements and query escaping
csrf.md — CSRF token implementation details
input.md — input filtering reference
forms.md — form validation reference
uploads.md — secure file upload patterns
acl.md — ACL actions, asset records, view levels
Use view to read specific reference files when detailed information is needed.
Best Practices
- Always escape output —
$this->escape() in views.
- Always use prepared statements — never concatenate input into SQL.
- Always validate the CSRF token —
$this->checkToken() in every state-changing handler.
- Always check ACL separately —
$user->authorise(...). CSRF is not authorization.
- Always filter input — never read
$_GET/$_POST/$_REQUEST directly.
- Always validate file uploads —
MediaHelper::canUpload() plus MIME-sniff plus extension allow-list.
- Always use
Path::clean() and base-prefix checks for any user-supplied path fragment.
- Always use
password_hash() / password_verify() — never custom hashing.
- Use Joomla core classes — don't reinvent security primitives.
- Keep Joomla and PHP updated — security patches ship regularly.
- Never modify Joomla core files — extend via plugins, events, services, and overrides.
Related Skills
joomla6-extensions — extension structure, MVC, manifests, service providers
joomla6-general-concepts — DI, ACL, DB, forms, routing, web assets
joomla6-code-reviewer — audits code against these patterns
joomla6-component-architect — component architecture and migration blueprints
Resources