Why allowlisting is the only robust line of defense
Anyone who checks user input only against known attack patterns inevitably misses the next variant. This article explains why allowlist validation beats every blocklist, how to correctly use PHP's filter_var function, which pitfalls Magento's form validation holds, and why validation and sanitization are strictly separate tasks with different goals.
Table of Contents
- 1. Why allowlists beat blocklists
- 2. The right validation layer: server-side is mandatory
- 3. Type, format, range and length: the four checking dimensions
- 4. PHP filter_var(): filters, flags and pitfalls
- 5. Validation libraries: Respect/Validation and Symfony Validator
- 6. Magento form validation: DataObject and form XML
- 7. Magento\Framework\Validator and EAV attribute validation
- 8. Canonicalization before validation
- 9. Validation vs. sanitization: two different jobs
- 10. Summary
- 11. FAQ
1. Why allowlists beat blocklists
A blocklist (denylist) enumerates what is forbidden: specific characters, specific words, specific patterns. The core problem is structural and cannot be fixed with more diligence: a denylist can only block attack patterns that were known at the time it was written. Every new bypass technique, every alternative encoding, every case trick, or every Unicode homoglyph that is not explicitly listed slips right through. A classic example: a filter blocks <script> but lets <img src=x onerror=alert(1)> pass, because nobody thought of that attribute.
An allowlist (whitelist) reverses the principle: it defines exactly what is permitted and implicitly rejects everything else. This is the fail closed principle instead of fail open. A product SKU field that only permits uppercase letters, digits, and hyphens cannot be defeated by any bypass technique, no matter how creative, because any input that does not exactly match the permitted pattern is discarded. The extra effort lies in the design: you need to know in advance which values are valid. That effort pays off, because unlike a denylist, an allowlist does not need constant maintenance against newly discovered attack techniques.
2. The right validation layer: server-side is mandatory
Client-side validation with HTML5 attributes like required, pattern, or maxlength, combined with supporting JavaScript, improves the user experience because errors become visible immediately, without a round trip to the server. It is, however, never a security boundary. Any request can be sent directly to the server via curl, through the browser DevTools, or with a tool like Burp Suite, bypassing every client-side check entirely. Relying on a form's pattern attribute to prevent SQL injection or XSS means, in effect, having no validation at all.
The rule is therefore unambiguous: every input is validated server-side, regardless of whether a client-side check also exists. Client-side validation is pure UX convenience and may exist redundantly alongside server-side validation, but never as a replacement for it. This applies even to input that appears to come from trusted sources, such as internal admin forms, REST endpoints protected by an API key, or CSV imports. Every boundary between systems, even within the same codebase between a controller and a service layer, is a potential entry point for unvalidated data, especially once services get reused elsewhere.
3. Type, format, range and length: the four checking dimensions
Robust validation does not just check whether a value "looks somewhat plausible"; it deliberately covers four dimensions. Type: a numeric field must actually be an integer or float, not a string that happens to look numeric. PHP's weak typing makes this especially important, since "0e123" == "0e456" evaluates to true under loose comparison logic. Format: a pattern like an email address, an IBAN, or a product SKU follows a fixed structure that should be checked with an anchored regex, meaning ^ at the start and $ at the end, so a partial substring match is never enough.
Range: numeric values need explicit upper and lower bounds, such as an order quantity between 1 and 999 or a discount percentage between 0 and 100. Without a range check, a form will happily accept a quantity of minus five or ten million. Length: strings need minimum and maximum limits, both from a business perspective, for example a company name capped at 255 characters to match the database column, and from a security perspective, to prevent denial of service through oversized payloads or ReDoS-prone regex evaluation. All four dimensions belong in every non-trivial validation routine.
<?php
declare(strict_types=1);
namespace Mironsoft\SeoSuite\Validator;
use InvalidArgumentException;
/**
* Strict allowlist validator for URL slugs.
* Rejects everything that is not explicitly permitted.
*/
final class SlugValidator
{
// Only lowercase letters, digits and hyphens, 1-80 chars, anchored on both ends
private const PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/';
private const MAX_LENGTH = 80;
/**
* Validates a slug against the allowlist pattern and length bounds.
*
* @param string $slug Raw input value.
* @return string The validated slug.
* @throws InvalidArgumentException If the slug is not allowed.
*/
public function validate(string $slug): string
{
if ($slug === '' || mb_strlen($slug) > self::MAX_LENGTH) {
throw new InvalidArgumentException('Slug length out of allowed range.');
}
// preg_match returns 1 only on a full anchored match, never a partial one
if (preg_match(self::PATTERN, $slug) !== 1) {
throw new InvalidArgumentException(sprintf('Slug "%s" is not on the allowlist.', $slug));
}
return $slug;
}
}
4. PHP filter_var(): filters, flags and pitfalls
PHP's built-in filter_var() function covers the most common validation cases without requiring an external library. FILTER_VALIDATE_INT, FILTER_VALIDATE_FLOAT, FILTER_VALIDATE_EMAIL, FILTER_VALIDATE_URL, and FILTER_VALIDATE_IP return the (typed) value on valid input and false on invalid input. This is exactly where the most common pitfall lies: checking false with a loose comparison (==) instead of a strict comparison (===) causes bugs, because 0, a legitimate integer return value for a valid zero input, is also treated as "falsy" under loose logic.
Another pitfall: FILTER_VALIDATE_EMAIL only checks syntax against RFC 822 and related standards, never whether the domain exists or has an MX record. Real deliverability requires an additional DNS lookup or a double opt-in. The min_range and max_range options on FILTER_VALIDATE_INT allow range checking directly inside the filter call, without extra code. It is also important not to confuse FILTER_VALIDATE_* with FILTER_SANITIZE_*: sanitize filters silently transform input instead of rejecting invalid values, and are therefore no substitute for real validation.
<?php
declare(strict_types=1);
// Common filter_var() pitfalls and correct usage
// PITFALL: FILTER_VALIDATE_INT returns false on failure, but false == 0 in loose comparisons
$rawId = '0';
$productId = filter_var($rawId, FILTER_VALIDATE_INT);
if ($productId === false) { // must use strict comparison
throw new InvalidArgumentException('Invalid product id.');
}
// Bounded integer validation with min_range / max_range options
$page = filter_var($_GET['page'] ?? '1', FILTER_VALIDATE_INT, [
'options' => ['default' => 1, 'min_range' => 1, 'max_range' => 500],
]);
// PITFALL: FILTER_VALIDATE_EMAIL accepts syntactically valid but non-deliverable addresses
// and does NOT verify the domain has an MX record - treat it as a syntax check only
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
throw new InvalidArgumentException('Invalid email format.');
}
// FILTER_VALIDATE_FLOAT with decimal option for locale-sensitive input
$price = filter_var('19,99', FILTER_VALIDATE_FLOAT, [
'options' => ['decimal' => ','],
]);
// PITFALL: filter_var() with FILTER_SANITIZE_* silently transforms instead of rejecting
// Do not use sanitize filters where validation (rejection) is required
$unsafe = filter_var($_GET['comment'] ?? '', FILTER_SANITIZE_SPECIAL_CHARS); // wrong tool for validation
5. Validation libraries: Respect/Validation and Symfony Validator
For rules more complex than individual filter_var() calls, a dedicated validation library pays off. Respect/Validation offers a fluent, chainable API where each rule is composed as its own validator, for example v::stringType()->length(3, 32)->regex($pattern). The library throws a ValidationException on assert() with a list of every violated rule, giving precise feedback for API responses or form errors without having to write your own error-collection logic.
Symfony Validator follows a declarative approach based on constraint objects that can be used in any PHP project independently of the rest of the Symfony framework, including inside Magento modules. Constraints like Assert\NotBlank, Assert\Length, and Assert\Regex are combined into a list and checked against a value; the result is a ConstraintViolationList. Both libraries consistently follow the allowlist principle: you positively define what makes a value valid, instead of maintaining exclusion lists of invalid values. The advantage over hand-rolled validators lies in already-tested edge cases, such as Unicode normalization or multibyte length calculation.
<?php
declare(strict_types=1);
use Respect\Validation\Validator as v;
use Respect\Validation\Exceptions\ValidationException;
// Respect/Validation: declarative allowlist chain, collects all failed rules
$customerAgeValidator = v::intType()->between(18, 120);
try {
$customerAgeValidator->assert($input['age']);
} catch (ValidationException $e) {
// $e->getMessages() returns one message per failed rule
throw new InvalidArgumentException(implode(' ', $e->getMessages()));
}
// Chained allowlist validator for a SKU field
$skuValidator = v::stringType()
->length(3, 32)
->regex('/^[A-Z0-9\-]+$/');
if (!$skuValidator->validate($input['sku'])) {
throw new InvalidArgumentException('SKU does not match the allowed pattern.');
}
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Constraints as Assert;
// Symfony Validator: constraint chain equivalent to the SKU rule above
$validator = Validation::createValidator();
$violations = $validator->validate($input['sku'], [
new Assert\NotBlank(),
new Assert\Length(min: 3, max: 32),
new Assert\Regex(pattern: '/^[A-Z0-9\-]+$/'),
]);
if (count($violations) > 0) {
throw new InvalidArgumentException((string) $violations);
}
6. Magento form validation: DataObject and form XML
Magento defines validation rules for UI Component forms declaratively in form XML under <validation>, with rules like required-entry, validate-length, min_text_length, max_text_length, or validate-digits. These rules are evaluated client-side via Magento's jQuery Validation integration and provide immediate feedback in the admin grid or in storefront forms. What matters: these XML rules run in the browser and are exactly the kind of convenience validation that must never serve as the sole line of defense. Every controller, repository, and service that receives the submitted data must validate it again server-side.
Magento\Framework\DataObject itself does not validate automatically; it is a plain data container with magic getters and setters. Validation must be implemented explicitly in the save logic, in a repository, or in a dedicated validator service before data is written to the database. A common mistake is loading form data straight from getRequest()->getParams() into a DataObject and passing it, unchecked, to a repository that itself performs no validation either.
<?xml version="1.0"?>
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<fieldset name="general">
<field name="sku" formElement="input">
<settings>
<dataType>text</dataType>
<label translate="true">SKU</label>
<!-- Client-side rendered validation rules, mirrored by JS UI but never a substitute for server checks -->
<validation>
<rule name="required-entry" xsi:type="boolean">true</rule>
<rule name="validate-length" xsi:type="boolean">true</rule>
<rule name="min_text_length" xsi:type="number">3</rule>
<rule name="max_text_length" xsi:type="number">32</rule>
<rule name="validate-code" xsi:type="boolean">true</rule>
</validation>
</settings>
</field>
<field name="qty" formElement="input">
<settings>
<dataType>number</dataType>
<validation>
<rule name="required-entry" xsi:type="boolean">true</rule>
<rule name="validate-zero-or-greater" xsi:type="boolean">true</rule>
<rule name="validate-digits" xsi:type="boolean">true</rule>
</validation>
</settings>
</field>
</fieldset>
</form>
7. Magento\Framework\Validator and EAV attribute validation
Magento\Framework\Validator provides a ValidatorChain that combines several rules into a reusable, server-side check chain, such as Magento\Framework\Validator\StringLength for length checks or Magento\Framework\Validator\Regex for format checks. Unlike form XML rules, this chain runs in the server's PHP code and can therefore actually serve as a security boundary. A validator service that wraps the chain can be injected via dependency injection into repositories, save controllers, and import handlers, ensuring consistent rules regardless of the entry channel.
EAV attribute validation is configured declaratively via input_filter and validate_rules on the attribute, for example as alphanum-with-spaces or with a custom regex in the attribute's backend form. These rules, however, only apply when the storage path actually goes through the EAV attribute model, meaning through Magento\Eav\Model\Attribute and its associated backend models. Direct SQL writes, bulk imports through Magento\ImportExport with custom processing, or REST endpoints that bypass repositories, all skip this validation and require their own explicit checks.
<?php
declare(strict_types=1);
namespace Mironsoft\SeoSuite\Model;
use Magento\Framework\Validator\ValidatorChain;
use Magento\Framework\Validator\StringLength;
use Magento\Framework\Validator\Regex;
use Magento\Framework\Validator\Exception as ValidatorException;
/**
* Server-side validation using Magento's Framework\Validator chain.
* Runs independently of any EAV attribute validation configured in the admin.
*/
final class MetaTitleValidator
{
/**
* Builds and runs the allowlist validation chain for a meta title.
*
* @param string $value Raw meta title input.
* @return void
* @throws ValidatorException If any rule in the chain fails.
*/
public function validate(string $value): void
{
$chain = new ValidatorChain();
$chain->addValidator(new StringLength(['min' => 1, 'max' => 70]));
// Anchored allowlist: letters, digits, spaces and a small set of punctuation only
$chain->addValidator(new Regex(['pattern' => '/^[\p{L}\p{N}\s\-.,:!?]+$/u']));
if (!$chain->isValid($value)) {
throw new ValidatorException(implode(' ', $chain->getMessages()));
}
}
}
// EAV attribute validation is configured declaratively, not in PHP:
// bin/magento setup:upgrade after adding input_filter / validate_rules to an eav_attribute row,
// e.g. 'input_filter' => 'trim', 'validate_rules' => serialize(['input_validation' => 'alphanum-with-spaces'])
8. Canonicalization before validation
A validation rule checks a concrete byte sequence, not a "meaning". When the same logical input can be represented in multiple encodings, for example through URL encoding, double URL encoding, Unicode normalization forms (NFC/NFD), UTF-8 overlong encoding, or mixed-case file paths, a regex that covers one form may let another form through. This is exactly the mechanism behind many path traversal and filter bypass attacks: %2e%2e%2f or ..%c0%af slip past a filter that only searches for ../.
The consequence: canonicalization must happen before validation, not after. Input is first brought into a single, defined normal form, for example via urldecode() repeated until it stabilizes, Normalizer::normalize($str, Normalizer::FORM_C) for Unicode text, or realpath() for filesystem paths, before the allowlist check runs. If validation happens first and decoding happens afterward, a malicious payload can pass the check in its encoded form and only take on its actual, disallowed shape after decoding. This ordering, normalize before checking rather than the other way around, is one of the most frequently overlooked points in validation routines.
9. Validation vs. sanitization: two different jobs
Validation and sanitization are often used interchangeably in practice, but they are fundamentally different operations with different guarantees. Validation is a binary decision: a value either matches the allowlist or it is rejected. There is no "partially valid". Sanitization, on the other hand, transforms an input, for example by removing, escaping, or replacing certain characters, and always returns a result, regardless of what the original input looked like.
The potential for conflict arises when the two concepts get mixed up: a developer "validates" an input by stripping dangerous characters with strip_tags() or htmlspecialchars(), and then wrongly assumes the input is now checked and safe for every context. Sanitization for the HTML output context does not automatically make an input valid for a SQL query, a file path, or a business rule like a positive order quantity. The clean separation is this: validation decides whether an input is accepted from a business and structural standpoint, while context-specific escaping or encoding (sanitization in the narrow sense) happens separately, right before the value is used in its specific output context, and never as a replacement for the upstream validation.
| Task | Insecure / Naive | Recommended strategy | Benefit |
|---|---|---|---|
| Email checking | Manually maintain a blocklist of forbidden characters | filter_var($v, FILTER_VALIDATE_EMAIL) |
Tested, allowlist-based format |
| Special characters in free text | str_replace() of known dangerous strings |
Permitted character set via anchored regex | No arms race against new bypasses |
| Validation layer | Client-side HTML5/JS pattern only | Mandatory server-side check, client is UX only | Cannot be bypassed via curl |
| Type checking | Loose comparisons (==, is_numeric) |
Strict typing + FILTER_VALIDATE_INT |
No type-juggling traps |
| File upload | Extension blocklist (.php, .phtml) |
Allowlist of permitted MIME types + content check | No overlooked executable extensions |
Mironsoft
Security audits, code reviews, and secure Magento development
Ready to lock down your input validation?
We audit your Magento and PHP codebase for blocklist pitfalls, missing server-side validation, and unclean separation between validation and sanitization, then implement robust allowlist strategies for forms, APIs, and import interfaces.
Security code review
Systematic review of every input point for allowlist compliance and OWASP standards
Validator refactoring
Replacing blocklist logic with Respect/Validation, Symfony Validator, or Framework\Validator
Form & API hardening
Securing Magento form XML, EAV attributes, and REST/GraphQL endpoints against bypasses
10. Summary
The most important takeaway of these input validation strategies fits in one sentence: an allowlist defines what is permitted and implicitly rejects everything else, while a blocklist only ever captures known attack patterns and inevitably has gaps. Server-side validation is non-negotiable, client-side checking is pure UX convenience. filter_var() covers many standard cases but demands strict comparisons and a clear understanding of what each filter actually checks. Validation libraries like Respect/Validation and Symfony Validator make complex rule chains readable and testable.
In Magento, form XML rules only run in the browser, while Magento\Framework\Validator and EAV attribute validation run server-side, but only on the paths that actually go through the attribute model. Canonicalization before checking prevents encoded payloads from slipping past an allowlist. And the clean separation between validation, which rejects, and sanitization, which transforms, prevents the dangerous assumption that an input sanitized for HTML output is automatically safe for every other context.
Input Validation Strategies: Allowlisting Instead of Blocklisting, the Essentials at a Glance
Allowlist over blocklist
Accept only explicitly permitted values, reject everything else by default. No arms race against new bypass techniques.
Server-side is mandatory
Client validation is UX convenience only, never a security boundary. Any request can bypass it.
Use filter_var() correctly
Strict comparisons, bounds options, and never use sanitize filters as a substitute for validation.
Validation vs. sanitization
Validation rejects, sanitization transforms. Keep them cleanly separated and never confuse the two.