ASVS Security Verification Skill
Reference: OWASP ASVS v5.0.0 — cross-reference IDs against https://github.com/OWASP/ASVS for the authoritative source.
When This Activates
- Performing security code reviews
- Implementing security features
- Responding to security review findings
- Security compliance verification
ASVS Requirements Checklist
V1 — Encoding and Sanitization (Critical)
| ID |
Requirement |
Drupal Validation |
| V1.1.1 |
Output encoding for HTTP/HTML context |
Twig auto-escape or #plain_text |
| V1.1.2 |
Dynamic URL building with encoding |
Url::fromRoute(), never string concat |
| V1.1.3 |
JavaScript/JSON encoding |
Json::encode() before output |
| V1.1.4 |
SQL injection prevention |
Parameterized queries only |
| V1.1.5 |
OS command injection prevention |
Avoid shell_exec, exec, system |
| V1.1.6 |
LDAP injection prevention |
Use parameterized LDAP queries |
| V1.1.7 |
Regex metacharacter escaping |
preg_quote() on user input |
| V1.2.1 |
HTML sanitization |
Xss::filter() or Xss::filterAdmin() |
| V1.2.2 |
No eval() with user input |
Avoid eval(), use Json::decode() |
| V1.2.3 |
SSRF prevention |
Validate URLs against explicit allowlist |
V2 — Validation
| ID |
Requirement |
Drupal Validation |
| V2.1.1 |
Input validation at server |
Validate all user input server-side |
| V2.1.2 |
Server-side validation only |
Never trust client-only validation |
| V2.2.1 |
Positive validation (allowlist) |
Use \Drupal\Component\Utility\Html::escape() for plain text |
| V2.3.1 |
Anti-automation / rate limiting |
Flood control or custom rate limit service |
V3 — Web Frontend Security
| ID |
Requirement |
Drupal Validation |
| V3.1.1 |
Cookie Secure attribute |
ini_set('session.cookie_secure', TRUE) |
| V3.1.2 |
Cookie SameSite attribute |
session.cookie_samesite = Lax in settings.php |
| V3.1.3 |
Cookie HttpOnly |
session.cookie_httponly = TRUE |
| V3.2.1 |
HSTS header |
Strict-Transport-Security response header |
| V3.2.2 |
CORS allowlist |
Validate Origin; never use wildcard |
| V3.2.3 |
CSP header |
Content-Security-Policy response header |
| V3.2.4 |
X-Content-Type-Options |
nosniff |
| V3.2.5 |
Referrer-Policy |
Set appropriate policy |
| V3.2.6 |
frame-ancestors |
Prevent clickjacking via CSP or X-Frame-Options |
| V3.3.1 |
CSRF protection |
Form API tokens (automatic) or CsrfTokenGenerator for custom endpoints |
V4 — API and Web Service
| ID |
Requirement |
Drupal Validation |
| V4.1.1 |
Content-Type header |
Set correct Content-Type on all responses |
| V4.1.2 |
HTTP method restrictions |
routing.yml methods: key |
| V4.1.3 |
HTTP request smuggling |
Proper header parsing; avoid custom HTTP parsing |
| V4.2.1 |
Authentication on every API endpoint |
_permission or _custom_access in routing |
V5 — File Handling
| ID |
Requirement |
Drupal Validation |
| V5.1.1 |
File size limits |
upload_max_filesize, managed file field limits |
| V5.1.2 |
File type validation |
file_validate_extensions() + MIME check |
| V5.2.1 |
No file execution |
Never serve uploads from a web-accessible path as PHP |
| V5.2.2 |
Path traversal prevention |
Use FileSystem::realpath(), validate against allowed dirs |
V6 — Authentication
| ID |
Requirement |
Drupal Validation |
| V6.1.1 |
Password minimum length |
Minimum 12 chars (Drupal default: 8 — raise it) |
| V6.1.2 |
Breached password check |
Integrate HaveIBeenPwned API or password_policy module |
| V6.1.3 |
No restrictive composition rules |
Allow all printable characters |
| V6.1.4 |
Long password support |
Allow 64+ characters |
| V6.2.1 |
Multi-factor authentication |
tfa module or SAML/OIDC with MFA |
| V6.3.1 |
Secrets from secure source |
getenv() or Key module — never config.get() |
| V6.3.2 |
No production secret fallback |
Throw exception if secret missing in prod |
V7 — Session Management
| ID |
Requirement |
Drupal Validation |
| V7.1.1 |
Session identifier length |
Minimum 128 bits of entropy |
| V7.1.2 |
Session identifier randomness |
Cryptographically random (Drupal core handles this) |
| V7.1.3 |
Session inactivity timeout |
Configure in settings.php |
| V7.1.4 |
Session termination on logout |
session_destroy() via AccountInterface::logout() |
| V7.2.1 |
New session on privilege change |
\Drupal::service('session')->migrate() on role change |
| V7.2.2 |
Session token not in URLs |
Ensure session.use_only_cookies = TRUE |
V8 — Authorization
| ID |
Requirement |
Drupal Validation |
| V8.1.1 |
Least privilege |
Grant minimal roles/permissions |
| V8.1.2 |
Role-based access control |
user.permissions + AccessResult |
| V8.1.3 |
Access control on every request |
_permission, _entity_access, or _custom_access |
| V8.2.1 |
No user/role enumeration |
Return identical responses for missing vs forbidden |
| V8.2.2 |
Sensitive data access logging |
Watchdog or event subscriber |
| V8.3.1 |
Object-level authorization |
Check entity ownership, not just route access |
V9 — Communication (TLS/Transport)
| ID |
Requirement |
Drupal Validation |
| V9.1.1 |
TLS required for all traffic |
Enforce HTTPS; redirect HTTP → HTTPS |
| V9.1.2 |
TLS certificate validity |
Valid cert, no self-signed in production |
| V9.1.3 |
TLS 1.2+ only |
Disable TLS 1.0/1.1 at server level |
| V9.2.1 |
No mixed content |
Serve all assets over HTTPS |
| V9.3.1 |
Outbound TLS verification |
Verify peer cert in \GuzzleHttp\Client calls |
V10 — Malicious Code / Supply Chain
| ID |
Requirement |
Drupal Validation |
| V10.1.1 |
Dependency integrity |
composer.lock committed; use composer audit |
| V10.1.2 |
No unused dependencies |
Regularly prune composer.json |
| V10.2.1 |
Code review for third-party modules |
Review contrib module code before enabling |
| V10.3.1 |
Integrity verification |
Use drupal/core-recommended and check module checksums |
V11 — Business Logic
| ID |
Requirement |
Drupal Validation |
| V11.1.1 |
Sequential step enforcement |
Enforce workflow states server-side |
| V11.1.2 |
Business logic limits |
Rate limits per user/IP per operation |
| V11.1.3 |
Transaction handling |
Use \Drupal\Core\Database\Connection::startTransaction() |
V12 — Configuration
| ID |
Requirement |
Drupal Validation |
| V12.1.1 |
Debug mode disabled |
$settings['twig_debug'] = FALSE in prod |
| V12.1.2 |
Verbose errors suppressed |
error_reporting(0) in prod; use logging |
| V12.1.3 |
Security headers present |
HSTS, CSP, X-Content-Type-Options |
| V12.2.1 |
Remove unused modules |
Uninstall and remove unused contrib |
| V12.3.1 |
Protect sensitive config files |
.htaccess on sites/default/, settings.php read-only |
V13 — Data Protection
| ID |
Requirement |
Drupal Validation |
| V13.1.1 |
Sensitive data not logged |
Strip PII from watchdog/log entries |
| V13.1.2 |
Sensitive data not in URLs |
Never pass tokens or PII as query parameters |
| V13.2.1 |
Data retention policy |
Define and enforce data retention in code |
| V13.3.1 |
Alert on anomalies |
Event subscriber for suspicious patterns |
V14 — Cryptography
| ID |
Requirement |
Drupal Validation |
| V14.1.1 |
Approved algorithms only |
AES-256, SHA-256+; no MD5/SHA-1 for security |
| V14.2.1 |
Secure random number generation |
\Drupal\Component\Utility\Crypt::randomBytes() |
| V14.3.1 |
Passwords hashed with strong algorithm |
Drupal's PhpassHashedPassword (or stronger via password_compat) |
| V14.4.1 |
Secrets not in source code |
Key module or environment variables |
| V14.5.1 |
No hardcoded crypto keys |
Use Key module; rotate keys on breach |
Critical Security Patterns for Drupal
SQL Injection (V1.1.4)
// VULNERABLE
$query = "SELECT * FROM users WHERE name = '" . $name . "'";
// SAFE — Drupal Query Builder
$result = $this->connection->select('users', 'u')
->fields('u')
->condition('name', $name)
->execute();
// SAFE — Placeholder
$result = $this->connection->query(
'SELECT * FROM {users} WHERE name = :name',
[':name' => $name]
);
XSS Prevention (V1.2.1)
// VULNERABLE
['#markup' => $user_input]
// SAFE — plain text
['#plain_text' => $user_input]
// SAFE — Twig auto-escape (always prefer)
{{ variable }}
// SAFE — Admin HTML (trusted editors only)
use Drupal\Component\Utility\Xss;
$safe = Xss::filterAdmin($html);
// SAFE — plain text in arbitrary context
use Drupal\Component\Utility\Html;
$safe = Html::escape($user_input);
CORS Configuration (V3.2.2)
// VULNERABLE — wildcard
$response->headers->set('Access-Control-Allow-Origin', '*');
// SAFE — explicit allowlist; only add Credentials header when the
// endpoint actually requires cookie/auth-header sharing
$allowed = $config->get('allowed_origins') ?: [];
$origin = $request->headers->get('Origin', '');
if ($origin && in_array($origin, $allowed, TRUE)) {
$response->headers->set('Access-Control-Allow-Origin', $origin);
$response->headers->set('Vary', 'Origin');
// Only add if this endpoint specifically requires credentialed requests:
// $response->headers->set('Access-Control-Allow-Credentials', 'true');
}
Authorization — Entity Access (V8.1.3, V8.3.1)
// VULNERABLE — route access only, no object-level check
$node = Node::load($nid);
return $node->get('field_secret')->value;
// SAFE — always check entity access
$node = Node::load($nid);
if (!$node || !$node->access('view')) {
throw new AccessDeniedHttpException();
}
return $node->get('field_secret')->value;
// SAFE — AccessResult in access checker service
use Drupal\Core\Access\AccessResult;
public function access(AccountInterface $account): AccessResult {
return AccessResult::allowedIfHasPermission($account, 'view secret content')
->cachePerPermissions();
}
CSRF for Custom Endpoints (V3.3.1)
// VULNERABLE — custom route with no CSRF check
public function myAction(Request $request): Response { ... }
// SAFE — Form API handles CSRF automatically; for non-form endpoints:
// In routing.yml add: requirements: { _csrf_token: 'TRUE' }
// Then validate token in controller:
$token = $request->query->get('token');
if (!$this->csrfTokenGenerator->validate($token, 'my-action')) {
throw new AccessDeniedHttpException();
}
Rate Limiting — Flood Control (V2.3.1)
// Drupal's built-in flood control service
$flood = \Drupal::service('flood');
$identifier = $request->getClientIp();
if (!$flood->isAllowed('my_module.action', $threshold = 10, $window = 3600, $identifier)) {
throw new TooManyRequestsHttpException(null, 'Rate limit exceeded.');
}
// Register the flood event after processing
$flood->register('my_module.action', $window = 3600, $identifier);
Secret Management (V6.3.1)
// VULNERABLE — stored in config
$secret = $config->get('jwt_secret');
// SAFE — environment variable
$secret = getenv('JWT_SECRET');
// SAFE — Key module (preferred for Drupal)
$key = \Drupal::service('key.repository')->getKey('jwt_secret');
$secret = $key->getKeyValue();
// Always fail hard in production if secret is missing
if ($is_production && empty($secret)) {
throw new \RuntimeException('JWT secret is required in production.');
}
Security Headers (V3.2.1–V3.2.4)
// In an EventSubscriber on KernelEvents::RESPONSE:
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'");
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
File Upload Validation (V5.1.2)
// SAFE — validate extension and MIME type
$validators = [
'file_validate_extensions' => ['pdf jpg png'],
'file_validate_size' => [10 * 1024 * 1024], // 10 MB
'file_validate_image_resolution' => ['2000x2000'],
];
$file = file_save_upload('my_field', $validators, 'private://', FILE_EXISTS_RENAME);
if (!$file) {
// Upload failed validation
}
Cryptographic Operations (V14.2.1, V14.3.1)
// SAFE — cryptographically secure random bytes
use Drupal\Component\Utility\Crypt;
$token = Crypt::randomBytesBase64(32);
// SAFE — password hashing (Drupal core)
$hashed = \Drupal::service('password')->hash($plain);
$valid = \Drupal::service('password')->check($plain, $hashed);
// UNSAFE — never use for security-sensitive hashing
$bad = md5($input);
$bad = sha1($input);
Quick Verification Checklist
Before any code is committed:
Red Flags
| Pattern |
ASVS |
Risk |
| String concat in SQL |
V1.1.4 |
SQL injection |
#markup with user data |
V1.2.1 |
XSS |
Access-Control-Allow-Origin: * |
V3.2.2 |
Cross-origin data leakage |
eval() with input |
V1.2.2 |
Code injection |
config.get() for secrets |
V6.3.1 |
Credential exposure |
| No rate limiting on auth/forms |
V2.3.1 |
Brute force / DoS |
| Missing CSRF token |
V3.3.1 |
CSRF |
| No entity access check |
V8.3.1 |
Broken object-level auth |
| MD5/SHA-1 for passwords or tokens |
V14.1.1 |
Weak cryptography |
| Uploaded files in public:// without type check |
V5.1.2 |
Remote code execution |
| HTTP-only routes in production |
V9.1.1 |
Credential interception |
| Debug mode / verbose errors in prod |
V12.1.1 |
Information disclosure |
Drupal-Specific Module References
| Concern |
Recommended Module / Core API |
| Secrets / key storage |
drupal/key + drupal/key_asymmetric |
| Rate limiting |
Core Flood service or drupal/rate_limiter |
| MFA |
drupal/tfa |
| Password policy |
drupal/password_policy |
| Security headers |
drupal/seckit |
| SAML / OIDC SSO |
drupal/samlauth or drupal/openid_connect |
| File scanning |
drupal/clamav |
| Security audit |
drupal/security_review |
| Dependency audit |
composer audit (built-in) |
References