-
tests/lint/check-architecture.php<pre>@@ -29,14 +29,17 @@* - core-table-write: no INSERT/UPDATE/DELETE/REPLACE on the tables of a base plugin (`coreTables` in* tests/lint/config.php), write through the commands of the base plugin; reading and the update* scripts in files/acp stay allowed- * - permission-strict: no `getPermission(...) === true/false`, the session returns the raw group value ('1', 1, false)* - open-redirect: a RedirectResponse to a request value (`$_GET/$_POST/$_REQUEST`, `returnURL`) needs an* ApplicationHandler::isInternalURL() check in the same file* - grid-view-access: a grid view with an ID constructor parameter (comes from the client when rows are reloaded) whose* isAccessible() only checks `userID > 0`, it has to check access to the parent object* - link-application: `{link controller='X'}` in templates/ of an application without `application=` for a controller* (Page/Form/Action) of the plugin itself, it resolves against wcf*+ * Rules that need the types of PHPStan are PHPStan rules (tests/phpstan): getPermission() compared with true/false+ * (yourecom.permissionStrict), legacy actions that change data without the token (yourecom.secureAction), endpoints that+ * bypass $requireACP (yourecom.endpointRequireAcp).+ ** Known findings that cannot be changed yet are listed in tests/lint/config.php (`architectureKnown`: relative file* => list of rules); entries that no longer match make the check fail, so the list can only shrink.*@@ -95,10 +98,6 @@$report($relative, 'listener-instanceof', 'event listener without an instanceof check of $eventObj');}- if (\preg_match('~getPermission\([^)]*\)\s*[!=]==?\s*(?:true|false)\b~', $source)) {- $report($relative, 'permission-strict', 'getPermission() compared with true/false, it returns the raw group value, cast with (bool)');- }-if (\preg_match('~new\s+RedirectResponse\(\s*(?:\$_(?:GET|POST|REQUEST)\b|\$this->(?:returnURL|redirectURL|url)\b)~', $source)&& !\str_contains($source, 'isInternalURL')
-
tests/lint/check-coverage.php<pre>@@ -2,16 +2,19 @@/*** YoureCom Standard: line coverage of the PHPUnit unit suite (Clover report) against the floor in- * tests/coverage-minimum.txt. The floor only goes up. Managed by _dev/standard (apply.php), do not edit here.+ * tests/coverage-minimum.txt. The floor only goes up: with --raise (check.sh does that, the CI does not) it is lifted to+ * the measured coverage minus one point, rounded down to a whole number (the margin absorbs the small difference between+ * the local run and the CI). Managed by _dev/standard (apply.php), do not edit here.*- * Usage: php tests/lint/check-coverage.php var/coverage.xml+ * Usage: php tests/lint/check-coverage.php var/coverage.xml [--raise]*/namespace yourecom\standard;require __DIR__ . '/lib.php';$report = $argv[1] ?? 'var/coverage.xml';+$raise = \in_array('--raise', $argv, true);if (!\is_file($report)) {fail(["The coverage report {$report} does not exist (run PHPUnit with --coverage-clover first)"]);}@@ -24,9 +27,17 @@$statements = (int)$xml->project->metrics['statements'];$covered = (int)$xml->project->metrics['coveredstatements'];$percent = $statements > 0 ? 100 * $covered / $statements : 100.0;-$minimum = (float)\trim((string)@\file_get_contents(ROOT . '/tests/coverage-minimum.txt'));+$floorFile = ROOT . '/tests/coverage-minimum.txt';+$minimum = (float)\trim((string)@\file_get_contents($floorFile));\printf("Coverage of the unit suite: %.2f%% (%d of %d statements), floor %.2f%%\n", $percent, $covered, $statements, $minimum);if ($percent + 0.005 < $minimum) {fail(['The coverage is below the floor.']);}++// the floor follows the coverage up (never down): check.sh lifts it, the change is committed with the code+$lifted = \floor($percent - 1);+if ($raise && $lifted > $minimum) {+ \file_put_contents($floorFile, \sprintf("%d\n", $lifted));+ \printf("Floor raised from %s to %d%% (tests/coverage-minimum.txt), commit it with the change.\n", $minimum, $lifted);+}
-
tests/phpstan/EndpointRequireAcpRule.php<pre>@@ -0,0 +1,153 @@+<?php++namespace yourecom\standard\phpstan;++use PhpParser\Node;+use PhpParser\Node\Expr\MethodCall;+use PHPStan\Analyser\Scope;+use PHPStan\Reflection\ClassReflection;+use PHPStan\Rules\Rule;+use PHPStan\Rules\RuleErrorBuilder;++/**+ * YoureCom Standard: an RPC endpoint that calls `validateX()` of a DatabaseObjectAction whose `x` is in `$requireACP`+ * checks the ACP access itself. `$requireACP` only blocks AJAX requests (`validateAction()`), the direct call bypasses it, so+ * a method that was meant for the ACP only is open to everyone who passes `validateX()` (e.g. moderators without ACP access+ * created shipping labels through `/api/rpc/`, WoltLab review, October 2026).+ *+ * Only own method names count (not create/update/delete: the standard puts those into `$requireACP` as a second lock for+ * frontend actions, too). Fine if the endpoint or `validateX()` mentions `canUseAcp` or an `admin.` permission; a method+ * that is meant for the frontend leaves `$requireACP` (or gets an ignore with the reason).+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * @author Marcel Beckers+ * @license YoureCom License - Commercial (YCLC) <https://www.yourecom.de/shop/licence-commercial/>+ * @implements Rule<MethodCall>+ */+final class EndpointRequireAcpRule implements Rule+{+ private const CRUD = ['create', 'update', 'delete', 'action'];++ #[\Override]+ public function getNodeType(): string+ {+ return MethodCall::class;+ }++ #[\Override]+ public function processNode(Node $node, Scope $scope): array+ {+ $endpoint = $scope->getClassReflection();+ if (+ $endpoint === null+ || !$endpoint->implementsInterface('wcf\system\endpoint\IController')+ || !$node->name instanceof Node\Identifier+ || \preg_match('~^validate(\w+)$~', $node->name->toString(), $match) !== 1+ ) {+ return [];+ }++ $name = \lcfirst($match[1]);+ if (\in_array(\strtolower($name), self::CRUD, true)) {+ return [];+ }++ foreach ($scope->getType($node->var)->getObjectClassReflections() as $action) {+ if (!$action->isSubclassOf('wcf\data\AbstractDatabaseObjectAction') || !\in_array($name, $this->requireAcp($action), true)) {+ continue;+ }+ if ($this->checksAcp($this->source($endpoint)) || $this->methodChecksAcp($action, $node->name->toString())) {+ continue;+ }++ return [+ RuleErrorBuilder::message(\sprintf(+ 'The endpoint calls %s::%s() directly, which bypasses $requireACP (\'%s\' is in it): check admin.general.canUseAcp or an administrator permission in the endpoint or in %s(). If the method is meant for the frontend, remove it from $requireACP.',+ $action->getDisplayName(),+ $node->name->toString(),+ $name,+ $node->name->toString(),+ ))+ ->identifier('yourecom.endpointRequireAcp')+ ->build(),+ ];+ }++ return [];+ }++ /**+ * The default value of `$requireACP` of the action class.+ *+ * @return list<string>+ */+ private function requireAcp(ClassReflection $action): array+ {+ $value = $action->getNativeReflection()->getDefaultProperties()['requireACP'] ?? [];++ return \is_array($value) ? \array_values(\array_filter($value, 'is_string')) : [];+ }++ /**+ * Whether the source checks the ACP access or an administrator permission.+ */+ private function checksAcp(string $source): bool+ {+ return \str_contains($source, 'canUseAcp') || \preg_match('~[\'"]admin\.~', $source) === 1;+ }++ /**+ * Whether the validation method checks the ACP access, itself or in a `$this->validate…()` it calls (e.g.+ * validateCreateReturnLabel() calling validateCreateLabel()), up to three calls deep.+ */+ private function methodChecksAcp(ClassReflection $action, string $method, int $depth = 0): bool+ {+ $source = $this->methodSource($action, $method);+ if ($this->checksAcp($source)) {+ return true;+ }+ if ($depth >= 3 || \preg_match_all('~\$this->(validate\w+)\s*\(~', $source, $calls) === 0) {+ return false;+ }+ foreach (\array_unique($calls[1]) as $called) {+ if (\strcasecmp($called, $method) !== 0 && $this->methodChecksAcp($action, $called, $depth + 1)) {+ return true;+ }+ }++ return false;+ }++ /**+ * The source of the class file, empty if unknown.+ */+ private function source(ClassReflection $class): string+ {+ $file = $class->getFileName();++ return $file !== null && \is_file($file) ? (string)\file_get_contents($file) : '';+ }++ /**+ * The source of a method (its lines in the declaring class file), empty if unknown.+ */+ private function methodSource(ClassReflection $class, string $method): string+ {+ $native = $class->getNativeReflection();+ if (!$native->hasMethod($method)) {+ return '';+ }++ $reflection = $native->getMethod($method);+ $file = $reflection->getFileName();+ $start = $reflection->getStartLine();+ $end = $reflection->getEndLine();+ if ($file === false || $start === false || $end === false || !\is_file($file)) {+ return '';+ }++ $lines = \file($file) ?: [];++ return \implode('', \array_slice($lines, $start - 1, $end - $start + 1));+ }+}
-
tests/phpstan/PermissionStrictRule.php<pre>@@ -0,0 +1,69 @@+<?php++namespace yourecom\standard\phpstan;++use PhpParser\Node;+use PhpParser\Node\Expr;+use PHPStan\Analyser\Scope;+use PHPStan\Rules\Rule;+use PHPStan\Rules\RuleErrorBuilder;++/**+ * YoureCom Standard: `getPermission()` is never compared with `true` or `false`. The session returns the raw value of the+ * group option (`'1'`, `1`, `false` …), so `=== true` denies everyone and `=== false` lets everyone through; cast with+ * `(bool)` instead. Replaces the regular expression of check-architecture.php, which missed calls over several lines and+ * arguments with parentheses.+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * @author Marcel Beckers+ * @license YoureCom License - Commercial (YCLC) <https://www.yourecom.de/shop/licence-commercial/>+ * @implements Rule<Expr\BinaryOp>+ */+final class PermissionStrictRule implements Rule+{+ #[\Override]+ public function getNodeType(): string+ {+ return Expr\BinaryOp::class;+ }++ #[\Override]+ public function processNode(Node $node, Scope $scope): array+ {+ if (!$node instanceof Expr\BinaryOp\Identical && !$node instanceof Expr\BinaryOp\NotIdentical) {+ return [];+ }++ foreach ([[$node->left, $node->right], [$node->right, $node->left]] as [$call, $value]) {+ if ($this->isGetPermission($call) && $this->isBoolean($value)) {+ return [+ RuleErrorBuilder::message('getPermission() compared with true/false: it returns the raw group value (\'1\', 1, false), cast with (bool).')+ ->identifier('yourecom.permissionStrict')+ ->build(),+ ];+ }+ }++ return [];+ }++ /**+ * Whether the expression calls a method named getPermission (session, user profile, any object or class).+ */+ private function isGetPermission(Expr $expression): bool+ {+ if (!$expression instanceof Expr\MethodCall && !$expression instanceof Expr\NullsafeMethodCall && !$expression instanceof Expr\StaticCall) {+ return false;+ }++ return $expression->name instanceof Node\Identifier && \strcasecmp($expression->name->toString(), 'getPermission') === 0;+ }++ /**+ * Whether the expression is the constant true or false.+ */+ private function isBoolean(Expr $expression): bool+ {+ return $expression instanceof Expr\ConstFetch && \in_array(\strtolower($expression->name->toString()), ['true', 'false'], true);+ }+}
-
tests/phpstan/SecureActionRule.php<pre>@@ -0,0 +1,186 @@+<?php++namespace yourecom\standard\phpstan;++use PhpParser\Node;+use PhpParser\Node\Expr;+use PhpParser\NodeFinder;+use PHPStan\Analyser\Scope;+use PHPStan\Node\InClassNode;+use PHPStan\Reflection\ClassReflection;+use PHPStan\Rules\Rule;+use PHPStan\Rules\RuleErrorBuilder;++/**+ * YoureCom Standard: a legacy action (`wcf\action\AbstractAction`) that changes something extends+ * `AbstractSecureAction`, which checks the security token (`t={csrfToken type=url}` in links, `{csrfToken}` in forms).+ * The core checks the XSRF header only for PSR-15 controllers (`RequestHandlerInterface`, middleware `Xsrf`), a legacy+ * action without the token can be triggered by any page the user visits (found by an external review in the bank transfer+ * plugin, October 2026).+ *+ * "Changes something": it calls `executeAction()`, creates a command (`…\command\…`), creates or calls a+ * `…Editor`, or prepares an INSERT/UPDATE/DELETE/REPLACE. An action that checks the token itself (`checkSecurityToken()`)+ * is fine, and so is an action that does not act for the signed-in user (no WCF::getUser()/getSession(), no login or+ * permissions required: a server-to-server API with a signature, a webhook). A download whose write is harmless (e.g. a download counter) gets an ignore with the reason.+ * Managed by _dev/standard (apply.php), do not edit here.+ *+ * @author Marcel Beckers+ * @license YoureCom License - Commercial (YCLC) <https://www.yourecom.de/shop/licence-commercial/>+ * @implements Rule<InClassNode>+ */+final class SecureActionRule implements Rule+{+ #[\Override]+ public function getNodeType(): string+ {+ return InClassNode::class;+ }++ #[\Override]+ public function processNode(Node $node, Scope $scope): array+ {+ $class = $node->getClassReflection();+ if (+ !$class->isSubclassOf('wcf\action\AbstractAction')+ || $class->isSubclassOf('wcf\action\AbstractSecureAction')+ || $class->implementsInterface('Psr\Http\Server\RequestHandlerInterface')+ ) {+ return [];+ }++ $finder = new NodeFinder();+ $statements = $node->getOriginalNode()->stmts;+ $checksToken = $finder->findFirst($statements, static fn (Node $found): bool => ($found instanceof Expr\MethodCall || $found instanceof Expr\StaticCall)+ && $found->name instanceof Node\Identifier+ && \strcasecmp($found->name->toString(), 'checkSecurityToken') === 0);+ if ($checksToken !== null) {+ return [];+ }++ // without the session (e.g. a server-to-server API with a signature, a webhook) a forged request gains nothing+ if (!$this->actsForTheUser($class, $statements, $finder)) {+ return [];+ }++ $writes = [];+ foreach ($finder->find($statements, static fn (Node $found): bool => $found instanceof Expr) as $expression) {+ $write = $this->describeWrite($expression);+ if ($write !== null) {+ $writes[$write] = true;+ }+ }+ if ($writes === []) {+ return [];+ }++ return [+ RuleErrorBuilder::message(\sprintf(+ '%s changes data (%s) but does not extend AbstractSecureAction: links need t={csrfToken type=url}, forms {csrfToken}. A harmless write (e.g. a download counter, a signed webhook) gets an ignore with the reason.',+ $class->getDisplayName(),+ \implode(', ', \array_slice(\array_keys($writes), 0, 3)),+ ))+ ->identifier('yourecom.secureAction')+ ->line($node->getOriginalNode()->getStartLine())+ ->build(),+ ];+ }++ /**+ * Whether the action acts for the signed-in user: it reads the user or the session (WCF::getUser(),+ * WCF::getSession(), also in a parent class of the plugin), requires a login or permissions.+ *+ * @param array<Node\Stmt> $statements+ */+ private function actsForTheUser(ClassReflection $class, array $statements, NodeFinder $finder): bool+ {+ $usesSession = static fn (Node $found): bool => $found instanceof Expr\StaticCall+ && $found->class instanceof Node\Name+ && \in_array(\ltrim($found->class->toString(), '\\'), ['wcf\system\WCF', 'WCF'], true)+ && $found->name instanceof Node\Identifier+ && \in_array(\strtolower($found->name->toString()), ['getuser', 'getsession', 'getuserprofilehandler'], true);+ if ($finder->findFirst($statements, $usesSession) !== null) {+ return true;+ }++ $defaults = $class->getNativeReflection()->getDefaultProperties();+ if (($defaults['loginRequired'] ?? false) === true || (\is_array($defaults['neededPermissions'] ?? null) && $defaults['neededPermissions'] !== [])) {+ return true;+ }++ // parent classes of the plugin (not the core's AbstractAction, which reads the session for its permission check)+ foreach ($class->getParents() as $parent) {+ if (\str_starts_with($parent->getName(), 'wcf\action\\')) {+ break;+ }+ $file = $parent->getFileName();+ if ($file !== null && \preg_match('~WCF::get(?:User|Session|UserProfileHandler)\(~', (string)\file_get_contents($file)) === 1) {+ return true;+ }+ }++ return false;+ }++ /**+ * Describes the expression if it changes data, null otherwise.+ */+ private function describeWrite(Expr $expression): ?string+ {+ if ($expression instanceof Expr\MethodCall && $expression->name instanceof Node\Identifier) {+ $method = $expression->name->toString();+ if (\strcasecmp($method, 'executeAction') === 0) {+ return 'executeAction()';+ }+ if (\preg_match('~^prepare(?:Statement|Unmanaged)?$~i', $method) === 1 && isset($expression->args[0]) && $expression->args[0] instanceof Node\Arg) {+ $sql = $this->leadingString($expression->args[0]->value);+ if ($sql !== null && \preg_match('~^\s*(INSERT|UPDATE|DELETE|REPLACE)\b~i', $sql, $match) === 1) {+ return \strtoupper($match[1]) . ' statement';+ }+ }+ }++ if ($expression instanceof Expr\New_ && $expression->class instanceof Node\Name) {+ $name = $expression->class->toString();+ if (\str_contains(\strtolower($name), '\command\\')) {+ return 'command ' . $this->shortName($name);+ }+ if (\str_ends_with($name, 'Editor')) {+ return $this->shortName($name);+ }+ }++ if ($expression instanceof Expr\StaticCall && $expression->class instanceof Node\Name && \str_ends_with($expression->class->toString(), 'Editor')) {+ return $this->shortName($expression->class->toString()) . '::' . ($expression->name instanceof Node\Identifier ? $expression->name->toString() : '…') . '()';+ }++ return null;+ }++ /**+ * The literal start of a string expression (a literal, or the left end of a concatenation or interpolation).+ */+ private function leadingString(Expr $expression): ?string+ {+ while ($expression instanceof Expr\BinaryOp\Concat) {+ $expression = $expression->left;+ }+ if ($expression instanceof Node\Scalar\String_) {+ return $expression->value;+ }+ if ($expression instanceof Node\Scalar\InterpolatedString && ($expression->parts[0] ?? null) instanceof Node\InterpolatedStringPart) {+ return $expression->parts[0]->value;+ }++ return null;+ }++ /**+ * The class name without its namespace.+ */+ private function shortName(string $name): string+ {+ $position = \strrpos($name, '\\');++ return $position === false ? $name : \substr($name, $position + 1);+ }+}
-
tests/phpstan/rules.php<pre>@@ -6,13 +6,20 @@* classes are loaded here, while the configuration is read.*/-require_once __DIR__ . '/OverrideOrDocblockRule.php';+$rules = [+ 'OverrideOrDocblockRule',+ 'PermissionStrictRule',+ 'SecureActionRule',+ 'EndpointRequireAcpRule',+];++foreach ($rules as $rule) {+ require_once __DIR__ . '/' . $rule . '.php';+}return [- 'services' => [- [- 'class' => \yourecom\standard\phpstan\OverrideOrDocblockRule::class,- 'tags' => ['phpstan.rules.rule'],- ],- ],+ 'services' => \array_map(+ static fn (string $rule): array => ['class' => 'yourecom\standard\phpstan\\' . $rule, 'tags' => ['phpstan.rules.rule']],+ $rules,+ ),];