Skip to content

Security hardening across query builder, storage, auth, mail and crypto - #430

Merged
papac merged 1 commit into
5.xfrom
refactor/code-base
Oct 4, 2026
Merged

papac merged 1 commit into
5.xfrom
refactor/code-base

Conversation

@papac

@papac papac commented Oct 4, 2026

Copy link
Copy Markdown
Member

Summary

One commit fixing a set of security issues found across the framework, each with a regression test. No public API is removed; one opt-in method is added (Crypto::allowLegacy()).

Fixes

Database QueryBuilder (SQL injection)

  • whereBetween() / whereNotBetween() concatenated both range bounds into the SQL. They are now ? placeholders bound through where_data_binding.
  • Column and table identifiers are validated through assertSafeIdentifier() in where, whereNull, whereNotNull, whereIn, whereNotIn, whereBetween, whereNotBetween, join / leftJoin / rightJoin (table and both sides of the on), select, distinct, aggregates, increment / decrement, and the keys of insert and update. Only a plain or table-qualified name is accepted. * and table.* are allowed only for select, distinct and aggregates. groupBy, having and orderBy already used the check and now call it as an instance method.

Storage DiskFilesystemService::path() (path traversal)

  • The old prefix check was an unescaped regex built from the base directory. The path is now joined to the base with a plain prefix check, any .. segment is rejected before touching the disk, and the resolved path (or its parent for files that don't exist yet) must stay under the base directory, which also blocks symlink escapes. Violations throw ResourceException.

Mail Envelop (header / SMTP injection, CWE-93)

  • withHeader(), subject(), from(), addBcc(), addCc(), addReplyTo(), addReturnPath() and returnPath() strip CR, LF and NUL from every header-bound value.

CsrfMiddleware (bypass and timing)

  • Both the AJAX header path and the form _token path previously used loose comparison, so an unset session token (null) matched an absent token. Empty session tokens are now rejected and comparison uses hash_equals().

SessionGuard + Session::regenerate() (session fixation)

  • The session id is rotated on attempts() success and on login(). Session::regenerate() now relies on session_regenerate_id(true) alone, which deletes the old record while preserving $_SESSION, instead of flushing and restarting, so the authenticated user survives the rotation.

Crypto::decrypt() (unauthenticated ciphertext)

  • Input without the BOW2: authenticated header now fails closed and returns false. The legacy static-IV, no-MAC format can be re-enabled explicitly with Crypto::allowLegacy(true) while migrating old ciphertexts.

HTTP

  • Response::download() strips directory components, CR, LF and quotes from the Content-Disposition filename and quotes it. UploadedFile::moveTo() applies basename() to an explicit target filename.

Tests added

  • QueryBuilderTest: unsafe where column is rejected; whereBetween binds both bounds (checked against a hostile upper bound).
  • DiskFilesystemTest: path('../../x') throws; get('../secret') cannot read a file outside the base directory.
  • SecurityTest: decrypt() fails closed on a legacy ciphertext unless allowLegacy() is on.
  • EnvelopHeaderInjectionTest: header-bound values with CR/LF cannot inject headers.

Verification

Ran the regression tests listed above locally:

vendor/bin/phpunit --filter 'test_where_rejects_unsafe_column_identifier|test_where_between_binds_both_bounds|test_path_rejects_traversal|test_get_does_not_leak_outside_base_directory|test_decrypt_fails_closed|EnvelopHeaderInjectionTest'
OK (9 tests, 36 assertions)

Behaviour changes to be aware of

  • Any code passing a raw expression as a column name to the query builder (for example where('1=1 ...') or select('count(*) as total')) will now throw QueryBuilderException. Use a subquery QueryBuilder for expressions.
  • Crypto::decrypt() returns false for pre-BOW2: ciphertexts unless Crypto::allowLegacy(true) is called.
  • DiskFilesystemService methods throw ResourceException for paths outside the base directory instead of silently operating on them.

- Database\QueryBuilder: bind whereBetween/whereNotBetween range values
  (SQL injection) and validate column/table identifiers in where*, join,
  select, aggregate, distinct, increment and insert/update keys through
  assertSafeIdentifier (wildcards allowed only where legitimate).
- Storage\DiskFilesystemService::path(): confine resolved paths to the base
  directory and reject traversal (arbitrary read/write/delete).
- Mail\Envelop: strip CR/LF/NUL from header-bound values (header/SMTP
  injection, CWE-93).
- Middleware\CsrfMiddleware: reject empty tokens and compare with hash_equals
  (null==null bypass and timing).
- Auth\SessionGuard + Session::regenerate(): rotate the session id on login
  while preserving data (session fixation).
- Security\Crypto::decrypt(): fail closed on non-authenticated legacy
  (static-IV) input unless explicitly opted in via allowLegacy().
- Http: sanitize download filename; basename() the explicit upload target.

Adds regression tests for the above.
@papac
papac merged commit 0249565 into 5.x Oct 4, 2026
1 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant