You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feature #634 [DeepClone] Gate closures over named callables behind an allow_named_closures option (nicolas-grekas)
This PR was merged into the 1.x branch.
Discussion
----------
[DeepClone] Gate closures over named callables behind an allow_named_closures option
| Q | A
| ------------- | ---
| Branch? | 1.x
| Bug fix? | no
| New feature? | yes
| Deprecations? | no
| Issues | -
| License | MIT
A by-name closure payload (a first-class callable over a named function or method, e.g. `strlen(...)` or `Cls::method(...)`, and `Closure::fromCallable()`) makes `deepclone_from_array()` a factory for a `Closure` over any function or method of that name -- with no visibility or staticness restriction and including internal functions such as `system()`. That is a stronger primitive than anything `unserialize()` exposes, and a one-step gadget when paired with an object whose `__wakeup`/`__unserialize`/destructor invokes a stored callback.
`deepclone_to_array()` and `deepclone_from_array()` gain a third parameter, `bool $allowNamedClosures = false`, that **both ends must enable**. With it off (the default) `to_array` refuses to encode such a closure and `from_array` rejects any payload carrying a by-name closure marker before instantiating anything. This is a behavior change: closures over named callables previously serialized and resolved unconditionally.
- **The attribute-cache use case does not regress.** First-class callables over a method of their own declaring class declared in a constant expression (e.g. `#[Assert\When(self::isStrict(...))]`, symfony/symfony#63228) are now encoded as a declaration-site reference (mask `1`), like anonymous const-expr closures, instead of by name; they resolve only to a closure the named class itself declares and round-trip **without** the opt-in. Userland identifies them by the target method's reflection key over the existing per-class const-expr index.
- **What still needs the opt-in.** Cross-class or global-function callables, inherited methods, and any first-class callable created at runtime keep the by-name form and require `$allowNamedClosures` on both ends.
- **`allowed_classes` is unchanged** and still gates `Closure` for both forms.
Payloads stay byte-identical and interchangeable with the extension (verified both directions). Tests cover the const-expr routing, both refusals, cross-end enforcement, a `system()` payload rejected by default, and wholesale rejection of a nested by-name closure; existing named-closure tests pass the opt-in.
Companion extension implementation: symfony/php-ext-deepclone#26
Commits
-------
3c0c20c [DeepClone] Gate closures over named callables behind an allow_named_closures option
if (!(\PHP_VERSION_ID >= 80200 ? $r->isAnonymous() : str_contains($r->name, "@anonymous\0"))) {
661
+
// First-class callable. When it references a method of its
662
+
// own declaring class declared in a constant expression
663
+
// (e.g. #[When(self::isStrict(...))]), encode it as a
664
+
// declaration-site reference like the extension does, so it
665
+
// round-trips without the allow_named_closures opt-in.
666
+
// Closure is allow-list-gated first, mirroring the
667
+
// extension (reported before any const-expr is evaluated).
668
+
if (\PHP_VERSION_ID >= 80500 && !$r->getClosureThis() && $r->getClosureScopeClass()) {
669
+
if (null !== $allowedSet && !isset($allowedSet['closure'])) {
670
+
thrownew \ValueError('deepclone_to_array(): class "Closure" is not allowed');
671
+
}
672
+
if (null !== $ref = self::locateConstExprClosure($r)) {
673
+
$value = $ref;
674
+
$mask[$k] = 1;
675
+
676
+
goto handle_value;
677
+
}
678
+
}
679
+
680
+
// Not addressable as a declaration-site reference (runtime
681
+
// first-class callable, cross-class or global-function
682
+
// target, internal function): encode it by name. That lets
683
+
// deepclone_from_array() mint a Closure over any function or
684
+
// method of that name, so it is gated behind
685
+
// allow_named_closures, which both ends must enable.
686
+
if (!$allowNamedClosures) {
687
+
thrownew \ValueError('deepclone_to_array(): serializing a closure over the named callable "'.$r->name.'" requires enabling the allow_named_closures option');
688
+
}
650
689
if (null !== $allowedSet && !isset($allowedSet['closure'])) {
651
690
thrownew \ValueError('deepclone_to_array(): class "Closure" is not allowed');
0 commit comments