rfc:redact_parameters_in_back_traces

This is an old revision of the document!


PHP RFC: Redacting parameters in back traces

Introduction

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:

PDOException: SQLSTATE[HY000] [2002] No such file or directory in /var/www/html/test.php:3
Stack trace:
#0 /var/www/html/test.php(3): PDO->__construct('mysql:host=loca...', 'root', 'password')
#1 {main}

This will lead to these sensitive parameters being logged within the application's error log and they might also be shipped to an external error tracking service if the application includes such an exception handler.

Ultimately, this might even leak an user's password in plain text if the password verification throws an Exception, violating privacy laws and putting the user at risk.

Proposal

To prevent these sensitive parameters from appearing within a stack trace this RFC proposes a new \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 \SensitiveParameter object.

Choice of replacement value

This RFC proposes a \SensitiveParameter 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 \SensitiveParameter 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 \SensitiveParameter check.

Examples

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(SensitiveParameter), '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(SensitiveParameter), '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(SensitiveParameter), '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(SensitiveParameter), Object(SensitiveParameter), Object(SensitiveParameter))
#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(SensitiveParameter), 'baz')
#1 test.php(19): test2(Object(SensitiveParameter), '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(SensitiveParameter), '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 \SensitiveParameter);
    \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:"SensitiveParameter":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(SensitiveParameter), '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(SensitiveParameter)#1 (0) {
      }
      [2]=>
      string(3) "baz"
    }
  }
}
*/

Why existing features are insufficient

zend.exception_ignore_args

zend.exception_ignore_args completely omits parameters in back traces. This is not a useful alternative:

  • The stack trace parameters are just too useful for debugging to completely strip them.

zend.exception_string_param_max_len

zend.exception_string_param_max_len configures the length of string parameters in back traces. This is not a useful alternative:

  • Many sensitive values might already be fully exposed before they are truncated. This specifically includes end-user credentials which tend to be low-entropy and shortish.

Backward Incompatible Changes

1. The \SensitiveParameter 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 \SensitiveParameter, 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 \SensitiveParameter check is considered trivial and will not break compatibility with older PHP versions.

Proposed PHP Version(s)

Next PHP 8.x.

RFC Impact

To SAPIs

None.

To Existing Extensions

Extensions should verify any existing parameters and add the \SensitiveParameter attribute for parameters deemed sensitive.

To Opcache

Probably none? [to be discussed]

New Constants

None.

php.ini Defaults

None.

Open Issues

  1. The prototype patch does not yet add the \SensitiveParameter attribute to any internal functions.

Unaffected PHP Functionality

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(SensitiveParameter) in place of a real parameter.

Future Scope

None.

Proposed Voting Choices

Add the \SensitiveParameter attribute and redact values in back traces for parameters having this attribute?

Patches and Tests

Prototype patch: https://github.com/php/php-src/pull/7921

We would need assistance by a more experienced developer in cleaning up the implementation and adding this attribute to existing functions.

Implementation

n/a

References

Rejected Features

Changelog

  • 1.1: Clarified language, justifying the choice of replacement value, Closure example, Keypair example, debug_backtrace example.
rfc/redact_parameters_in_back_traces.1642064843.txt.gz · Last modified: 2022/01/13 09:07 by timwolla