Skip to content

v1.55.0 - Peacock

Latest

Choose a tag to compare

@MichaelSowah MichaelSowah released this 11 Jun 16:25
3f157e7

Theme: Security & correctness hardening. A focused pass over the framework's security-sensitive surfaces (routing/permissions, auth, storage paths, the query write-path, deserialization, and the container/extension boundary). Most items are bug fixes, but several change behavior or defaults -- see Upgrade Notes -- and one (range UPDATE/DELETE predicates) is a new capability, so this ships as a minor. Staying in 1.x per the pre-public policy.

Added

  • Range predicates on UPDATE/DELETE. Two predicates on the same column now both apply on writes -- ->where('id','>',1)->where('id','<',3)->update([...]) / ->delete() updates/deletes only the in-range rows. Previously the write-path condition reparser keyed conditions by column name, so the second predicate silently overwrote the first (the range collapsed to a single bound, affecting more rows than intended). Repeated columns are now folded into a __multi list that the update/delete builders emit AND-joined, preserving binding order. (SELECT was always correct; this only affected UPDATE/DELETE.)

Fixed

  • Security: route permission attributes are now actually enforced (handler_meta was never populated; the gate was never auto-attached). Two defects made #[RequiresPermission] / #[RequiresRole] fail open: (1) GateAttributeMiddleware (the gate_permissions alias) reads the matched handler's attributes from the handler_meta request attribute, but nothing ever set it -- so even when the middleware ran it passed every request through; and (2) unlike #[RequireScope] (which auto-attaches require_scope), nothing auto-attached gate_permissions for the permission/role attributes, so the middleware never ran at all unless the developer added it by hand. An authenticated user therefore cleared permission-gated routes regardless of grants (PermissionManager::can() itself was always fail-closed; it was simply never invoked). The Router now derives handler_meta after route match (supports [Class::class, 'method'], Class::method string, and invokable-class handlers) and sets it before the middleware pipeline runs; AttributeRouteLoader now auto-attaches gate_permissions whenever #[RequiresPermission]/#[RequiresRole] is present on the method or the handler class (so class-level annotations guard every route in the controller). Regression tests prove deny -> 403 end-to-end and the auto-attach for method- and class-level attributes. Behavioral note: routes annotated with permission attributes now genuinely enforce -- apps using such routes (including glueful/users' admin surfaces) without a permission provider bound will now receive 403s instead of silent allows. Bind a provider (e.g. glueful/aegis) or grant the required permissions.
  • Security: signed URLs fail closed when no signing secret is configured. SignedUrl::resolveSecretKey() fell back to a hardcoded constant ('glueful-default-signing-key') when neither uploads.signed_urls.secret/app.key (config) nor SIGNED_URL_SECRET/APP_KEY (env) was set. Since signed URLs gate private blob access, a misconfigured deployment used a publicly known HMAC key -- letting anyone forge expires+signature for any blob. Construction now throws instead of signing or validating with an insecure default.
  • Security: FlysystemStorage::storeContent() now routes writes through PathGuard. The string-content write path called disk()->write() directly, bypassing the traversal / absolute-path / null-byte validation that store() (and the rest of StorageManager) applies -- so a user-influenced destination could escape the configured prefix. Flysystem's own normalizer rejects .. for all adapters, but absolute paths (e.g. /etc/evil) it silently relativizes; on cloud drivers that is a real prefix-escape. Added a PathGuard-validated StorageManager::put(path, contents, disk) (the missing string-content primitive, alongside putStream/putJson) and routed storeContent() through it, restoring the "every write routes through PathGuard" invariant regardless of adapter behavior.
  • Security: #[RequireScope] no longer passes for non-scope-bearing (e.g. JWT) requests. The middleware read granted scopes from the api_key_scopes request attribute, which only the API-key provider sets. A request without that attribute (e.g. JWT-authenticated) produced an empty scope set, and the "empty scopes = unrestricted key" rule then satisfied any #[RequireScope] -- so any authenticated user cleared a scoped route. It now distinguishes the attribute being absent (deny -- not scope-bearing auth) from present-but-empty (an unrestricted API key, still allowed).
  • Security: removed the unverified-JWT claims fallback. AuthToRequestAttributesMiddleware::extractJwtClaims() had a fallback that base64-decoded the JWT payload with no signature verification; those claims feed the gate's ScopeVoter. It was latent (guarded by an always-true class_exists) but a forge-the-scope primitive one edit away -- removed entirely; claims now come only from the signature-verifying JWTService::decode().
  • Security: API key via ?api_key= query string is now off by default. Query strings leak into access logs, proxies, browser history, and Referer. The query-param key source is gated behind security.api_keys.allow_query_param (default false), mirroring the JWT allow_query_param gate; the X-API-Key header is unaffected.
  • Security: operator/identifier allow-listing on raw-interpolated query surfaces. Operators that are interpolated directly into SQL (not bound) are now validated against a fixed allow-list (Glueful\Database\Query\SqlOperators): JOIN operator + join type (JoinClause), HAVING operator (QueryModifiers), and the ORM has()/whereHas() count comparison (ORM\Builder). JSON paths in whereJsonContains() are validated against a conservative path grammar before interpolation, and wrapIdentifier() now doubles embedded quote characters (MySQL backtick / PostgreSQL double-quote) so an identifier cannot terminate its own wrapping. These are developer-facing methods (safe under documented use); the guards harden against an app forwarding request input into them.
  • Security: SecureSerializer namespace auto-allow no longer covers gadget-shaped classes. The unserialize allow-list auto-trusted any class under Glueful\Models\* / \DTOs\* / \Entities\* / \Extensions\*\Models|DTOs\*. There is no live gadget today, but any such class that later gained a __wakeup/__destruct/__unserialize would silently become a cache/queue deserialization gadget. The auto-allow now excludes any class declaring one of those magic methods (it must be explicitly allow-listed instead); plain data classes are unaffected.
  • Security: optional fail-closed CSRF token-generation rate limiter. The limiter fell open (allowed) whenever its cache was absent or errored. That default is preserved (a cache outage should not lock out all token generation), but a stricter posture can now opt into fail-closed (deny) via security.csrf.rate_limit_fail_closed.
  • Container hardening: extension load failures no longer fail silently. A provider whose services()/defs()/tags() threw was caught and error_log'd, then the entire provider was dropped and boot continued -- so a misconfigured extension vanished with no signal (e.g. an entitlement checker silently replaced by core's allow-all default). Now, outside production a load failure is rethrown (wrapped, naming the provider + phase) so it surfaces at boot; in production it is recorded (ContainerFactory::failedProviders(), for extensions:diagnose / health checks) and logged at WARNING, but boot continues so one broken extension cannot take the app down. Mirrors the existing ServiceProvider::loadRoutesFrom() posture.
  • Container hardening: non-instantiable service bindings are rejected at load time. A DSL services() entry whose id is an interface or abstract class with no class/factory/autowire was accepted and only fataled with "Cannot instantiate interface" at first resolution -- possibly in production, on a cold path (this bug class bit several first-party extensions during development). DefaultServicesLoader now rejects it at load, naming the id, with guidance to bind a concrete class or factory. (Also surfaces the inverted-alias footgun -- Interface => ['alias' => Concrete] -- which left the interface bound to new Interface().)
  • Container hardening (minor): clearer container internals. The 'alias' DSL direction is now documented inline on DefaultServicesLoader::collectAliases() (Id => ['alias' => X] makes X resolve to Id, not the reverse -- the footgun behind the bricked extensions); the framework-reserved service ids are consolidated into a single ContainerFactory::RESERVED_KEYS (exposed via reservedKeys()) with a pinReservedDefinitions() helper instead of three scattered statements; a production container-compilation failure now logs at WARNING before falling back to the runtime container (previously a silent degrade to uncompiled); and the empty-tag behavior ($c->get('some.tag') throws NotFoundException when no service contributed to a tag) is documented so consumers of optional extension tags has()-check.
  • FlysystemStorage::exists() / delete() route through PathGuard. Like the storeContent() fix, these passed raw paths straight to the disk adapter; they now go through new PathGuard-validated StorageManager::fileExists() / delete(), so a traversal/absolute path resolves to "does not exist" / "nothing deleted" rather than probing or deleting an unvalidated path.
  • Router reflection cache no longer breaks on object handlers. Router::getReflection() built its cache key by string-concatenating the handler, which threw "Object could not be converted to string" for an [object, method] handler. The key now derives the class name from the object (matching the handler_meta fix). Edge case (such handlers don't survive route caching), but no longer a latent fatal.

Security notes

  • Signed URLs are host-agnostic -- use a per-environment signing secret. SignedUrl HMACs the path + query only (not the host), so a signature is valid on any host sharing the secret. Documented (in SignedUrl and config/uploads.php) that deployments must use a distinct uploads.signed_urls.secret / SIGNED_URL_SECRET / APP_KEY per environment; reusing one APP_KEY across staging/prod enables cross-environment replay.
  • Data integrity: wrong-row UPDATE/DELETE from a duplicate-column WHERE. See the range-predicate item under Added -- the silent predicate collapse could delete/update more rows than the query specified; it now applies both predicates.
  • Data integrity: soft-delete column cache is no longer shared across connections. The "does this table have a deleted_at column?" cache was process-static and keyed by table name alone, so two connections to different databases (e.g. multiple tenants) that share a table name poisoned each other's soft-vs-hard-delete decision -- causing irreversible hard-deletes of would-be-soft rows, soft-deleted rows leaking into reads, or a "no such column" error. The cache key is now namespaced per Connection instance (and includes the configured deleted_at column), so connections cannot poison each other while a single connection still amortizes the schema lookup across its queries.
  • Data integrity: pooled connections are cleaned before reuse. ConnectionPool::release() now rolls back any transaction left open on the underlying PDO (checking the raw PDO, since direct use bypasses the wrapper's tracked flag) and clears per-session state (DISCARD ALL on PostgreSQL, RESET CONNECTION on MySQL; no-op on SQLite/unknown, disabled gracefully if unsupported) before returning the connection to the pool. Previously a borrower that left a transaction open could have its uncommitted write committed by the next borrower, or leak uncommitted/cross-tenant rows. A connection that cannot be cleaned is retired rather than reused. (Connection pooling is opt-in via DB_POOLING_ENABLED, default off.)

Upgrade Notes

Most changes are backward-compatible bug fixes. The following can change behavior for an existing app -- review them before upgrading:

  • #[RequiresPermission] / #[RequiresRole] now actually enforce. These attributes were silently unenforced; routes annotated with them now require the permission/role. A route annotated with a permission attribute but running without a permission provider bound will now return 403 instead of allowing the request. Bind a provider (e.g. glueful/aegis), grant the required permissions, or remove the attribute from routes that should be open. This includes glueful/users' admin surfaces. (#[RequireScope] was already auto-attached and is unaffected by this change, but see the next item.)
  • #[RequireScope] now denies requests that carry no API-key scopes. A scoped route reached by a non-API-key request (e.g. JWT) previously passed (absent scopes were treated as an unrestricted key); it now denies. If you intend JWT users to clear scoped routes, gate those routes differently (scopes are an API-key concept).
  • API key via ?api_key= query string is now OFF by default. If your clients authenticate with the key in the query string, either move them to the X-API-Key header (recommended) or set security.api_keys.allow_query_param = true. The header path is unchanged.
  • Signed URLs fail closed without a secret. If neither uploads.signed_urls.secret / SIGNED_URL_SECRET nor app.key / APP_KEY is configured, generating or validating a signed URL now throws instead of using a hardcoded default key. Configure a signing secret (use a distinct value per environment -- signatures are host-agnostic).
  • Extensions fail loud at boot outside production. A provider whose services()/defs()/tags() throws is now rethrown (naming the provider) outside production, and a DSL service bound to a bare interface/abstract is rejected at load. In production these are logged at WARNING and recorded in ContainerFactory::failedProviders() and boot continues. If you have an extension that was silently failing to register, it will now surface -- fix the binding (a non-instantiable id needs ['class' => Concrete::class] or a factory).
  • New config keys (both optional, secure defaults): security.api_keys.allow_query_param (default false) and security.csrf.rate_limit_fail_closed (default false). No new env vars; no migrations.