Skip to content

Commit 0000c99

Browse files
xHeavenbrendt
andauthored
feat(http): add support to mark Request properties as #[SensitiveField] (#1746)
Co-authored-by: Brent Roose <[email protected]>
1 parent df2dcdc commit 0000c99

File tree

10 files changed

+127
-7
lines changed

10 files changed

+127
-7
lines changed

docs/1-essentials/01-routing.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,33 @@ final readonly class AirportController
387387
The `map()` function allows mapping any data from any source into objects of your choice. You may read more about them in [their documentation](../2-features/01-mapper.md).
388388
:::
389389

390+
### Sensitive fields
391+
392+
When handling sensitive data such as passwords or tokens, you may not want these values to be stored in the session or re-displayed in forms after validation errors. You can mark request properties as sensitive using the {b`#[Tempest\Http\SensitiveField]`} attribute:
393+
394+
```php app/ResetPasswordRequest.php
395+
use Tempest\Http\Request;
396+
use Tempest\Http\IsRequest;
397+
use Tempest\Http\SensitiveField;
398+
use Tempest\Validation\Rules\HasMinLength;
399+
400+
final class ResetPasswordRequest implements Request
401+
{
402+
use IsRequest;
403+
404+
public string $email;
405+
406+
#[SensitiveField]
407+
#[HasMinLength(8)]
408+
public string $password;
409+
410+
#[SensitiveField]
411+
public string $password_confirmation;
412+
}
413+
```
414+
415+
When a validation error occurs, Tempest will filter out sensitive fields from the original values stored in the session. This prevents sensitive data from being re-populated in forms after a redirect.
416+
390417
### Retrieving data directly
391418

392419
For simpler use cases, you may simply retrieve a value from the body or the query parameter using the request's `get` method.

packages/http/src/Mappers/RequestToObjectMapper.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ public function map(mixed $from, mixed $to): array|object
5858
$failingRules = $this->validator->validateValuesForClass($to, $data);
5959

6060
if ($failingRules !== []) {
61-
throw $this->validator->createValidationFailureException($failingRules, $from);
61+
$targetClass = is_string($to) ? $to : $to::class;
62+
throw $this->validator->createValidationFailureException($failingRules, $from, $targetClass);
6263
}
6364

6465
return map($data)->with(ArrayToObjectMapper::class)->to($to);

packages/http/src/Responses/Invalid.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
use Tempest\Http\IsResponse;
88
use Tempest\Http\Request;
99
use Tempest\Http\Response;
10+
use Tempest\Http\SensitiveField;
1011
use Tempest\Http\Session\Session;
1112
use Tempest\Http\Status;
13+
use Tempest\Reflection\ClassReflector;
1214
use Tempest\Support\Json;
1315
use Tempest\Validation\Rule;
1416
use Tempest\Validation\Validator;
@@ -24,10 +26,14 @@ final class Invalid implements Response
2426
get => get(Validator::class);
2527
}
2628

29+
/**
30+
* @param class-string|null $targetClass
31+
*/
2732
public function __construct(
2833
Request $request,
2934
/** @var \Tempest\Validation\Rule[][] $failingRules */
3035
array $failingRules = [],
36+
?string $targetClass = null,
3137
) {
3238
if ($referer = $request->headers['referer'] ?? null) {
3339
$this->addHeader('Location', $referer);
@@ -37,7 +43,7 @@ public function __construct(
3743
}
3844

3945
$this->flash(Session::VALIDATION_ERRORS, $failingRules);
40-
$this->flash(Session::ORIGINAL_VALUES, $request->body);
46+
$this->flash(Session::ORIGINAL_VALUES, $this->filterSensitiveFields($request, $targetClass));
4147
$this->addHeader(
4248
'x-validation',
4349
Json\encode(
@@ -49,4 +55,26 @@ public function __construct(
4955
),
5056
);
5157
}
58+
59+
/**
60+
* @param class-string|null $targetClass
61+
*/
62+
private function filterSensitiveFields(Request $request, ?string $targetClass): array
63+
{
64+
$body = $request->body;
65+
66+
if ($targetClass === null) {
67+
return $body;
68+
}
69+
70+
$reflector = new ClassReflector($targetClass);
71+
72+
foreach ($reflector->getPublicProperties() as $property) {
73+
if ($property->hasAttribute(SensitiveField::class)) {
74+
unset($body[$property->getName()]);
75+
}
76+
}
77+
78+
return $body;
79+
}
5280
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Http;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final class SensitiveField
11+
{
12+
}

packages/router/src/HandleRouteExceptionMiddleware.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ private function forward(Request $request, HttpMiddlewareCallable $next): Respon
4747
} catch (RouteBindingFailed) {
4848
return new NotFound();
4949
} catch (ValidationFailed $validationException) {
50-
return new Invalid($validationException->subject, $validationException->failingRules);
50+
return new Invalid($validationException->subject, $validationException->failingRules, $validationException->targetClass);
5151
}
5252
}
5353
}

packages/validation/src/Exceptions/ValidationFailed.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ final class ValidationFailed extends Exception
1414
*
1515
* @param array<TKey,Rule[]> $failingRules
1616
* @param array<TKey,string> $errorMessages
17+
* @param class-string|null $targetClass
1718
*/
1819
public function __construct(
1920
public readonly array $failingRules,
2021
public readonly null|object|string $subject = null,
2122
public readonly array $errorMessages = [],
23+
public readonly ?string $targetClass = null,
2224
) {
2325
parent::__construct(match (true) {
2426
is_null($subject) => 'Validation failed.',

packages/validation/src/Validator.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,18 @@ public function validateObject(object $object): void
5656
* Creates a {@see ValidationFailed} exception from the given rule failures, populated with error messages.
5757
*
5858
* @param array<string,Rule[]> $failingRules
59+
* @param class-string|null $targetClass
5960
*/
60-
public function createValidationFailureException(array $failingRules, null|object|string $subject = null): ValidationFailed
61+
public function createValidationFailureException(array $failingRules, null|object|string $subject = null, ?string $targetClass = null): ValidationFailed
6162
{
62-
return new ValidationFailed($failingRules, $subject, Arr\map_iterable($failingRules, function (array $rules, string $field) {
63-
return Arr\map_iterable($rules, fn (Rule $rule) => $this->getErrorMessage($rule, $field));
64-
}));
63+
return new ValidationFailed(
64+
$failingRules,
65+
$subject,
66+
Arr\map_iterable($failingRules, function (array $rules, string $field) {
67+
return Arr\map_iterable($rules, fn (Rule $rule) => $this->getErrorMessage($rule, $field));
68+
}),
69+
$targetClass,
70+
);
6571
}
6672

6773
/**

tests/Fixtures/Controllers/ValidationController.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Tempest\Router\Post;
1313
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
1414
use Tests\Tempest\Fixtures\Requests\BookRequest;
15+
use Tests\Tempest\Fixtures\Requests\SensitiveFieldRequest;
1516
use Tests\Tempest\Fixtures\Requests\ValidationRequest;
1617

1718
use function Tempest\Router\uri;
@@ -30,6 +31,12 @@ public function store(ValidationRequest $request): Response
3031
return new Redirect(uri([self::class, 'get']));
3132
}
3233

34+
#[Post('/test-sensitive-validation')]
35+
public function storeSensitive(SensitiveFieldRequest $request): Response
36+
{
37+
return new Redirect(uri([self::class, 'get']));
38+
}
39+
3340
#[Get(uri: '/test-validation-responses-json/{book}')]
3441
public function book(Book $book): Response
3542
{
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Fixtures\Requests;
6+
7+
use Tempest\Http\IsRequest;
8+
use Tempest\Http\Request;
9+
use Tempest\Http\SensitiveField;
10+
use Tempest\Validation\Rules\IsNotEmptyString;
11+
12+
final class SensitiveFieldRequest implements Request
13+
{
14+
use IsRequest;
15+
16+
#[IsNotEmptyString]
17+
public string $not_sensitive_param;
18+
19+
#[SensitiveField]
20+
#[IsNotEmptyString]
21+
public string $sensitive_param;
22+
}

tests/Integration/Http/ValidationResponseTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,19 @@ public function test_failing_post_request(): void
102102

103103
$this->assertSame('Timeline Taxi', Book::find(id: 1)->first()->title);
104104
}
105+
106+
public function test_sensitive_fields_are_excluded_from_original_values(): void
107+
{
108+
$this->http
109+
->post(
110+
uri: uri([ValidationController::class, 'storeSensitive']),
111+
body: ['not_sensitive_param' => '', 'sensitive_param' => 'secret123'],
112+
headers: ['referer' => '/test-sensitive-validation'],
113+
)
114+
->assertHasValidationError('not_sensitive_param')
115+
->assertHasSession(Session::ORIGINAL_VALUES, function (Session $_session, array $data): void {
116+
$this->assertArrayNotHasKey('sensitive_param', $data);
117+
$this->assertArrayHasKey('not_sensitive_param', $data);
118+
});
119+
}
105120
}

0 commit comments

Comments
 (0)