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__multilist 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_metawas never populated; the gate was never auto-attached). Two defects made#[RequiresPermission]/#[RequiresRole]fail open: (1)GateAttributeMiddleware(thegate_permissionsalias) reads the matched handler's attributes from thehandler_metarequest attribute, but nothing ever set it -- so even when the middleware ran it passed every request through; and (2) unlike#[RequireScope](which auto-attachesrequire_scope), nothing auto-attachedgate_permissionsfor 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 deriveshandler_metaafter route match (supports[Class::class, 'method'],Class::methodstring, and invokable-class handlers) and sets it before the middleware pipeline runs;AttributeRouteLoadernow auto-attachesgate_permissionswhenever#[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 (includingglueful/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 neitheruploads.signed_urls.secret/app.key(config) norSIGNED_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 forgeexpires+signaturefor 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 calleddisk()->write()directly, bypassing the traversal / absolute-path / null-byte validation thatstore()(and the rest ofStorageManager) 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-validatedStorageManager::put(path, contents, disk)(the missing string-content primitive, alongsideputStream/putJson) and routedstoreContent()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 theapi_key_scopesrequest 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'sScopeVoter. It was latent (guarded by an always-trueclass_exists) but a forge-the-scope primitive one edit away -- removed entirely; claims now come only from the signature-verifyingJWTService::decode(). - Security: API key via
?api_key=query string is now off by default. Query strings leak into access logs, proxies, browser history, andReferer. The query-param key source is gated behindsecurity.api_keys.allow_query_param(default false), mirroring the JWTallow_query_paramgate; theX-API-Keyheader 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),HAVINGoperator (QueryModifiers), and the ORMhas()/whereHas()count comparison (ORM\Builder). JSON paths inwhereJsonContains()are validated against a conservative path grammar before interpolation, andwrapIdentifier()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:
SecureSerializernamespace auto-allow no longer covers gadget-shaped classes. Theunserializeallow-list auto-trusted any class underGlueful\Models\*/\DTOs\*/\Entities\*/\Extensions\*\Models|DTOs\*. There is no live gadget today, but any such class that later gained a__wakeup/__destruct/__unserializewould 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 anderror_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(), forextensions:diagnose/ health checks) and logged at WARNING, but boot continues so one broken extension cannot take the app down. Mirrors the existingServiceProvider::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 noclass/factory/autowirewas 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).DefaultServicesLoadernow rejects it at load, naming the id, with guidance to bind a concrete class or factory. (Also surfaces the inverted-aliasfootgun --Interface => ['alias' => Concrete]-- which left the interface bound tonew Interface().) - Container hardening (minor): clearer container internals. The
'alias'DSL direction is now documented inline onDefaultServicesLoader::collectAliases()(Id => ['alias' => X]makesXresolve toId, not the reverse -- the footgun behind the bricked extensions); the framework-reserved service ids are consolidated into a singleContainerFactory::RESERVED_KEYS(exposed viareservedKeys()) with apinReservedDefinitions()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')throwsNotFoundExceptionwhen no service contributed to a tag) is documented so consumers of optional extension tagshas()-check. FlysystemStorage::exists()/delete()route through PathGuard. Like thestoreContent()fix, these passed raw paths straight to the disk adapter; they now go through new PathGuard-validatedStorageManager::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 thehandler_metafix). 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.
SignedUrlHMACs the path + query only (not the host), so a signature is valid on any host sharing the secret. Documented (inSignedUrlandconfig/uploads.php) that deployments must use a distinctuploads.signed_urls.secret/SIGNED_URL_SECRET/APP_KEYper environment; reusing oneAPP_KEYacross 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_atcolumn?" 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 perConnectioninstance (and includes the configureddeleted_atcolumn), 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 ALLon PostgreSQL,RESET CONNECTIONon 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 viaDB_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 includesglueful/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 theX-API-Keyheader (recommended) or setsecurity.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_SECRETnorapp.key/APP_KEYis 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 inContainerFactory::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(defaultfalse) andsecurity.csrf.rate_limit_fail_closed(defaultfalse). No new env vars; no migrations.