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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat(ui): add sensitive queue-payload redaction and recognize Yii3 queue producers for Dump, Mail, and Queue parity.
- fix(ui): add keyboard-resizable drawers with Escape handling and focus restoration.
- fix: harden packaging, privacy, lifecycle, snapshot recovery, dump and toolbar security, and accelerate value hydration.
- refactor: simplify strict value hydration, collector cleanup reporting, sensitive-key lookup, and toolbar message validation without changing public contracts.
2 changes: 1 addition & 1 deletion resources/assets/dist/js/focus.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 16 additions & 10 deletions resources/src/toolbar/focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,9 @@ export function focusToolbarTrigger(root, url) {
}

export function isToolbarDrawerCloseMessage(event, origin, frameWindow) {
var data = event && event.data;
var data = toolbarDrawerMessageData(event, origin, frameWindow);

return Boolean(
event &&
event.origin === origin &&
frameWindow &&
event.source === frameWindow &&
data &&
typeof data === "object" &&
data.source === "yii-debug-toolbar" &&
Expand All @@ -48,19 +44,29 @@ export function isToolbarDrawerCloseMessage(event, origin, frameWindow) {
}

export function isToolbarDrawerThemeMessage(event, origin, frameWindow) {
var data = event && event.data;
var data = toolbarDrawerMessageData(event, origin, frameWindow);

return Boolean(
event &&
event.origin === origin &&
frameWindow &&
event.source === frameWindow &&
data &&
typeof data === "object" &&
data.source === "yii-debug-toolbar" &&
data.type === "theme",
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function toolbarDrawerMessageData(event, origin, frameWindow) {
if (
!event ||
event.origin !== origin ||
!frameWindow ||
event.source !== frameWindow
) {
return null;
}

return event.data;
}

export function requestParentToolbarDrawerClose(
event,
browserWindow,
Expand Down
12 changes: 12 additions & 0 deletions resources/tests/toolbar-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,18 @@ test("theme messages are bound to the active same-origin iframe", () => {
),
false,
);
var callableData = function () {};
callableData.source = "yii-debug-toolbar";
callableData.type = "theme";

assert.equal(
isToolbarDrawerThemeMessage(
{ ...message, data: callableData },
"https://example.test",
frameWindow,
),
false,
);
});

test("embedded debug pages request drawer closure after an unhandled Escape", () => {
Expand Down
28 changes: 21 additions & 7 deletions src/Collector/CollectorCoordinator.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,7 @@
try {
$this->shutdown();
} catch (Throwable $cleanupFailure) {
if ($cleanupFailureHandler !== null) {
try {
$cleanupFailureHandler($cleanupFailure);
} catch (Throwable) {
// Diagnostic observers must not replace the primary application failure.
}
}
self::reportCleanupFailure($cleanupFailure, $cleanupFailureHandler);
}

throw $primaryFailure;
Expand All @@ -173,8 +167,8 @@
*/
public function shutdown(): void
{
if (!$this->started && $this->startedCollectors === []) {

Check warning on line 170 in src/Collector/CollectorCoordinator.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "LogicalNot": @@ @@ */ public function shutdown(): void { - if (!$this->started && $this->startedCollectors === []) { + if ($this->started && $this->startedCollectors === []) { return; }

Check warning on line 170 in src/Collector/CollectorCoordinator.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "LogicalNot": @@ @@ */ public function shutdown(): void { - if (!$this->started && $this->startedCollectors === []) { + if ($this->started && $this->startedCollectors === []) { return; }
return;

Check warning on line 171 in src/Collector/CollectorCoordinator.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ public function shutdown(): void { if (!$this->started && $this->startedCollectors === []) { - return; + } $this->started = false;

Check warning on line 171 in src/Collector/CollectorCoordinator.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.5-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ public function shutdown(): void { if (!$this->started && $this->startedCollectors === []) { - return; + } $this->started = false;
}

$this->started = false;
Expand Down Expand Up @@ -239,4 +233,24 @@

$this->started = true;
}

/**
* Reports a secondary cleanup failure without allowing the observer to replace the primary failure.
*
* @param (callable(Throwable): void)|null $cleanupFailureHandler Secondary-failure observer.
*/
private static function reportCleanupFailure(
Throwable $cleanupFailure,
callable|null $cleanupFailureHandler,
): void {
if ($cleanupFailureHandler === null) {
return;
}

try {
$cleanupFailureHandler($cleanupFailure);
} catch (Throwable) {
// Diagnostic observers must not replace the primary application failure.
}
}
}
18 changes: 14 additions & 4 deletions src/Helper/SensitiveDataRedactor.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ final class SensitiveDataRedactor
*/
public static function isSensitiveKey(string $key, array $sensitiveKeys = self::DEFAULT_KEYS): bool
{
return isset(array_fill_keys(array_map(strtolower(...), $sensitiveKeys), true)[strtolower($key)]);
return isset(self::keyMap($sensitiveKeys)[strtolower($key)]);
}

/**
Expand All @@ -85,11 +85,21 @@ public static function isSensitiveKey(string $key, array $sensitiveKeys = self::
*/
public static function redact(#[SensitiveParameter] array $value, array $sensitiveKeys = self::DEFAULT_KEYS): array
{
$keys = array_fill_keys(array_map(strtolower(...), $sensitiveKeys), true);

$nodes = 0;

return self::walk($value, $keys, 0, $nodes);
return self::walk($value, self::keyMap($sensitiveKeys), 0, $nodes);
}

/**
* Normalizes configured keys into a case-insensitive lookup map.
*
* @param list<string> $sensitiveKeys Exact key names to normalize.
*
* @return array<string, true> Normalized key lookup.
*/
private static function keyMap(array $sensitiveKeys): array
{
return array_fill_keys(array_map(strtolower(...), $sensitiveKeys), true);
}

/**
Expand Down
140 changes: 55 additions & 85 deletions src/Storage/DebugValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@
*/
final readonly class DebugValue implements JsonSerializable
{
/**
* @var array<string, true>
*/
private const array ENTRY_SHAPE = [
'keyType' => true,
'key' => true,
'value' => true,
];
private const int MAX_DEPTH = 10;
private const int MAX_NODES = 10000;

Expand Down Expand Up @@ -234,11 +242,7 @@ public function toDisplayValue(): mixed
*/
private static function bool(array $payload, string $key, string $path): bool
{
if (!array_key_exists($key, $payload)) {
throw HydrationException::at("{$path}.{$key}", 'a required field');
}

$value = $payload[$key];
$value = self::required($payload, $key, $path);

if (!is_bool($value)) {
throw HydrationException::at("{$path}.{$key}", 'a boolean');
Expand Down Expand Up @@ -273,49 +277,14 @@ private function displayLabel(): string
*/
private static function entryObject(mixed $value, string $path): array
{
if (!is_array($value) || (array_is_list($value) && $value !== [])) {
throw HydrationException::at($path, 'an object');
}

foreach ($value as $key => $_) {
if (!is_string($key)) {
throw HydrationException::at($path, 'an object with string keys');
}
}
$entry = self::object($value, $path);

if (!array_key_exists('keyType', $value)) {
throw HydrationException::at("{$path}.keyType", 'a required field');
}

if (!array_key_exists('key', $value)) {
throw HydrationException::at("{$path}.key", 'a required field');
}

if (!array_key_exists('value', $value)) {
throw HydrationException::at("{$path}.value", 'a required field');
}

if (count($value) !== 3) {
$unknown = array_diff_key(
$value,
[
'keyType' => true,
'key' => true,
'value' => true,
],
);

if ($unknown !== []) {
$key = array_key_first($unknown);

throw HydrationException::at("{$path}.{$key}", 'a declared field');
}
}
self::validateShape($entry, self::ENTRY_SHAPE, $path);

return [
'keyType' => $value['keyType'],
'key' => $value['key'],
'value' => $value['value'],
'keyType' => self::required($entry, 'keyType', $path),
'key' => self::required($entry, 'key', $path),
'value' => self::required($entry, 'value', $path),
];
}

Expand Down Expand Up @@ -483,11 +452,7 @@ private static function hydrateEntries(array $payload, string $path, int $depth,
*/
private static function int(array $payload, string $key, string $path): int
{
if (!array_key_exists($key, $payload)) {
throw HydrationException::at("{$path}.{$key}", 'a required field');
}

$value = $payload[$key];
$value = self::required($payload, $key, $path);

if (!is_int($value)) {
throw HydrationException::at("{$path}.{$key}", 'an integer');
Expand All @@ -505,11 +470,7 @@ private static function int(array $payload, string $key, string $path): int
*/
private static function list(array $payload, string $key, string $path): array
{
if (!array_key_exists($key, $payload)) {
throw HydrationException::at("{$path}.{$key}", 'a required field');
}

$value = $payload[$key];
$value = self::required($payload, $key, $path);

if (!is_array($value) || !array_is_list($value)) {
throw HydrationException::at("{$path}.{$key}", 'a list');
Expand Down Expand Up @@ -676,11 +637,7 @@ className: $value::class,
*/
private static function nullableString(array $payload, string $key, string $path): string|null
{
if (!array_key_exists($key, $payload)) {
throw HydrationException::at("{$path}.{$key}", 'a required field');
}

$value = $payload[$key];
$value = self::required($payload, $key, $path);

if ($value !== null && !is_string($value)) {
throw HydrationException::at("{$path}.{$key}", 'a string or null');
Expand All @@ -696,11 +653,7 @@ private static function nullableString(array $payload, string $key, string $path
*/
private static function number(array $payload, string $key, string $path): float
{
if (!array_key_exists($key, $payload)) {
throw HydrationException::at("{$path}.{$key}", 'a required field');
}

$value = $payload[$key];
$value = self::required($payload, $key, $path);

if (!is_int($value) && (!is_float($value) || !is_finite($value))) {
throw HydrationException::at("{$path}.{$key}", 'a number');
Expand All @@ -709,6 +662,26 @@ private static function number(array $payload, string $key, string $path): float
return (float) $value;
}

/**
* Returns a decoded JSON object with string keys.
*
* @return array<string, mixed> Validated object fields.
*/
private static function object(mixed $value, string $path): array
{
if (!is_array($value) || (array_is_list($value) && $value !== [])) {
throw HydrationException::at($path, 'an object');
}

foreach ($value as $key => $_) {
if (!is_string($key)) {
throw HydrationException::at($path, 'an object with string keys');
}
}

return $value;
}

/**
* Returns a safe display label for an object.
*
Expand Down Expand Up @@ -742,17 +715,27 @@ private static function objectLabel(object $value): string
}

/**
* Returns a required string without coercion.
* Returns a required field without coercion.
*
* @param array<string, mixed> $payload Validated payload.
*/
private static function string(array $payload, string $key, string $path): string
private static function required(array $payload, string $key, string $path): mixed
{
if (!array_key_exists($key, $payload)) {
throw HydrationException::at("{$path}.{$key}", 'a required field');
}

$value = $payload[$key];
return $payload[$key];
}

/**
* Returns a required string without coercion.
*
* @param array<string, mixed> $payload Validated payload.
*/
private static function string(array $payload, string $key, string $path): string
{
$value = self::required($payload, $key, $path);

if (!is_string($value)) {
throw HydrationException::at("{$path}.{$key}", 'a string');
Expand All @@ -770,21 +753,8 @@ private static function string(array $payload, string $key, string $path): strin
*/
private static function taggedObject(mixed $value, string $path, string &$type): array
{
if (!is_array($value) || (array_is_list($value) && $value !== [])) {
throw HydrationException::at($path, 'an object');
}

foreach ($value as $key => $_) {
if (!is_string($key)) {
throw HydrationException::at($path, 'an object with string keys');
}
}

if (!array_key_exists('type', $value)) {
throw HydrationException::at("{$path}.type", 'a required field');
}

$rawType = $value['type'];
$payload = self::object($value, $path);
$rawType = self::required($payload, 'type', $path);

if (!is_string($rawType)) {
throw HydrationException::at("{$path}.type", 'a string');
Expand All @@ -797,9 +767,9 @@ private static function taggedObject(mixed $value, string $path, string &$type):
'a known debug-value type',
);

self::validateShape($value, $shape, $path);
self::validateShape($payload, $shape, $path);

return $value;
return $payload;
}

/**
Expand Down
Loading