Skip to content

fix: a stack trace in a log carries no argument values - #924

Merged
blaipr merged 1 commit into
mainfrom
fix/a-stack-trace-in-a-log-carries-no-argument-values
Sep 17, 2026
Merged

blaipr merged 1 commit into
mainfrom
fix/a-stack-trace-in-a-log-carries-no-argument-values

Conversation

@blaipr

@blaipr blaipr commented Sep 17, 2026

Copy link
Copy Markdown
Member

DatabaseHandler::update() wrote (string)$source into the Eventlog row when the event's source
was a Throwable. PHP's default Exception::__toString() embeds getTraceAsString(), which prints
each frame's argument values — measured, not assumed:

#0 Command line code(3): decryptSecret('SuperSecretMast...', 'an-account-key')

Fifteen characters of every string on the stack. The call chains that throw into this sink include
the crypt and database layers and the LDAP providers — AccountMasterPassword, UserMasterPass,
CustomFieldCrypt, BaseRepository::transactionAware(), the LDAP bind — so a master password, an
account password or a bind credential can be an argument on the way to the throw point. The row is
readable by anyone whose profile has isEvl(), and the event log can be searched and exported.

formatStackTrace() in src/Infrastructure/Functions.php is the same trace with every argument
reduced to its type, and processException() has always used it for exactly this reason. This
sink is the one that did not — the sibling that already gets it right was one file away.

Two things worth knowing, both of which narrow it

  • SPException::__toString() emits no trace at all — it is sprintf('%s: [%s]: %s (%s)', …).
    So the application's own exception type was never the leaky one, which is what makes this hard to
    see when reading the code: what arrives here carrying a trace is a RuntimeException, a
    PDOException, a TypeError or a library's own, and that is precisely the set thrown from inside
    crypt and database calls.
  • It needs exception logging switched on. All 84 Throwable-sourced notifications use the
    event name exception, which is in LogInterface::EVENTS (opt-in) and not EVENTS_FIXED, and
    getLogEvents() defaults to empty. So this is not the out-of-the-box configuration — it is a
    checkbox labelled exception, with no warning attached, that an administrator ticks while
    troubleshooting.

The change

The header each exception renders for itself is kept, and only the trace is replaced. SPException
therefore logs exactly what it logged before — including its hint, which the existing
testUpdateWithSPExceptionMessage pins unchanged — while everything else keeps its
Class: message in file:line header and gets the typed trace.

processException() had the same defect in its previous-exception branch: the line above it uses
formatStackTrace(), and that one called $previous->getTraceAsString(). Same fix, one line. It
writes to var/syspass.log rather than the database, so the audience differs, but the leak is the
same.

Test

testALoggedExceptionCarriesNoArgumentValues throws from a closure taking a secret, and asserts the
stored description contains neither the secret nor the second argument, and still contains the
message and the word String — arguments recorded by type, because withholding them would otherwise
have been achieved just as well by logging nothing.

It pins zend.exception_ignore_args first: whether a trace carries arguments at all is an ini
setting that differs between a development build and a production one, so without pinning it the
test passes locally and proves nothing where the production ini is in force. FunctionsTest already
does this, which is where the technique came from.

Mutation-verified: reverting src/ fails it on the real trace.

DatabaseHandler::update() wrote (string)$source into the Eventlog row for a Throwable, and
PHP's default Exception::__toString() embeds getTraceAsString(), which prints each frame's
argument values — measured, not assumed:

  #0 Command line code(3): decryptSecret('SuperSecretMast...', 'an-account-key')

Fifteen characters of every string on the stack. The chains that throw into this sink
include the crypt and database layers and the LDAP providers, so a master password, an
account password or a bind credential can be an argument on the way to the throw point, and
the row is readable by anyone whose profile has isEvl() and can be searched and exported.

formatStackTrace() is the same trace with every argument reduced to its type, and
processException() has always used it for exactly this reason — this sink was the one that
did not. processException() had the same defect on its previous-exception branch, one line
below where it uses the safe formatter.

Two things narrow it, both worth knowing: SPException::__toString() emits no trace at all,
so the application's own exception type was never the leaky one — what arrives carrying a
trace is a RuntimeException, a PDOException or a library's own, precisely the set thrown
from inside crypt and database calls. And all 84 Throwable-sourced notifications use the
event name 'exception', which is opt-in rather than in EVENTS_FIXED.

The header each exception renders for itself is kept and only the trace is replaced, so
SPException logs exactly what it logged before, hint included.
@blaipr
blaipr merged commit e64a111 into main Sep 17, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-stack-trace-in-a-log-carries-no-argument-values branch September 17, 2026 01:16
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