Skip to content

v8.5.51 — the Shield holds; forms, action arguments, services and the Logger simplified (breaking)

Choose a tag to compare

@marcing marcing released this 03 Nov 13:25
· 13 commits to main since this release
v8.5.51
c2e1700

For logging, take v8.5.52 instead. It restores the Logger's query-name and token-path redaction, identity masking as a policy, and keepingIdentities(), looksSecret() and Sender::maskName(), so the callers listed below need no change. Everything else in these notes still applies.

⚠️ Read before upgrading

This release removes public API (see Breaking changes). Code that still calls a removed method fails with Call to undefined method, and an old query tuple fails with a TypeError, once composer update brings it in. Search your code before you update:

grep -rnE "keepingIdentities|maskEmails|looksSecret|addUsernames|getUsernames|addQueryNames|getQueryNames|maskName|CODESAFE_USERNAMES|MASK_(GROUP|MAX)|MASKED_CUT_PATTERN|->as(Text|Number|Integer|Choice|Flag|Collection)\(|addNormalizer|getNormalizers|getGate\(|Normalizer|Validator\\\\Gate|castParam|getCastedParams|bodyIds|Body::ids|->ids\(|->emails?\(|setQueueConnection|(where|andWhere|orWhere|innerJoin|leftJoin|having|select|delete)\(\[" .

Known callers that need a change before they take this tag:

  • CMS\Log\Writer\Codesafe in cms-trunk and leadersnet calls Logger::keepingIdentities(), Logger::looksSecret() and Sender::maskName(). Both require ^8.5, so their next composer update picks this tag up.
  • bo2go's Service\Auth calls Sender::maskName() inside a catch (Throwable): its auth_failure and auth_success reports stop and nothing logs an error.

The Shield kernel

src/Codesafe/Shield/Kernel.php and Wordpress.php are codesafe's client-php kernel byte for byte (sha1 33e46314 and 0a9a5a47). Nothing changes for a host: Service\Codesafe\Shield hands the kernel the same inputs, and the switches are as before.

  • The stored ruleset is sealed. The kernel keeps an HMAC-SHA256 over every field of its record, under a key derived from the project key (Kernel::sealKey()). The kernel treats a record that is not sealed under that key as no record at all, and the next pull fetches the whole ruleset. That covers a record planted in the store file or a shared APCu pool, one changed after the write, and one written by an older kernel. After the upgrade every site pulls its full ruleset once.
  • Padding no longer turns a block into an observe. A condition past a read cap is unread, and a virtual patch whose other entries pin the handler still blocks, with reason unread. Decoding shares one budget per request (Facts::DECODED_BUDGET, 131072 bytes).
  • Form bodies on PUT, PATCH and DELETE are read as WordPress reads them (PHP leaves $_POST empty on those methods). The method judged is the one WordPress's REST server runs, after _method or X-HTTP-Method-Override.
  • Uploads are read whole for a file-content: condition, within Facts::FILE_BUDGET (16 MiB a request). An upload past the budget is unread and blocks on a pinned handler, as every other cut does.
  • A regex the engine gives up on is unread, never a pass. A module or a test that throws costs its own rule, not the whole request.
  • Rules cut past the kernel's caps are reported. Their ids are kept in the sealed record and sent as X-Shield-Dropped.
  • XML-RPC parameters are a target, read as WordPress's IXR reads them. The WordPress module adds wordpress-xmlrpc to X-Shield-Platforms.
  • A site cut off from codesafe keeps its blocking rules. Once the last good pull is older than Kernel::CEILING (24 h), match, rate and virtual-patch rules that block hold until their own expires_at; observe rules and the threat section lapse. A failed pull holds the next one off for Kernel::RETRY (60 s). Reports sent past the ceiling are tagged stale with pulled_at. codesafe's own JSON 401 (a rotated key, a project switched off) lifts every rule.
  • Service\Codesafe\Shield keeps its record on disk even when its folder cannot be created or written, by falling back to the temp dir itself. Past the ceiling the blocking rules hold only while that record exists, and an idle APCu eviction (apc.ttl) empties APCu.

Pair it with codesafe d0f1509a or later, which reads X-Shield-Dropped, stale and pulled_at.

The codesafe Sender

  • A rollup sequence that starts over gets a new instance. When APCu dropped the sequence counter but kept the instance (an apc.ttl idle eviction), the instance sent seq 1 again, and codesafe dropped every following fragment as a retry. The instance id now also carries three random bytes, so a new one made within the same second differs.

⚠️ Breaking changes

Logger: secrets by name, identities raw everywhere

  • Local log files no longer mask e-mail addresses or usernames in the GET and POST data, the only place they were masked there. The copy sent to codesafe was already raw; codesafe masks and vaults identities when they arrive.
  • Redaction goes by field name only: Logger::$remove, extended with addRemove(). A path segment is redacted when the segment before it is a secret name (/token/<value>).
  • No longer redacted: a token-shaped path segment with no secret name before it (/invite/<token>), and the query-only names key, code, sig, pin and hash (wp-login.php?action=rp&key=…). codesafe's own scrubber still drops these when they arrive. The log file wrote the request URI and argv unredacted up to v8.5.50; from this release it redacts them by secret name. A project that needs one adds a pattern with addRemove(), which then applies to field names everywhere.
  • Removed: keepingIdentities(), maskEmails(), addUsernames(), getUsernames(), looksSecret(), addQueryNames(), getQueryNames(), and the constants CODESAFE_USERNAMES, MASK_GROUP, MASK_MAX and MASKED_CUT_PATTERN.
  • Sender::maskName() is removed. Keep the username out of a reportRefusal() message and pass the account in the context (['userId' => …]), where codesafe can recognise it.

Services: boot() and terminate()

  • Service::boot(Application $app) runs for every service in the configuration once all of them are registered, for each application, including a reused instance on a warm worker. An override calls parent::boot($app).
  • Service::terminate() runs after the deferred response callbacks, in configuration order, before the collected events are cleared.
  • Events no longer finds its own writers: the Logger and the codesafe Sender add themselves in boot(). Events::addWriter($writer, $key = null) takes a key, and a keyed writer replaces the previous one under that key. A Logger or Sender that is not in the configured services is never booted and writes nothing. List it there, or call addWriter() yourself.
  • The codesafe Sender flushes in terminate(). Application no longer handles it as a special case.

Controllers: PHP coerces action arguments

  • Controller::castParam() and getCastedParams() are removed. Actions are called through reflection, so PHP's own weak coercion applies. "42" still reaches an int parameter as 42, but a value PHP cannot coerce ("abc" for an int) now raises a TypeError instead of becoming 0.
  • Controller\Api::bodyIds(), Http\Body::ids(), Http\Input::ids(), Input::email() and Input::emails() are removed. Validate ids and addresses with a form; Validator\Each applies one validator to every entry of a list.

Forms: input validators, filters, validators, casts

  • Removed: Element::asText(), asNumber(), asInteger(), asChoice(), asFlag(), asCollection(), getGate(), addNormalizer() and getNormalizers(), plus the classes Form\Normalizer, Form\Normalizer\Callback and Form\Validator\Gate. Validator\Text, Number and Collection extend Validator directly and keep their error codes (type_text, type_number).
  • A value goes through raw input → input validators → filters → validators → casts:
    • addInputValidator() checks the raw value before any filter. A failure stops the field, and its value reads as null.
    • addFilter($filter, each: false) hands the filter the whole array. Filters still run per item by default.
    • addValidator($validator, breakOnFailure: true) skips the field's later rules after a failure. Rules run in the order they are declared.
    • addCast() takes a filter or a callable. Casts run only in getCastValue() and getPresentValues().
  • A filter that returns null no longer fails the field. Add Validator\NotNull where null is not an acceptable value.
  • New: Validator\NotNull, Validator\Each, Filter\Text, Filter\Number and Filter\Flag, plus Filter\Integer(nullable: true), which keeps null as null, and Validator\InArray(nullable:). Element\Text, Number, Flag and Collection set these steps up in their constructors.
// before
$this->period->asInteger('must be a number of minutes')
    ->addValidator(new Validator\Range(1, 10080, nullable: false));

// after
$this->period
    ->addInputValidator((new Validator\Number)->withMessage('must be a number of minutes'))
    ->addValidator(new Validator\Range(1, 10080, nullable: false), breakOnFailure: true)
    ->addValidator(new Validator\WholeNumber)
    ->addCast(new Filter\Integer(nullable: true));

Query builder: Fragment instead of tuples

  • A clause with bound values is now new Fragment($sql, ...$values) (Ovos\Store\Mysql\Query\Fragment), wherever a tuple [$sql, ...$values] used to go: select(), where(), andWhere(), orWhere(), innerJoin(), leftJoin(), having() and QueryBuilder::delete(). An array tuple there is now a TypeError.
  • Any callable opens a nested group, not only a Closure: where([$conditions, 'active']) calls $conditions->active($group).

Cache, container and HTTP requests

  • Cache\Store\KeyValue\Redis::setConnection() and setQueueConnection() are removed. A store keeps the connections its constructor received; create another store for other connections.
  • RedisVersioned::setGroup() with a different group discards the store's local rule state, so one group's rules are never applied to another. Each group keeps its own shared rules in APCu.
  • Container injects a value passed by name as null as null. Before, the null was skipped and the parameter fell back to the container or its default.
  • Stream\Request can be invoked again: each invoke() starts with a fresh response and measurement. setContent() takes a string body and a content type as well as an array, and getContent() returns array|string|null. A body goes out on every method except GET, where an array is still the query string. setJsonContent() builds on setContent(), so setMethod() can turn a JSON POST into a PUT. A read that fails or times out now throws, or leaves a null response under setBestEffort(); before, a timed-out read kept whatever had arrived. getContextOptions() returns the built options, body and Content-Type included.

Also

  • Test\Cache\Store\TraitStoredTime (storedTtlMs(), ageStoredValue()): the cache policy tests age a stored value instead of sleeping.
  • The codesafe Sender's docs name the working-copy pass php cli.php codesafe integrity and the project switch integrity_enabled.

Full changelog: v8.5.50...v8.5.51