PHP's stack traces in exceptions are very useful for debugging, because they include the original parameters for each stack frame, allowing the developer to see exactly what data is passed to a function call. Unfortunately sometimes this verbosity is a drawback. Specifically when sensitive values, such as credentials, are passed to a function and some other function deep down the call stack throws an Exception.
One common “offender” is PDO which takes the database password as a constructor parameter and immediately attempts to connect to the database within the constructor, instead of having a pure constructor and a separate ->connect()
method. Thus when the database connection fails the stack trace will include the database password:
While exposing the database password certainly should be avoided, any exposed passwords can usually quickly be rotated and the risk reduced by not connecting the database to the Internet.
However passing secrets into function that might fail is not limited to the internal database connection of the application. The application itself commonly handles many more kinds of secrets that are often directly related to a single user, putting them at risk in case of exposure. For instance the PDO example can happen likewise when validating the user's password while the authentication service (e.g. LDAP) is unavailable, exposing the password in a validatePassword(string $password): bool
method. During the checkout within an e-commerce application, a credit card number might be part of a stack frame when the backend service times out during credit card validation.
Even if the application follows best practices by not exposing raw error messages to the visitor, the error messages, including their stack trace, will usually end up within the application's error log. These error logs are then commonly shipped to a log analysis or error tracking services and in many cases these services are provided by a third party as a SaaS offering. Sending plaintext passwords, credit card numbers or other personally identifying information to such a log analysis service might put the operator in a violation of their respective privacy laws.
Not having a standardized solution to parameter redaction makes it hard for userland exception handlers to reliably scrub stack traces, because the handler will need to guess which parameters might or might not hold sensitive values. Furthermore it requires reimplementing the scrubbing logic within every library, framework or application.
To prevent these sensitive parameters from appearing within a stack trace this RFC proposes a new standardized \SensitiveParameter
attribute that can be applied to a function's parameter to indicate that the parameter contains sensitive information that must not appear in back traces.
To reliably apply this protection for all types of back traces and all types of exception and error handlers, the redaction should happen when collecting the parameter values during back trace generation. Specifically when the backtrace is generated, any parameter that has a \SensitiveParameter
attribute will not have its value stored in the backtrace, but instead will be replaced with a \SensitiveParameterValue
object.
This RFC proposes a \SensitiveParameterValue
object as the replacement value, instead of something simpler such as a '[redacted]'
string.
While strings are likely the most commonly encountered type of sensitive parameter, some sensitive values might also be passed as an object that might get serialized and then shipped to a logging service (e.g. a Keypair
object) within an exception handler. For parameters that are type-hinted to take a specific argument it is generally not possible to generically construct a replacement value that does not violate the type-hint.
For this reason, the replacement value will need to violate the type-hint for at least some of the parameters the attribute is applied to. Using a \SensitiveParameterValue
object will almost certainly violate a type hint, but it allows userland code to reliably detect the difference between a real value and a parameter that was redacted by using an $foo instanceof \SensitiveParameterValue
check.
Furthermore the replacement object will store the original value, allowing it to retrieve it on explicit request, while making it hard to accidentally expose it.
The userland equivalent of the \SensitiveParameterValue
class is:
<?php final class SensitiveParameterValue { public function __construct(private readonly mixed $value) {} public function getValue(): mixed { return $value; } /* Hide the value from var_dump(). */ public function __debugInfo(): array { return []; } /* Hide the value from serialization. */ public function __serialize(): array { return []; } /* Prevent unserialization, as the stored value cannot round-trip. */ public function __unserialize(array $data): void { throw new \Exception('...'); } }
Simple example with a single sensitive parameter:
<?php function test( $foo, #[\SensitiveParameter] $bar, $baz ) { throw new \Exception('Error'); } test('foo', 'bar', 'baz'); /* Fatal error: Uncaught Exception: Error in test.php:8 Stack trace: #0 test.php(11): test('foo', Object(SensitiveParameterValue), 'baz') #1 {main} thrown in test.php on line 8 */
Named parameters:
<?php function test( $foo = null, #[\SensitiveParameter] $bar = null, $baz = null ) { throw new \Exception('Error'); } test( baz: 'baz', bar: 'bar', ); /* Fatal error: Uncaught Exception: Error in test.php:8 Stack trace: #0 test.php(13): test(NULL, Object(SensitiveParameterValue), 'baz') #1 {main} thrown in test.php on line 8 */
Omitted default parameter:
<?php function test( $foo = null, #[\SensitiveParameter] $bar = null, $baz = null ) { throw new \Exception('Error'); } test(baz: 'baz'); /* Fatal error: Uncaught Exception: Error in test.php:8 Stack trace: #0 test.php(11): test(NULL, Object(SensitiveParameterValue), 'baz') #1 {main} thrown in test.php on line 8 */
Variadic parameters:
<?php function test( $foo, #[\SensitiveParameter] ...$bar ) { throw new \Exception('Error'); } test('foo', 'bar1', 'bar2', 'bar3'); /* Fatal error: Uncaught Exception: Error in test.php:7 Stack trace: #0 test.php(10): test('foo', Object(SensitiveParameterValue), Object(SensitiveParameterValue), Object(SensitiveParameterValue)) #1 {main} thrown in test.php on line 7 */
Nested function calls:
<?php function test( $foo, #[\SensitiveParameter] $bar, $baz ) { throw new \Exception('Error'); } function test2( #[\SensitiveParameter] $foo, $bar, $baz ) { test($foo, $bar, $baz); } test2('foo', 'bar', 'baz'); /* Fatal error: Uncaught Exception: Error in test.php:8 Stack trace: #0 test.php(16): test('foo', Object(SensitiveParameterValue), 'baz') #1 test.php(19): test2(Object(SensitiveParameterValue), 'bar', 'baz') #2 {main} thrown in test.php on line 8 */
Closures:
<?php $test = function ( $foo, #[\SensitiveParameter] $bar, $baz ) { throw new \Exception('Error'); }; $test('foo', 'bar', 'baz'); /* Fatal error: Uncaught Exception: Error in test.php:8 Stack trace: #0 test.php(11): {closure}('foo', Object(SensitiveParameterValue), 'baz') #1 {main} thrown in test.php on line 8 */
Processing Stack Traces:
<?php function test( $foo, #[\SensitiveParameter] $bar, $baz ) { throw new \Exception('Error'); } try { test('foo', 'bar', 'baz'); echo 'Not reached'; } catch (\Exception $e) { echo $e->getMessage(); $testFrame = $e->getTrace()[0]; \assert($testFrame['function'] === 'test'); \assert($testFrame['args'][0] === 'foo'); \assert($testFrame['args'][1] instanceof \SensitiveParameterValue); // Explicitly retrieve the original value. \assert($testFrame['args'][1]->getValue() === 'bar'); \assert($testFrame['args'][2] === 'baz'); } /* Error */
Non-string arguments:
<?php class Keypair { private $publicKey; private $privateKey; public function __construct($publicKey, $privateKey) { $this->publicKey = $publicKey; $this->privateKey = $privateKey; } } function test( Keypair $foo, #[\SensitiveParameter] Keypair $bar ) { throw new \Exception('Error'); }; try { test( new Keypair('public1', 'private1'), new Keypair('public2', 'private2') ); } catch (\Exception $e) { // Send the exception to a logging service. echo serialize($e->getTrace()); } /* (Formatting for readability) a:1:{ i:0; a:4:{ s:4:"file"; s:8:"test.php"; s:4:"line"; i:24; s:8:"function"; s:4:"test"; s:4:"args"; a:2:{ i:0; O:7:"Keypair":2:{ s:18:"KeypairpublicKey"; s:7:"public1"; s:19:"KeypairprivateKey"; s:8:"private1"; } i:1; O:18:"SensitiveParameterValue":0:{} } } } */
debug_print_backtrace / debug_backtrace:
<?php function test( $foo, #[\SensitiveParameter] $bar, $baz ) { debug_print_backtrace(); var_dump(debug_backtrace()); } test('foo', 'bar', 'baz'); /* #0 test.php(12): test('foo', Object(SensitiveParameterValue), 'baz') array(1) { [0]=> array(4) { ["file"]=> string(8) "test.php" ["line"]=> int(12) ["function"]=> string(4) "test" ["args"]=> array(3) { [0]=> string(3) "foo" [1]=> object(SensitiveParameterValue)#1 (0) { } [2]=> string(3) "baz" } } } */
zend.exception_ignore_args completely omits parameters in back traces. This is not a useful alternative:
zend.exception_string_param_max_len configures the length of string parameters in back traces. This is not a useful alternative:
The paragonie/hidden-string library attempts to solve the same problem of sensitive parameters appearing in stack traces. While this attempt works with functions that specifically expect such a HiddenString
to be passed, it will not work with parameters that are type-hinted to take a string
. Either the underlying string needs to be explicitly extracted, or __toString()
support needs to be enabled within the library. In both cases the scalar string will appear in the stack trace.
Furthermore such a wrapper class is limited to string values and cannot easily protect other types of sensitive values.
1. The \SensitiveParameter
and \SensitiveParameterValue
class name will no longer be available to userland code.
This is very unlikely to break existing code. The class name is fairly specific and GitHub's search for SensitiveParameter
in PHP code only returns 6 results, all of them strings:
https://github.com/search?l=PHP&q=SensitiveParameter&type=Code
2. Custom exception handlers might see objects of class \SensitiveParameterValue
, despite the parameter having a different type within the method's signature.
Clearly indicating any redacted parameters is considered to outweight this minor BC break. It is unlikely that an exception handler would use reflection to learn about the parameter type and then validate the passed value. In any case updating the exception handler to include an $foo instanceof \SensitiveParameterValue
check is considered trivial and will not break compatibility with older PHP versions.
Next PHP 8.x.
None.
Extensions should verify any existing parameters and add the \SensitiveParameter
attribute for parameters deemed sensitive.
Debuggers might be affected. Changes might be required to expose the original values during a debugging session, e.g. when stepping through the code.
None.
None.
None.
None.
This RFC only affects the collected arguments within a back trace. Unless the back trace is processed programmatically, the only change is that a developer will notice is that some error messages show Object(SensitiveParameterValue) in place of a real parameter.
None.
Add the \SensitiveParameter
attribute and replace parameters having this attribute in back traces by \SensitiveParameterValue
?
Voting started on 2022-02-09. Voting runs until 2022-02-23 at 13:30 UTC.
Prototype patch: https://github.com/php/php-src/pull/7921
During code review it was noticed that the proposed serialization behavior of \SensitiveParameterValue
was not useful:
Compared to the proposal a userland implementation of \SensitiveParameterValue
class would look like the following:
<?php final class SensitiveParameterValue { public function __construct(private readonly mixed $value) {} public function getValue(): mixed { return $value; } /* Hide the value from var_dump(). */ public function __debugInfo(): array { return []; } /* Prevent serialization. */ public function __serialize(): array { throw new \Exception('...'); } /* Prevent unserialization. */ public function __unserialize(array $data): void { throw new \Exception('...'); } }
Note that the __serialize()
and __unserialize()
methods are not actually implemented. Serialization is prevented using a flag on the internal class implementation.
This was merged into PHP 8.2 in https://github.com/php/php-src/commit/90851977348cbb8c65fce19a1670868422414fae, based on the PR https://github.com/php/php-src/pull/7921.
The attribute was applied to existing functions in https://github.com/php/php-src/pull/8352.