Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas
- `RequestContext` holds the client address and the AJAX and HTTPS flags for each decision, and `Injector::isHtml()` tells if a response is an HTML page. [#62](https://github.com/phalcon/debugbar/issues/62)
- `Security\Redactor::redactUri()` applies the redaction rule to the query of a URI. [#62](https://github.com/phalcon/debugbar/issues/62)
- A shared fixture, `tests/support/assets/DebugBar/panel-contract.json`, makes the PHP and JavaScript tests use the same payload. [#62](https://github.com/phalcon/debugbar/issues/62)
- `redact.unmask` takes patterns for masked keys whose values show, and `Security\Redactor` takes them as an optional third argument. A pattern matches the whole key, and `*` matches zero or more characters. A hidden key stays hidden. [#67](https://github.com/phalcon/debugbar/issues/67)

### Changed

Expand All @@ -41,13 +42,15 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas
- `DebugBarTypes` holds the wire contract and the config shapes, and it lists the panel names, so static analysis rejects an unknown panel name. [#62](https://github.com/phalcon/debugbar/issues/62)
- The debug page in ADR applications uses `Injector::isHtml()` and no longer depends on the injection rules of the bar. [#62](https://github.com/phalcon/debugbar/issues/62)
- `DebugBarTypes` lists the icon names as `icon_type`, so static analysis rejects an unknown `indicator.icon`. A test keeps the list equal to the drawings in `debugbar.js`. An indicator with an unknown icon name now shows no icon, not the gears. [#64](https://github.com/phalcon/debugbar/issues/64)
- `redact.mask` and `redact.hidden` also match the word parts of a key. The redactor splits a key at `_`, `-`, `.` and camelCase, so `token` now masks `reset_token` and `accessToken`. Some values that showed before now show `***`. [#67](https://github.com/phalcon/debugbar/issues/67)

### Fixed

- History: check storage at boot, remove empty browser directories, fix glob paths on Windows, add `nosniff` to JSON responses.
- The browser asset exports test helpers only outside a DOM, and no longer hard-codes the request collector name.
- The `request` panel and request history no longer show or store a query value under a masked key in the URI. [#62](https://github.com/phalcon/debugbar/issues/62)
- The documentation states the effect of a long-lived worker on request history. [#62](https://github.com/phalcon/debugbar/issues/62)
- The documentation states that `access.callback` can run more than once for a request. [#66](https://github.com/phalcon/debugbar/issues/66)

## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14)

Expand Down
13 changes: 9 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ The second argument to `Provider` is a nested array. Every key is optional. `Pha
| `history.ttl_seconds` | `int` | `86400` | Lifetime in seconds; active-browser entries are checked immediately. |
| `redact.hidden` | `list<string>` | `[]` | Keys dropped from the output entirely. |
| `redact.mask` | `list<string>` | `[]` | Extra keys whose values are masked (added to the defaults). |
| `redact.unmask` | `list<string>` | `[]` | Patterns for masked keys whose values show. `*` matches any characters. |

```php
<?php
Expand All @@ -329,7 +330,7 @@ use Phalcon\DebugBar\Provider;
'max_browsers' => 10,
'ttl_seconds' => 86400,
],
'redact' => ['mask' => ['api_key'], 'hidden' => ['secret_question']],
'redact' => ['mask' => ['iban'], 'hidden' => ['secret_question'], 'unmask' => ['primary_key']],
]))->boot();
```

Expand Down Expand Up @@ -494,7 +495,7 @@ Debug::getBar()?->addCollector(new OrdersCollector());

`addCollector()` calls `subscribe()` when the bar has an events manager. A collector with no events does not implement `Subscriber`, and you add it the same way. A collector with the name of a built-in collector replaces it. The bridges do not feed the replacement, so it must attach its own listeners.

The bar does not redact the data of a custom collector. Run sensitive data through `Phalcon\DebugBar\Security\Redactor` in the collector. The collector does not receive the `redact.mask` and `redact.hidden` values, so give them to its `Redactor`.
The bar does not redact the data of a custom collector. Run sensitive data through `Phalcon\DebugBar\Security\Redactor` in the collector. The collector does not receive the `redact.mask`, `redact.hidden` and `redact.unmask` values, so give them to its `Redactor`.

## Sharing the Events Manager

Expand Down Expand Up @@ -598,13 +599,17 @@ use Phalcon\DebugBar\Provider;
- **Masked** keys keep their key but replace the value with `***`.
- **Hidden** keys are dropped entirely.

The default masked keys are `authorization`, `cookie`, `csrf`, `key`, `password`, `secret`, and `token`. The redactor matches whole key names, so `token` does not match `reset_token`. Add other keys to `redact.mask`.
A key matches an entry when it has the same name, or when it contains the word parts of the entry next to each other. The redactor splits a name into word parts at `_`, `-`, `.` and camelCase. For example, `token` matches `reset_token` and `accessToken`, and `card_number` matches `customer_card_number`. `token` does not match `tokens`, and `key` does not match `monkey`.

The default masked keys are `authorization`, `cookie`, `csrf`, `key`, `password`, `secret`, and `token`. Add other keys to `redact.mask`, and keys to drop to `redact.hidden`.

A word part can mask a key that is not sensitive, such as `primary_key`. To show its value, add a pattern to `redact.unmask`. A pattern must match the whole key, and the match is not case-sensitive. `*` matches zero or more characters, and each other character matches only itself. For example, `card` matches only `card`, `card*` also matches `card_number`, and `*card*` also matches `customer_card_number` and `discard`. A pattern does not use word parts. A hidden key stays hidden, even when a pattern matches it. A pattern of `*` alone shows all masked values.

The `request`, `config`, `session`, and `logger` collectors run their data through the redactor. The `request` panel and request history also redact the query of the URI. When the redactor changes a value in a query, it rebuilds the query, so the encoding of the other values can change. The `session` collector also omits the raw session id and every `$PHALCON/*` internal key, including the CSRF token.

The `database` panel and request history show and store query bindings as they are, because generated names such as `:APL0` do not match a key. The redactor does not change log messages, exception messages, or stack traces.

`Phalcon\DebugBar\Security\AccessGate` decides who receives the bar within an allowed environment. An empty IP allowlist admits any client. When both an allowlist and a callback are given, both must pass.
`Phalcon\DebugBar\Security\AccessGate` decides who receives the bar within an allowed environment. An empty IP allowlist admits any client. When both an allowlist and a callback are given, both must pass. The callback can run more than once for a request, for example for a server error in an ADR application with `debug_page` on. Do not put side effects in the callback.

```php
<?php
Expand Down
3 changes: 2 additions & 1 deletion src/DebugBar/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ public function __construct(array $config = [])
$this->historyOptions = HistoryOptions::fromConfig($config['history'] ?? []);
$this->redactor = new Redactor(
[...Redactor::DEFAULT_KEYS, ...($redact['mask'] ?? [])],
$redact['hidden'] ?? []
$redact['hidden'] ?? [],
$redact['unmask'] ?? []
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/DebugBar/DebugBarTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
* debug_page?: bool|\Phalcon\Debug,
* headers?: bool,
* history?: history_config,
* redact?: array{mask?: list<string>, hidden?: list<string>}
* redact?: array{mask?: list<string>, hidden?: list<string>, unmask?: list<string>}
* }
*/
final class DebugBarTypes
Expand Down
125 changes: 105 additions & 20 deletions src/DebugBar/Security/Redactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,33 @@

namespace Phalcon\DebugBar\Security;

use function array_map;
use function array_slice;
use function count;
use function explode;
use function http_build_query;
use function in_array;
use function implode;
use function is_array;
use function mb_strtolower;
use function parse_str;
use function preg_match;
use function preg_quote;
use function preg_split;
use function rawurlencode;
use function str_replace;
use function strpos;
use function substr;

use const PREG_SPLIT_NO_EMPTY;

/**
* Guards data before it leaves PHP, matching keys case-insensitively through
* nested arrays. Sensitive keys are masked (key shown, value replaced); "hidden"
* keys are dropped entirely - mirroring the blacklist `Debug` already applies.
* Applied by the Request/Session/Config/Logger collectors, and to the URI that
* request history stores.
* Guards data before it leaves PHP. It looks at the keys through nested arrays, and
* the match is not case-sensitive. A key matches an entry when it has the same name,
* or when it has the word parts of the entry next to each other. The word parts come
* from `_`, `-`, `.` and camelCase. A hidden key is dropped. A masked key keeps its
* name and its value becomes `***`, but an unmask pattern that matches the whole key
* stops the mask. The Request, Session, Config and Logger collectors use it, and so
* does the URI that request history stores.
*/
final class Redactor
{
Expand All @@ -45,23 +56,36 @@ final class Redactor
public const MASK = '***';

/**
* @var list<string>
* Splits on `_`, `-` and `.`, before an upper case letter that follows a lower
* case letter or a digit, and between an acronym and the next word.
*/
private const PARTS = '/[_.\-]+|(?<=[a-z\d])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/';

/**
* @var list<array{0: string, 1: list<string>}>
*/
private array $hidden;

/**
* @var list<string>
* @var list<array{0: string, 1: list<string>}>
*/
private array $keys;

/**
* @var list<string>
*/
private array $unmask;

/**
* @param list<string> $keys
* @param list<string> $hidden
* @param list<string> $unmask
*/
public function __construct(array $keys = self::DEFAULT_KEYS, array $hidden = [])
public function __construct(array $keys = self::DEFAULT_KEYS, array $hidden = [], array $unmask = [])
{
$this->keys = $this->lower($keys);
$this->hidden = $this->lower($hidden);
$this->keys = $this->entries($keys);
$this->hidden = $this->entries($hidden);
$this->unmask = array_map($this->pattern(...), $unmask);
}

/**
Expand All @@ -75,12 +99,13 @@ public function redact(array $data): array
foreach ($data as $key => $value) {
if (is_string($key)) {
$lower = mb_strtolower($key);
$parts = $this->parts($key);

if (in_array($lower, $this->hidden, true)) {
if ($this->matches($lower, $parts, $this->hidden)) {
continue;
}

if (in_array($lower, $this->keys, true)) {
if ($this->matches($lower, $parts, $this->keys) && !$this->isUnmasked($lower)) {
$result[$key] = self::MASK;

continue;
Expand Down Expand Up @@ -117,17 +142,77 @@ public function redactUri(string $uri): string
}

/**
* @param list<string> $keys
* @param list<string> $names
*
* @return list<string>
* @return list<array{0: string, 1: list<string>}>
*/
private function lower(array $keys): array
private function entries(array $names): array
{
$lowered = [];
foreach ($keys as $key) {
$lowered[] = mb_strtolower($key);
$entries = [];
foreach ($names as $name) {
$entries[] = [mb_strtolower($name), $this->parts($name)];
}

return $lowered;
return $entries;
}

private function isUnmasked(string $lower): bool
{
foreach ($this->unmask as $pattern) {
if (1 === preg_match($pattern, $lower)) {
return true;
}
}

return false;
}

/**
* @param list<string> $parts
* @param list<array{0: string, 1: list<string>}> $entries
*/
private function matches(string $lower, array $parts, array $entries): bool
{
foreach ($entries as [$name, $needle]) {
if ($lower === $name) {
return true;
}

$last = count($parts) - count($needle);
for ($index = 0; $index <= $last; $index++) {
if (array_slice($parts, $index, count($needle)) === $needle) {
return true;
}
}
}

return false;
}

/**
* Returns the word parts of a name in lower case. A name with no word part, such
* as `_`, is one part.
*
* @return list<string>
*/
private function parts(string $name): array
{
$parts = preg_split(self::PARTS, $name, -1, PREG_SPLIT_NO_EMPTY) ?: [$name];

return array_map(mb_strtolower(...), $parts);
}

/**
* Returns a pattern that matches the whole key in lower case. `*` matches zero or
* more characters. Each other character matches only itself.
*/
private function pattern(string $entry): string
{
$literals = array_map(
static fn (string $literal): string => preg_quote($literal, '/'),
explode('*', mb_strtolower($entry))
);

return '/\A' . implode('.*', $literals) . '\z/s';
}
}
14 changes: 8 additions & 6 deletions tests/Unit/DebugBar/CoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,15 @@ public function testConfigSourceReachesTheCollectorRedacted(): void
$_ENV[self::ENV_VAR] = 'dev';
$config = new Config([
'section' => [
'password' => 'sekret',
'maskme' => 'mask-value',
'dropme' => 'drop-value',
'plain' => 'keepme',
'password' => 'sekret',
'maskme' => 'mask-value',
'dropme' => 'drop-value',
'plain' => 'keepme',
'primary_key' => 'id',
],
]);

$this->core(['redact' => ['mask' => ['maskme'], 'hidden' => ['dropme']]])
$this->core(['redact' => ['mask' => ['maskme'], 'hidden' => ['dropme'], 'unmask' => ['primary_key']]])
->boot(null, null, $config);

$panel = $this->bootedBar()->collect()['data']['config']['panel'];
Expand All @@ -201,6 +202,7 @@ public function testConfigSourceReachesTheCollectorRedacted(): void
$this->assertSame('keepme', $panel['section.plain']);
$this->assertSame('***', $panel['section.password']);
$this->assertSame('***', $panel['section.maskme']);
$this->assertSame('id', $panel['section.primary_key']);
$this->assertArrayNotHasKey('section.dropme', $panel);
}

Expand Down Expand Up @@ -532,7 +534,7 @@ private function bootedBar(): DebugBar
* collectors?: array<string, bool>,
* headers?: bool,
* history?: array{enabled?: bool, path?: string, base_uri?: string},
* redact?: array{mask?: list<string>, hidden?: list<string>}
* redact?: array{mask?: list<string>, hidden?: list<string>, unmask?: list<string>}
* } $config
*/
private function core(array $config = []): Core
Expand Down
Loading
Loading