Skip to content

Commit 94cb8cc

Browse files
Sniderclaude
andcommitted
feat: add webhook security validation rules
- SafeWebhookUrl: SSRF protection for webhook URLs (blocks private IPs, localhost, reserved ranges) - SafeJsonPayload: validates JSON payload structure and size Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f3125b8 commit 94cb8cc

2 files changed

Lines changed: 449 additions & 0 deletions

File tree

src/Core/Rules/SafeJsonPayload.php

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Core\Rules;
6+
7+
use Closure;
8+
use Illuminate\Contracts\Validation\ValidationRule;
9+
10+
/**
11+
* Validates that a JSON payload is safe for storage.
12+
*
13+
* Protects against:
14+
* - Excessively large payloads (DoS via storage bloat)
15+
* - Deeply nested structures (parsing/memory issues)
16+
* - Too many keys (storage/indexing issues)
17+
* - Overly long string values
18+
*
19+
* Use this for metadata fields, custom parameters, or any
20+
* user-provided JSON that gets stored in the database.
21+
*/
22+
class SafeJsonPayload implements ValidationRule
23+
{
24+
/**
25+
* Create a new rule instance.
26+
*
27+
* @param int $maxSizeBytes Maximum total size in bytes
28+
* @param int $maxDepth Maximum nesting depth
29+
* @param int $maxKeys Maximum total number of keys (across all levels)
30+
* @param int $maxStringLength Maximum length of any string value
31+
*/
32+
public function __construct(
33+
protected int $maxSizeBytes = 10240, // 10KB default
34+
protected int $maxDepth = 3,
35+
protected int $maxKeys = 50,
36+
protected int $maxStringLength = 1000
37+
) {}
38+
39+
/**
40+
* Run the validation rule.
41+
*/
42+
public function validate(string $attribute, mixed $value, Closure $fail): void
43+
{
44+
if ($value === null) {
45+
return;
46+
}
47+
48+
if (! is_array($value)) {
49+
$fail('The :attribute must be a valid JSON object or array.');
50+
51+
return;
52+
}
53+
54+
// Check total encoded size
55+
$encoded = json_encode($value);
56+
if ($encoded === false || strlen($encoded) > $this->maxSizeBytes) {
57+
$fail("The :attribute exceeds the maximum allowed size of {$this->maxSizeBytes} bytes.");
58+
59+
return;
60+
}
61+
62+
// Check structure
63+
$keyCount = 0;
64+
$depthError = false;
65+
$stringError = false;
66+
67+
$this->traverseArray($value, 1, $keyCount, $depthError, $stringError);
68+
69+
if ($depthError) {
70+
$fail("The :attribute exceeds the maximum nesting depth of {$this->maxDepth} levels.");
71+
72+
return;
73+
}
74+
75+
if ($keyCount > $this->maxKeys) {
76+
$fail("The :attribute exceeds the maximum of {$this->maxKeys} keys.");
77+
78+
return;
79+
}
80+
81+
if ($stringError) {
82+
$fail("The :attribute contains string values exceeding {$this->maxStringLength} characters.");
83+
84+
return;
85+
}
86+
}
87+
88+
/**
89+
* Recursively traverse array to check depth, key count, and string lengths.
90+
*/
91+
protected function traverseArray(array $array, int $currentDepth, int &$keyCount, bool &$depthError, bool &$stringError): void
92+
{
93+
if ($currentDepth > $this->maxDepth) {
94+
$depthError = true;
95+
96+
return;
97+
}
98+
99+
foreach ($array as $key => $value) {
100+
$keyCount++;
101+
102+
if ($keyCount > $this->maxKeys) {
103+
return;
104+
}
105+
106+
if (is_string($value) && strlen($value) > $this->maxStringLength) {
107+
$stringError = true;
108+
109+
return;
110+
}
111+
112+
if (is_array($value)) {
113+
$this->traverseArray($value, $currentDepth + 1, $keyCount, $depthError, $stringError);
114+
115+
if ($depthError || $stringError || $keyCount > $this->maxKeys) {
116+
return;
117+
}
118+
}
119+
}
120+
}
121+
122+
/**
123+
* Create with default limits (10KB, 3 depth, 50 keys, 1000 char strings).
124+
*/
125+
public static function default(): self
126+
{
127+
return new self;
128+
}
129+
130+
/**
131+
* Create with small payload limits (2KB, 2 depth, 20 keys, 500 char strings).
132+
*/
133+
public static function small(): self
134+
{
135+
return new self(2048, 2, 20, 500);
136+
}
137+
138+
/**
139+
* Create with large payload limits (100KB, 5 depth, 200 keys, 5000 char strings).
140+
*/
141+
public static function large(): self
142+
{
143+
return new self(102400, 5, 200, 5000);
144+
}
145+
146+
/**
147+
* Create for metadata/tags (5KB, 2 depth, 30 keys, 256 char strings).
148+
*/
149+
public static function metadata(): self
150+
{
151+
return new self(5120, 2, 30, 256);
152+
}
153+
}

0 commit comments

Comments
 (0)