.agents/skills/symfony-security-review/hardening-families.md
Reference catalogue for the symfony-security-review skill. Each family lists:
Families are grouped by how they are best checked.
grep -rl "extends AbstractRequestParser" src --include=*.php | grep -v TestsdoParse(Request, #[\SensitiveParameter] string $secret)
must verify the request before returning an event: compare the signature with
hash_equals() (never ===/!==/==/strcmp), pin the HMAC algorithm to a
literal (never read it from the request), and reject a missing signature.HardenedComparisonRule (inline compare only),
check-hardening-tests.php (requires a RejectWebhookException test).'' !== $secret is accepted
(an empty configured secret intentionally disables verification). Authenticating
by IP allowlist (IpsRequestMatcher) instead of a secret is accepted.__unserialize() __toString trampolinegrep -rln "function __unserialize" src --include=*.php | grep -v Tests$data[k] to a string-typed property, reject
object values (instanceof \Stringable / is_object), so a crafted payload cannot
fire __toString during unserialize().UnserializeToStringTrampolineRule; check-hardening-tests.php part B.__unserialize() that blanket-throws is exempt.unserialize() without allowed_classesgrep -rn "unserialize(" src --include=*.php | grep -v Tests | grep -v allowed_classesunserialize() of data that is not provably first-party-trusted must
pass ['allowed_classes' => false] or an explicit allowlist.UnserializeMissingAllowedClassesRule.allowed_classes on a first-party cache/container
file is acceptable; the risk is untrusted bytes.unserialize,
destructor gadgets).decode() and doParse() bodies that both deserialize and verify:
grep -rn "function decode\|function doParse" src --include=*.php | grep -v Testsunserialize() / $request->toArray() / inner decode() may run
before the hash_equals() / secret guard. Verify, then parse. Deserializing before
verifying turns a signed transport into an unauthenticated deserialization sink.unserialize() inside __unserialize()__unserialize body, any raw unserialize( that is not parent::__unserialize(.unserialize() again inside __unserialize(); it
re-opens the gadget surface the outer call already controls.UnserializeMissingAllowedClassesRule).__destruct / __wakeup as a POP gadgetgrep -rln "function __destruct\|function __wakeup" src --include=*.php | grep -v Tests,
then keep the classes that are still serializable (no throwing __sleep/__serialize/__wakeup).__destruct()/__wakeup() has exploitable side effects
(filesystem writes, unlink, process or network calls, cache flush) must be made
non-unserializable, so it cannot be reached as a gadget when an untrusted unserialize()
exists elsewhere. The accepted fix throws from __sleep()/__serialize()/__wakeup().
Cache\Adapter\AbstractAdapter and TagAwareAdapter are the canonical exemplars
(they forbid serialization).__destruct/__wakeup with a side-effecting call and no throwing serialization guard).
Complements A3, which blocks instantiating arbitrary gadget classes, by hardening the
gadget itself.unserialize() entry, so usually hardening rather than a CVE on its own; the
gadget-bearing class is still the high-value target. A __destruct that only releases
in-memory state is not a gadget.grep -rn "validateOnParse\|->loadXML(\|LIBXML_NOENT\|LIBXML_DTDLOAD" src --include=*.php | grep -v TestsvalidateOnParse = true or with
LIBXML_NOENT/LIBXML_DTDLOAD; reject DOCTYPE, or disable external entities.loadXML (XML)
from loadHTML.validateOnParse but are guarded by a
post-parse DOCTYPE-rejection loop; confirm the guard before flagging.grep -rn "PRIVATE_SUBNETS\|NoPrivateNetworkHttpClient\|gethostbyname\|filter_var.*FILTER_VALIDATE_IP" src --include=*.php | grep -v Testsgrep -rn "RedirectResponse\|createRedirectResponse\|'Location'\|\"Location\"" src --include=*.php | grep -v Tests//host tricks.grep -rn "class Address\|ParameterizedHeader\|MailboxHeader\|->setUri\|getUri(" src/Symfony/Component/Mime src/Symfony/Component/HttpFoundation --include=*.php | grep -v Tests\r, \n, and other control characters in email addresses,
header parameter names/values, and URIs before they reach a header sink.Mime\Header).grep -rn "escapeshellarg\|proc_open\|\bexec(\|shell_exec\|new Process(" src --include=*.php | grep -v Tests--) before them and reject leading-dash values; on
Windows, account for the documented quoting hazards.SendmailTransport
is the positive exemplar (emits ' --' before the recipient loop).grep -rn "preg_match\|preg_replace\|preg_split\|function.*recurs\|self::\$.*\[" src --include=*.php | grep -v Testsgrep -rn "->escape(\|htmlspecialchars\|is_safe\|sprintf.*<" src/Symfony/Bridge/Twig src/Symfony/Component/ErrorHandler src/Symfony/Component/VarDumper --include=*.php | grep -v Tests*.html.php, dumpers).grep -rn "UrlAttributeSanitizer\|getSupportedAttributes\|DENIED" src/Symfony/Component/HtmlSanitizer --include=*.php | grep -v Testsgrep -rn "->query(\|->exec(\|\"SELECT\|'SELECT\|\\. \\$" src/Symfony/Component/Cache --include=*.php | grep -v Testsgrep -rn "fputcsv\|class CsvEncoder" src/Symfony/Component/Serializer --include=*.php | grep -v Tests=, +, -, @ (and tab/CR) are escaped so
a spreadsheet does not execute them.csv_escape_formulas,
default off) and documented; the default being off is not a finding. The check is that
the escape path, when enabled, covers the full trigger set.grep -rn "UriSigner\|hash_equals\|=== .*hash\|RememberMe.*cookie" src --include=*.php | grep -v Testshash_equals(); HMAC inputs have unambiguous field boundaries.HardenedComparisonRule (inline HMAC compare only).grep -rnE "->denormalize\(|ClassDiscriminator|getMappedObjectType|new \\\$" src/Symfony/Component/Serializer --include=*.php | grep -v Tests,
plus any custom (de)normalizer that reads a class or type name from the payload.ClassDiscriminatorMapping allowlist; never feed an unvalidated type discriminator to
object construction. This is the Serializer analogue of A3.type field reaching new/denormalize. Pairs with A3 and the gadget hardening in B3.grep -rnE "file_get_contents|file_put_contents|fopen|->dumpFile|unlink\(|include |require " src --include=*.php | grep -v Tests;
keep the sites whose path is built from input (session ids, cache keys, upload names,
profiler tokens, command arguments)... and absolute paths, basename() untrusted segments, or realpath()-check containment
under a base directory before the open/write/include.Confirm by reading, not by rule. Raise as needs-human-judgement.
#[IsGranted]/#[IsSignatureValid]
and HEAD-request handling, default-on CSRF/secure cookies, OIDC claim validation.Host header, trusted proxy/header handling,
stripping internal headers before caching, fragment/secret exposure, CAS service URL
derived from Host.UserProvider caches; consistent status codes on switch-user and login.random_bytes/random_int),
not rand/mt_rand/uniqid/microtime; such tokens are compared constant-time (B14).