Drupal Site Security Audit
Drupal historically has a good security reputation — dedicated Security Team, Security Advisory (SA) system, regular patches. Nevertheless, outdated modules, incorrect configuration, and custom code create vulnerabilities.
Audit Tools
# Check security updates
drush pm:security
# Detailed list of vulnerable packages
composer audit
# Drupal Security Scanner (external)
# https://www.drupal.org/security/advisories
Checklist
Core and Modules:
# Are all security updates applied?
drush pm:security --format=table
# PHP version
php -v # minimum 8.1, recommended 8.3
File Access Rights:
# settings.php should be read-only
stat web/sites/default/settings.php
# 444 or 400 — correct
# 644 or 755 — too open
# Files directory
find web/sites/default/files -name "*.php" -type f
# PHP files in /files — sign of compromise
# Directories with correct permissions
find web -type d -perm /o+w -not -path "*/\.*"
settings.php Audit:
// Should be:
$settings['hash_salt'] = 'long-random-string'; // unique for each site
$config['system.logging']['error_level'] = 'hide'; // don't show errors
$settings['trusted_host_patterns'] = [ // list of allowed hosts
'^yourdomain\.com$',
'^www\.yourdomain\.com$',
];
// Should not be:
// $settings['rebuild_access'] = TRUE; // development only
// $config['system.performance']['cache']['page']['max_age'] = 0; // not in production
Trusted Host Patterns — critical. Without this setting, site is vulnerable to Host header injection attacks.
Users and Roles:
# List administrators
drush sql:query "SELECT name, mail FROM users u JOIN user__roles ur ON u.uid = ur.entity_id WHERE ur.roles_target_id = 'administrator'"
# Check anonymous permissions
drush role:list --filter=anonymous
Security Configuration:
# Is Security Review enabled?
composer require drupal/security_review
drush en security_review -y
drush secrev --store # run check
Security Review checks: file permissions, role permissions, critical PHP settings, input filters, private files.
Custom Code Analysis
XSS Vulnerabilities:
# Search for unsafe output in Twig
grep -r "raw" web/themes/custom/ web/modules/custom/
# {{ var | raw }} without prior sanitization — vulnerable
# In PHP
grep -r "print_r\(\$_\|echo \$_\|print \$_" web/modules/custom/
SQL Injection:
// Unsafe (do not do):
$result = \Drupal::database()->query("SELECT * FROM node WHERE title = '" . $input . "'");
// Correct:
$result = \Drupal::database()->query(
"SELECT * FROM node WHERE title = :title",
[':title' => $input]
);
CSRF Protection:
// All forms must use FormAPI or explicit token validation
$form['#token'] = 'my_operation';
// Or in custom controller
$this->csrfToken->validate($token, 'my-route');
File Upload:
# Allowed file extensions in fields
drush config:get field.field.node.article.field_attachment | grep "file_extensions"
# Never allow: php, phtml, phar, js, html
Security Headers
// services.yml or custom EventSubscriber
class SecurityHeadersSubscriber implements EventSubscriberInterface {
public function onResponse(ResponseEvent $event): void {
$response = $event->getResponse();
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
}
}
Private Files
// settings.php: store private files outside web root
$settings['file_private_path'] = '/var/private/drupal-files';
Documents accessible only to authorized users — never in sites/default/files (public directory).
Timeline
Drupal security audit with report — 1–2 days. Remediation of critical vulnerabilities — additional 1–3 days.







