business-decision/query-security-bundle
Installation Add the bundle via Composer:
composer require betd/query-security-bundle
Register the bundle in config/bundles.php (Symfony):
return [
// ...
Betd\QuerySecurityBundle\QuerySecurityBundle::class => ['all' => true],
];
Basic Configuration
Configure allowed fields, operators, and filters in config/packages/betd_query_security.yaml:
betd_query_security:
allowed_fields: ['id', 'name', 'email'] # Whitelist fields
allowed_operators: ['=', '!=', '>', '<'] # Whitelist operators
default_filter: 'id' # Fallback filter
First Use Case: Secure API Filtering
In a Symfony controller, use the QuerySecurity service to sanitize input:
use Betd\QuerySecurityBundle\Security\QuerySecurity;
class ProductController extends AbstractController
{
public function listProducts(Request $request, QuerySecurity $querySecurity)
{
$filters = $request->query->all();
$sanitizedFilters = $querySecurity->sanitize($filters);
// Use sanitizedFilters in Doctrine QueryBuilder
return $this->render('product/list.html.twig', [
'products' => $this->getDoctrine()
->getRepository(Product::class)
->findBy($sanitizedFilters),
]);
}
}
Use the QuerySecurity service to dynamically build secure queries:
use Betd\QuerySecurityBundle\Security\QuerySecurity;
use Doctrine\ORM\QueryBuilder;
class ProductController
{
public function listProducts(Request $request, QuerySecurity $querySecurity, EntityManagerInterface $em)
{
$qb = $em->createQueryBuilder();
$qb->select('p')
->from(Product::class, 'p');
$filters = $request->query->all();
$sanitized = $querySecurity->sanitize($filters);
foreach ($sanitized as $field => $value) {
$qb->andWhere("p.$field = :$field")
->setParameter($field, $value);
}
return $this->render('product/list.html.twig', [
'products' => $qb->getQuery()->getResult(),
]);
}
}
For API Platform projects, extend AbstractItem/AbstractCollection to auto-sanitize:
use Betd\QuerySecurityBundle\Security\QuerySecurity;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter;
class SecureFilter extends AbstractFilter
{
public function __construct(private QuerySecurity $querySecurity) {}
protected function filterProperty(string $property, $value, QueryBuilder $qb, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [])
{
$sanitizedValue = $this->querySecurity->sanitize([$property => $value]);
return parent::filterProperty($property, $sanitizedValue[$property], $qb, $queryNameGenerator, $resourceClass, $operationName, $context);
}
}
Override allowed fields per entity using a service:
# config/services.yaml
services:
Betd\QuerySecurityBundle\Security\QuerySecurity:
arguments:
$allowedFields: '%betd_query_security.allowed_fields%'
$customRules: !tagged 'query_security.rule'
Define custom rules:
// src/Service/CustomFieldRule.php
namespace App\Service;
use Betd\QuerySecurityBundle\Security\Rule\FieldRuleInterface;
class CustomFieldRule implements FieldRuleInterface
{
public function isAllowed(string $field): bool
{
return in_array($field, ['id', 'slug', 'created_at']);
}
}
Register the rule:
# config/services.yaml
services:
App\Service\CustomFieldRule:
tags:
- { name: 'query_security.rule' }
Overly Restrictive Whitelists
allowed_fields is empty, all queries will fail. Always define a default.default_filter: 'id' in config to avoid silent failures.SQL Injection via Operator Abuse
'; DROP TABLE users-- can slip through if not properly escaped.Case Sensitivity in Field Names
User.name and user.name as different fields. Normalize case in config:
allowed_fields: ['user.name', 'user.email'] # Explicit casing
Enable Verbose Logging
Add to config/packages/monolog.yaml:
handlers:
query_security:
type: stream
path: "%kernel.logs_dir%/query_security.log"
level: debug
channels: ["query_security"]
Then log sanitization steps in QuerySecurity service.
Test Edge Cases
user.address.city).', ", \).Custom Validators
Extend Betd\QuerySecurityBundle\Security\Validator\FieldValidatorInterface to add logic (e.g., regex validation):
class EmailFieldValidator implements FieldValidatorInterface
{
public function validate(string $field, $value): bool
{
return filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
}
}
Register it in services.yaml under query_security.validator.
Dynamic Operator Whitelisting
Override allowed_operators per request using a middleware:
// src/Middleware/DynamicQuerySecurity.php
public function handle(Request $request, QuerySecurity $security)
{
$security->setAllowedOperators($request->headers->get('x-allowed-ops', ['=']));
return $next($request);
}
Performance Optimization
$querySecurity->setCache(new \Symfony\Component\Cache\Adapter\FilesystemAdapter());
How can I help you explore Laravel packages today?