Chris
Chris White Web Developer

Hiding Sensitive Arguments in PHP Stack Traces

29 November 2021 ~3 minute read

Sometimes we need to pass around sensitive and secret variable values in our application logic. Take this login code as an example:

1<?php
2 
3class AccessBroker
4{
5 public function login(string $username, string $password): void
6 {
7 // Pretend that we're attempting to log the user in, but something throws an exception.
8 throw new \Exception('Oops, something went wrong!');
9 }
10}
11 
12(new AccessBroker)->login($_POST['username'], $_POST['password']);

I've deliberately kept this framework-agnostic and basic to just show the issue. When the above code is executed, we can clearly see the user's password in the exception's stack trace:

1Fatal error: Uncaught Exception: Oops, something went wrong! in /in/HhjIr:8
2Stack trace:
3#0 /in/HhjIr(13): AccessBroker->login('chris', 'secret password')
4#1 {main}
5 thrown in /in/HhjIr on line 8

This might not strike you as a big deal, after all, you should disable the rendering of stack traces in production. Nobody will ever have the chance to see the value, right? While that may be true, I'd propose that sensitive strings being a single configuration value away from being potentially exposed is taking unnecessary risk. If this exception gets reported to an external service for error monitoring, like Sentry or Bugsnag, you'll also be leaking your user's passwords to somebody else's service. At the very least, you might be leaking sensitive values into your log files.

So what can we do to protect those secrets? Obviously we need to remove the secret value from stack traces. We can do that with a variety of different methods.

zend.exception_ignore_args

PHP 7.4 introduced a new ini configuration value called zend.exception_ignore_args. This will stop rendering a preview of the method arguments in stack traces. Turning that on, our stack trace no longer reveals the user's password:

1Fatal error: Uncaught Exception: Oops, something went wrong! in /in/TYMID:10
2Stack trace:
3#0 /in/TYMID(14): AccessBroker->login()
4#1 {main}
5 thrown in /in/TYMID on line 10

However, this comes with the significant downside that we now won't get a preview of any arguments in the entire stack trace, on any exception thrown by our application. Since most of the arguments we'll be passing around in our applications are not sensitive, this is a dealbreaker in my opinion.

Redact individual arguments manually

We can write an exception handler that can take the stack trace of the exception via $e->getTrace(), iterate over the frames, and redact any arguments that we want to protect. There are plenty of Stack Overflow answers showing you how to do this. The problem with this method is that it's reactive and not proactive. When you start passing another new secret argument around in your application, you need to remember to go back to your exception handler and update it to account for the new argument. In my experience, this rarely happens 🙂

Use an array to hold the secret value

PHP doesn't render array values in a stack trace by default. Using this method our login code from before could look something like this:

1<?php
2 
3class AccessBroker
4{
5 public function login(array $credentials)
6 {
7 // We can use $credentials['username'] and $credentials['password'].
8 
9 // Pretend that we're attempting to log the user in, but something throws an exception.
10 throw new \Exception('Oops, something went wrong!');
11 }
12}
13 
14(new AccessBroker)->login(['username' => 'chris', 'password' => 'secret password']);

and the rendered stack trace would be:

1Fatal error: Uncaught Exception: Oops, something went wrong! in /in/37rTZ:8
2Stack trace:
3#0 /in/37rTZ(13): AccessBroker->login(Array)
4#1 {main}
5 thrown in /in/37rTZ on line 8

This seems to be the method that the Laravel framework has taken - and it's a good one. It does the job of hiding that sensitive argument while keeping the rest, and doesn't require us to write our own exception handling code to do so.

Use an object to wrap the sensitive value

As good as the above method is it has one downside: you no longer get autocompletion or static analysis for the $credentials array inside the login() method because you're passing in a generic PHP array. We can solve that issue by replacing the array with an ObfuscatedValue object that we create ourselves. Our login code from before is now:

1<?php
2 
3class ObfuscatedValue
4{
5 private $value;
6 
7 public function __construct($value)
8 {
9 $this->value = $value;
10 }
11 
12 public function value()
13 {
14 return $this->value;
15 }
16}
17 
18class AccessBroker
19{
20 public function login(string $username, ObfuscatedValue $password)
21 {
22 // We can use $password->value() to get the real password value.
23 
24 // Pretend that we're attempting to log the user in, but something throws an exception.
25 throw new \Exception('Oops, something went wrong!');
26 }
27}
28 
29(new AccessBroker)->login('chris', new ObfuscatedValue('secret password'));

and again, our stack trace hides the password:

1Fatal error: Uncaught Exception: Oops, something went wrong! in /in/Dnf3b:25
2Stack trace:
3#0 /in/Dnf3b(29): AccessBroker->login('chris', Object(ObfuscatedValue))
4#1 {main}
5 thrown in /in/Dnf3b on line 25

Use a library

@julesjanssen pointed out on Twitter that a library exists to do exactly this already called hidden-string. It looks very feature complete and even makes use of a magic method that I didn't know about called __debugInfo(). You can use it in much the same way as what I showed above, and since the library implements a __toString() magic method you don't need to treat $password like an object at all:

1use ParagonIE\HiddenString\HiddenString;
2 
3class AccessBroker
4{
5 public function login(string $username, HiddenString $password)
6 {
7 // We can use $password as if it is a real string because HiddenString has a __toString() method.
8 
9 // Pretend that we're attempting to log the user in, but something throws an exception.
10 throw new \Exception('Oops, something went wrong!');
11 }
12}
13 
14(new AccessBroker)->login('chris', new HiddenString('password'));
Made with Jigsaw and Torchlight. Hosted on Netlify.