Security
Shared Responsibility
Governments and Fortune 500 companies depend on Concrete, so the robust security of Concrete CMS is always a top priority. While the transparency of open-source code accelerates contributions to security enhancements, it also makes the methodology accessible to potential adversaries. As a result, the Concrete core team adopts an unwavering commitment to address security issues with utmost urgency.
However, this rigorous security foundation is just the beginning. As a developer utilizing Concrete for web applications, you play a pivotal role in maintaining this security paradigm. The core of Concrete is secure, but it's essential to ensure the code you introduce upholds these standards. Thankfully, Concrete provides a suite of helper libraries and functions to aid in crafting secure code, but the onus is on you to leverage them effectively.
Preventing Cross-Site Request Forgery with Tokens
Cross-Site Request Forgery (CSRF) allows a malicious actor to trick users into unknowingly submitting a form, potentially leading to unauthorized actions. As an example, a hacker could exploit a form on a Concrete CMS site to change a user's password.
To illustrate, consider a simple form:
<form method="post" action="<?=$view->action('change_password')?>
<input type="password" name="new_password" />
<button type="submit">Change Password</button>
</form>
With the corresponding controller:
public function change_password()
{
$u = new User();
if ($u->isRegistered()) {
$ui = $u->getUserInfoObject();
$ui->changPassword($this->request->request->get('new_password'));
}
}
This setup is vulnerable to CSRF. A hacker could use an iframe to send a POST request, changing the password if the user is logged in.
To protect against this, we can use a token:
<form method="post" action="<?=$view->action('change_password')?>
<?=Core::make('token')->generate('perform_change_password');?>
<input type="password" name="new_password" />
<button type="submit">Change Password</button>
</form>
In the controller, we verify the token:
public function change_password()
{
$u = new User();
$token = \Core::make("token");
if ($u->isRegistered() && $token->validate('perform_change_password')) {
$ui = $u->getUserInfoObject();
$ui->changPassword($this->request->request->get('new_password'));
}
}
For Dashboard pages in Concrete, you don’t need to fetch the token with Core::make()
. It’s already available:
<form method="post" action="<?=$view->action('change_password')?>
<?=$token->generate('perform_change_password');?>
<input type="password" name="new_password" />
<button type="submit">Change Password</button>
</form>
Even if you're not using a form, you can still include token validation. For instance, a button to remove themes could use a token to ensure security:
$valt = Core::make('token');
$bt = Core::make('helper/concrete/ui');
echo $bt->button(t('Remove Object'), $view->url('/path/to/page', 'remove_object', $valt->generate('remove_object_string')), 'btn-danger');
Remember, always use tokens to secure actions against CSRF attacks. This way, you help ensure the integrity and security of your Concrete CMS application.
Defending Against Cross-Site Scripting: Use Output Filtering & Sanitization
Cross-Site Scripting (XSS) is a major threat. Need to understand XSS? Check this out. The golden rule to counter XSS? Always be skeptical of user input.
Form Input
Displaying user input without checks can expose you to risks. Here's an example with a search form:
<form method="get" action="<?=$view->action('search')?>">
<input type="text" name="query" />
<button type="submit">Search</button>
</form>
Controller logic:
public function search()
{
// ... Some search logic here ...
$this->set("query", $this->request->query('query'));
}
Display in view:
<h1>Search Results</h1>
You searched for: "<?=$query?>"
Looks okay, right? But, this is prone to XSS. The $query
variable is taken at face value without any checks. Entering JavaScript code here could have harmful consequences.
The fix? Use PHP's htmlspecialchars
or Concrete CMS's h()
function:
$this->set("query", h($this->request->query('query')));
This way, harmful HTML/JavaScript gets neutralized.
Attributes
Be cautious when displaying data, like user profiles, that could have untrusted input. Generally, the best practice is to save raw data, but sanitize it when displaying.
How to display attributes safely:
$ui = \UserInfo::getByName('andrew');
print $ui->getAttribute('bio');
But this might contain unsafe data! Here's how you make sure it's safe:
print $ui->getAttribute('bio', 'displaySanitized');
The getAttribute()
function, when passed a second parameter, first tries to sanitize the data before displaying it. For instance, the rich_text
attribute will run through:
getDisplaySanitizedValue()
Then, if needed:
getDisplayValue()
If you have attributes, especially from users, make sure to display them using 'displaySanitized'.
Sanitizing User Input
Are you accepting user input and you really need to be sure it comes in in a sanitized format? This can easily be accomplished by using our sanitization libraries:
Basic HTML Special Character Encoding
h($request->request->get('maliciousParameter'));
The POST variable 'maliciousParameter' will be run through htmlspecialchars
.
Special Sanitize Methods
Use the sanitizing service to sanitize based on types:
Retrieve the Service
$service = \Core::make('helper/security');
Then you can run all the sanitize methods found in Concrete\Core\Validation\SanitizeService
print $service->sanitizeInt($request->request->get('maliciousInt'));
Or email:
print $service->sanitizeEmail($request->request->get('maliciousInt'));
These methods will only validate integers and emails through.
Preventing SQL Injection
Developers working with databases should be aware of SQL Injection. In short:
An attacker can insert harmful SQL code through your web form or URL. And if you're wondering how they know about your database structure, remember, we're open source!
Concrete's core is fortified against SQL injection, but you must ensure your code is too. Here's an example of vulnerable code:
<form method="post" action="<?=$view->action('search')?>
User ID: <input type="text" name="uID" />
<button type="submit">Search</button>
</form>
public function search()
{
$db = \Database::connection();
$ids = $db->GetCol('select cID from Pages where uID = {$this->request->request->get('uID')}');
}
A user might input "1 union select uEmail from Users" to fetch email addresses or more.
Solution 1: Use the API
Avoid direct SQL when possible. Use the concrete5 API which is safer:
public function search()
{
$ui = \UserInfo::getByID($this->request->request->get('uID'));
if (is_object($ui)) {
$list = new \Concrete\Core\Page\PageList();
$list->filterByUserID($ui->getUserID());
$results = $list->getResults();
}
}
This approach is more secure and concise.
Solution 2: Apply Placeholders
If you must run a direct SQL query, use placeholders. They're supported by Doctrine DBAL and PHP's PDO:
public function search()
{
$db = \Database::connection();
$ids = $db->GetCol('select cID from Pages where uID = ?', array($this->request->request->get('uID')));
}
It's clear and protects against SQL injection.
File Upload Checks
When handling file uploads in custom code, always verify the file first. Here's how to safely validate files:
Use Basic PHP Checks
For any file upload via a form:
<form method="post" enctype="multipart/form-data" action="<?=$view->action('save_avatar')?>">
<input type="file" name="avatar">
<button type="submit">Save Avatar</button>
</form>
Ensure to employ standard PHP checks:
public function save_avatar()
{
if (isset($_FILES['avatar']) && (is_uploaded_file($_FILES['avatar']['tmp_name']))) {
// ready to continue
}
}
Confirm It's an Image
Use Concrete's built-in Image validation to ensure the file is an image:
public function save_avatar()
{
if (isset($_FILES['avatar']) && (is_uploaded_file($_FILES['avatar']['tmp_name']))) {
$service = \Core::make("helper/validation/file");
if ($service->image($_FILES['avatar']['tmp_name'])) {
// It's a confirmed image
}
}
}
Check File Extensions
Ensure the file extension is supported:
public function save_avatar()
{
if (isset($_FILES['avatar']) && (is_uploaded_file($_FILES['avatar']['tmp_name']))) {
$service = \Core::make("helper/validation/file");
if ($service->extension($_FILES['avatar']['name'])) {
// Extension is supported
}
}
}
This validation uses the list of allowed extensions from the Concrete Dashboard.
Encryption Service
Need to encrypt potentially sensitive data, while still being able to retrieve it for later? Use the encryption service!
First, retrieve the service:
$encryptor = \Core::make("helper/encryption");
Now, you can use
$encrypted = $encryptor->encrypt('This text will be encrypted.');
And
print $encryptor->decrypt($encrypted); // "This text will be encrypted."
Important Note: These functions rely on the mcrypt
library; if the library is not installed, the output will not be encrypted. It will just be passed back as unencrypted text.
Encrypting User Passwords
Note: this is NOT to be used for user passwords! It's not secure enough. In general, you should never encrypt user passwords with anything that can be reversed. Instead, encrypt it using a one-way hashing algorithm, and any time the user enters their password, compare the value of the hashed provided password with the one you're storing. This is all taken care of by Concrete when dealing with the standard User model, but if you need to encrypt user passwords yourself for business purposes, here's the secure, standardized method by which Concrete accomplishes this.
First, retrieve the global password hasher from the User object:
$u = new User();
$hasher = $u->getUserPasswordHasher();
This will return an instance of the Hautelook\Phpass\PasswordHash object, with the proper configuration values set for portability and proper encryption of data. Then, it's a simple matter of either calling
return $hasher->HashPassword($password);
to generate an encrypted password for storage, or
$hasher->checkPassword($inputPassword, $storedPassword)
to check an inputted password against the password stored in the database.
Anti-Spam and Captcha
Spam is a significant issue online. Without protection, online contact forms quickly become inundated with spam. Luckily, there are several systems available to counter this issue. All user-facing forms, like Form and Survey blocks, as well as the registration system, channel their input through our Anti-Spam layer. Furthermore, Captcha can be used with all these forms.
Enabling Anti-Spam and Captcha
For detailed instructions on activating these features, refer to the Captcha and Spam Control sections of the Editors Documentation.
Implementing Captcha in Your Forms
To integrate captcha into your custom form, append this code at the end of the view layer:
<form method="post" action="<?=$view->action('submit_form')?>">
<?php
$captcha = Core::make('captcha');
?>
<div class="form-group">
<label class="control-label"><?=$captcha->label()?></label>
<div><?php $captcha->display(); ?></div>
<div><?php $captcha->showInput(); ?></div>
</div>
</form>
This will show the output of the chosen Captcha library.
Ensure to validate the Captcha when the form is submitted:
public function submit_form()
{
$captcha = \Core::make("captcha");
if ($captcha->check()) {
// Continue with submission
} else {
// Inform the user of the captcha error
}
}
With that, your form now has captcha protection.
Implementing Anti-Spam in Your Forms
To add anti-spam support, consider the example form with input fields first_name
, last_name
, and feedback
. The method below will check these fields for spam:
$antispam = \Core::make('helper/validation/antispam');
$message = "First Name: {$this->request->request->get('first_name')}\n";
$message .= "Last Name: {$this->request->request->get('last_name')}\n";
$message .= "Feedback: {$this->request->request->get('feedback')}\n";
$u = new \User();
$additionalArgs = array('user' => $u);
if (!$antispam->check($message, 'feedback_form', $additionalArgs)) {
return false;
}
Creating Your Own Captcha Service
The Anti-Spam and Captcha services can be customized. Initially, there are no Anti-Spam services and a basic SecurImage Captcha is provided. However, you can easily design your own Captcha and Anti-Spam libraries as part of a Package.
Setting Up Your Captcha
During your package's install()
method, integrate your Captcha library. Assuming your package is called "Awesome Captcha" with the handle "awesome_captcha":
public function install()
{
$pkg = parent::install();
\Concrete\Core\Captcha\Library::add('awesome_captcha', t('Awesome Captcha'), $pkg);
}
Designing a Captcha Class
When setting up your class, remember to provide the essential methods: showInput()
, display()
, label()
, and check()
.
Custom Options
If your Captcha needs custom settings, like an API key for Google's ReCaptcha, you can define these in the Dashboard. Create a form at packages/awesome_captcha/elements/system/captcha/awesome_captcha/form.php
, and then specify a saveOptions($data)
method in your controller to store this information.
Crafting Your Own Anti-Spam Provider
Setting Up Your Provider
In your package's install()
method, integrate your Anti-Spam library. For a package named "Akismet" with the handle "akismet":
public function install()
{
$pkg = parent::install();
\Concrete\Core\Antispam\Library::add('akismet', t('Akismet'), $pkg);
}
Designing an Anti-Spam Class
Ensure that your class implements the necessary methods: check($data)
and report($data)
.
Custom Options
For custom settings, create a form at packages/akismet/elements/system/antispam/akismet/form.php
. Then, define a saveOptions($data)
method in your controller to store the data.
Programmatically Managing IP Blacklist
The IP Blacklist is a robust feature that is always active when the Anti-Spam system is in use. You can interact with the IP blacklist through code as follows:
Verifying an IP
To check an IP, first get the IP Service:
$ip = \Core::make('helper/validation/ip');
Then, execute the check:
if ($ip->isBanned()) {
// IP is blacklisted
}
Blocking an IP
To blacklist the current IP:
$ip->createIPBan();