Skip to content

Commit fc1e2ea

Browse files
Fast-fail oversize SA key/value and operation names at request boundary
Closes the last remaining gaps in #462 audit: - signal/update/query name length + control-char validation before DB - per-key regex + length cap for search_attributes at start - per-value byte cap for SA string values (and array elements) - configurable via DW_MAX_OPERATION_NAME_LENGTH, DW_MAX_SEARCH_ATTRIBUTE_KEY_LENGTH, DW_MAX_SEARCH_ATTRIBUTE_VALUE_BYTES - /api/cluster/info exposes the new limits so clients can discover them
1 parent 64bf65e commit fc1e2ea

5 files changed

Lines changed: 329 additions & 0 deletions

File tree

app/Http/Controllers/Api/HealthController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ public function clusterInfo(Request $request): JsonResponse
9292
'max_payload_bytes' => (int) config('server.limits.max_payload_bytes', 2 * 1024 * 1024),
9393
'max_memo_bytes' => (int) config('server.limits.max_memo_bytes', 256 * 1024),
9494
'max_search_attributes' => (int) config('server.limits.max_search_attributes', 100),
95+
'max_search_attribute_key_length' => (int) config('server.limits.max_search_attribute_key_length', 128),
96+
'max_search_attribute_value_bytes' => (int) config('server.limits.max_search_attribute_value_bytes', 2048),
97+
'max_operation_name_length' => (int) config('server.limits.max_operation_name_length', 256),
9598
'max_pending_activities' => (int) config('server.limits.max_pending_activities', 2000),
9699
'max_pending_children' => (int) config('server.limits.max_pending_children', 2000),
97100
],

app/Http/Controllers/Api/WorkflowController.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ public function start(Request $request): JsonResponse
149149
}
150150
}
151151

152+
if (isset($validated['search_attributes'])) {
153+
$this->validateSearchAttributes($validated['search_attributes']);
154+
}
155+
152156
$workflowId = $validated['workflow_id'] ?? null;
153157

154158
if ($workflowId !== null && $this->workflowIdReservedElsewhere($namespace, $workflowId)) {
@@ -302,6 +306,8 @@ public function signal(Request $request, string $workflowId, string $signalName)
302306
return $response;
303307
}
304308

309+
$this->validateOperationName($signalName, 'signal');
310+
305311
$namespace = $request->attributes->get('namespace');
306312

307313
if (! NamespaceWorkflowScope::workflowBound($namespace, $workflowId)) {
@@ -352,6 +358,8 @@ public function query(Request $request, string $workflowId, string $queryName):
352358
return $response;
353359
}
354360

361+
$this->validateOperationName($queryName, 'query');
362+
355363
$namespace = $request->attributes->get('namespace');
356364

357365
if (! NamespaceWorkflowScope::workflowBound($namespace, $workflowId)) {
@@ -408,6 +416,8 @@ public function update(Request $request, string $workflowId, string $updateName)
408416
return $response;
409417
}
410418

419+
$this->validateOperationName($updateName, 'update');
420+
411421
$namespace = $request->attributes->get('namespace');
412422

413423
if (! NamespaceWorkflowScope::workflowBound($namespace, $workflowId)) {
@@ -839,4 +849,103 @@ private function rejectLegacyUpdateFields(Request $request): void
839849
]);
840850
}
841851
}
852+
853+
/**
854+
* Fast-fail oversize or malformed signal/update/query names at the
855+
* request boundary so the control plane never dispatches a command
856+
* row the downstream path would later reject.
857+
*/
858+
private function validateOperationName(string $name, string $kind): void
859+
{
860+
$max = (int) config('server.limits.max_operation_name_length', 256);
861+
$field = $kind.'_name';
862+
$bytes = strlen($name);
863+
864+
if ($bytes === 0) {
865+
throw ValidationException::withMessages([
866+
$field => [sprintf('The %s name must not be empty.', $kind)],
867+
]);
868+
}
869+
870+
if ($max > 0 && $bytes > $max) {
871+
throw ValidationException::withMessages([
872+
$field => [sprintf(
873+
'The %s name exceeds the maximum length of %d bytes.',
874+
$kind,
875+
$max,
876+
)],
877+
]);
878+
}
879+
880+
if (preg_match('/[\x00-\x1F\x7F]/', $name) === 1) {
881+
throw ValidationException::withMessages([
882+
$field => [sprintf(
883+
'The %s name must not contain control characters.',
884+
$kind,
885+
)],
886+
]);
887+
}
888+
}
889+
890+
/**
891+
* Validate each search-attribute key and string value against the
892+
* per-key and per-value limits so large or malformed entries are
893+
* rejected before being written to the DB or forwarded to the
894+
* control plane.
895+
*
896+
* @param array<int|string, mixed> $searchAttributes
897+
*/
898+
private function validateSearchAttributes(array $searchAttributes): void
899+
{
900+
$maxKeyLength = (int) config('server.limits.max_search_attribute_key_length', 128);
901+
$maxValueBytes = (int) config('server.limits.max_search_attribute_value_bytes', 2048);
902+
$messages = [];
903+
904+
foreach ($searchAttributes as $key => $value) {
905+
if (! is_string($key) || $key === '') {
906+
$messages[] = 'Search attribute keys must be non-empty strings.';
907+
908+
continue;
909+
}
910+
911+
if ($maxKeyLength > 0 && strlen($key) > $maxKeyLength) {
912+
$messages[] = sprintf(
913+
'Search attribute key [%s] exceeds the maximum length of %d bytes.',
914+
$key,
915+
$maxKeyLength,
916+
);
917+
918+
continue;
919+
}
920+
921+
if (preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $key) !== 1) {
922+
$messages[] = sprintf(
923+
'Search attribute key [%s] must start with a letter and contain only letters, numbers, and underscores.',
924+
$key,
925+
);
926+
927+
continue;
928+
}
929+
930+
$atoms = is_array($value) ? $value : [$value];
931+
932+
foreach ($atoms as $atom) {
933+
if (is_string($atom) && $maxValueBytes > 0 && strlen($atom) > $maxValueBytes) {
934+
$messages[] = sprintf(
935+
'Search attribute [%s] value exceeds the maximum of %d bytes.',
936+
$key,
937+
$maxValueBytes,
938+
);
939+
940+
break;
941+
}
942+
}
943+
}
944+
945+
if ($messages !== []) {
946+
throw ValidationException::withMessages([
947+
'search_attributes' => $messages,
948+
]);
949+
}
950+
}
842951
}

config/dw-contract.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,24 @@
325325
'since' => '2.0.0',
326326
'legacy' => 'WORKFLOW_MAX_SEARCH_ATTRIBUTES',
327327
],
328+
'DW_MAX_SEARCH_ATTRIBUTE_KEY_LENGTH' => [
329+
'description' => 'Maximum length in bytes for a single search-attribute key at the request boundary.',
330+
'default' => '128',
331+
'since' => '2.0.0',
332+
'legacy' => 'WORKFLOW_MAX_SEARCH_ATTRIBUTE_KEY_LENGTH',
333+
],
334+
'DW_MAX_SEARCH_ATTRIBUTE_VALUE_BYTES' => [
335+
'description' => 'Maximum size in bytes for a single search-attribute string value at the request boundary.',
336+
'default' => '2048',
337+
'since' => '2.0.0',
338+
'legacy' => 'WORKFLOW_MAX_SEARCH_ATTRIBUTE_VALUE_BYTES',
339+
],
340+
'DW_MAX_OPERATION_NAME_LENGTH' => [
341+
'description' => 'Maximum length in bytes for a signal, update, or query name accepted at the request boundary.',
342+
'default' => '256',
343+
'since' => '2.0.0',
344+
'legacy' => 'WORKFLOW_MAX_OPERATION_NAME_LENGTH',
345+
],
328346
'DW_MAX_PENDING_ACTIVITIES' => [
329347
'description' => 'Maximum pending activities per workflow run before the server rejects a command batch.',
330348
'default' => '2000',

config/server.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,21 @@
350350
'max_payload_bytes' => (int) EnvAuditor::env('DW_MAX_PAYLOAD_BYTES', 'WORKFLOW_MAX_PAYLOAD_BYTES', 2 * 1024 * 1024),
351351
'max_memo_bytes' => (int) EnvAuditor::env('DW_MAX_MEMO_BYTES', 'WORKFLOW_MAX_MEMO_BYTES', 256 * 1024),
352352
'max_search_attributes' => (int) EnvAuditor::env('DW_MAX_SEARCH_ATTRIBUTES', 'WORKFLOW_MAX_SEARCH_ATTRIBUTES', 100),
353+
'max_search_attribute_key_length' => (int) EnvAuditor::env(
354+
'DW_MAX_SEARCH_ATTRIBUTE_KEY_LENGTH',
355+
'WORKFLOW_MAX_SEARCH_ATTRIBUTE_KEY_LENGTH',
356+
128,
357+
),
358+
'max_search_attribute_value_bytes' => (int) EnvAuditor::env(
359+
'DW_MAX_SEARCH_ATTRIBUTE_VALUE_BYTES',
360+
'WORKFLOW_MAX_SEARCH_ATTRIBUTE_VALUE_BYTES',
361+
2048,
362+
),
363+
'max_operation_name_length' => (int) EnvAuditor::env(
364+
'DW_MAX_OPERATION_NAME_LENGTH',
365+
'WORKFLOW_MAX_OPERATION_NAME_LENGTH',
366+
256,
367+
),
353368
'max_pending_activities' => (int) EnvAuditor::env('DW_MAX_PENDING_ACTIVITIES', 'WORKFLOW_MAX_PENDING_ACTIVITIES', 2000),
354369
'max_pending_children' => (int) EnvAuditor::env('DW_MAX_PENDING_CHILDREN', 'WORKFLOW_MAX_PENDING_CHILDREN', 2000),
355370
],

tests/Feature/PayloadLimitsTest.php

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,188 @@ public function test_search_attribute_limit_is_scoped_to_namespace(): void
314314
], $this->apiHeaders('default'))
315315
->assertCreated();
316316
}
317+
318+
// ── Per-attribute SA validation at request boundary ─────────────
319+
320+
public function test_workflow_start_rejects_oversized_search_attribute_value(): void
321+
{
322+
config(['server.limits.max_search_attribute_value_bytes' => 64]);
323+
324+
$this->configureWorkflowTypes([
325+
ExternalGreetingWorkflow::class,
326+
]);
327+
328+
$this->postJson('/api/workflows', [
329+
'workflow_type' => 'ExternalGreetingWorkflow',
330+
'search_attributes' => ['Region' => str_repeat('x', 200)],
331+
], $this->apiHeaders())
332+
->assertStatus(422)
333+
->assertJsonPath(
334+
'validation_errors.search_attributes.0',
335+
fn (string $msg): bool => str_contains($msg, 'Region') && str_contains($msg, '64'),
336+
);
337+
}
338+
339+
public function test_workflow_start_rejects_oversized_search_attribute_key(): void
340+
{
341+
config(['server.limits.max_search_attribute_key_length' => 8]);
342+
343+
$this->configureWorkflowTypes([
344+
ExternalGreetingWorkflow::class,
345+
]);
346+
347+
$longKey = str_repeat('K', 32);
348+
349+
$this->postJson('/api/workflows', [
350+
'workflow_type' => 'ExternalGreetingWorkflow',
351+
'search_attributes' => [$longKey => 'small'],
352+
], $this->apiHeaders())
353+
->assertStatus(422)
354+
->assertJsonPath(
355+
'validation_errors.search_attributes.0',
356+
fn (string $msg): bool => str_contains($msg, $longKey) && str_contains($msg, '8'),
357+
);
358+
}
359+
360+
public function test_workflow_start_rejects_malformed_search_attribute_key(): void
361+
{
362+
$this->configureWorkflowTypes([
363+
ExternalGreetingWorkflow::class,
364+
]);
365+
366+
$this->postJson('/api/workflows', [
367+
'workflow_type' => 'ExternalGreetingWorkflow',
368+
'search_attributes' => ['123invalid' => 'small'],
369+
], $this->apiHeaders())
370+
->assertStatus(422)
371+
->assertJsonPath(
372+
'validation_errors.search_attributes.0',
373+
fn (string $msg): bool => str_contains($msg, '123invalid'),
374+
);
375+
}
376+
377+
public function test_workflow_start_rejects_search_attribute_array_with_oversized_element(): void
378+
{
379+
config(['server.limits.max_search_attribute_value_bytes' => 32]);
380+
381+
$this->configureWorkflowTypes([
382+
ExternalGreetingWorkflow::class,
383+
]);
384+
385+
$this->postJson('/api/workflows', [
386+
'workflow_type' => 'ExternalGreetingWorkflow',
387+
'search_attributes' => ['Tags' => ['short', str_repeat('y', 200)]],
388+
], $this->apiHeaders())
389+
->assertStatus(422)
390+
->assertJsonPath(
391+
'validation_errors.search_attributes.0',
392+
fn (string $msg): bool => str_contains($msg, 'Tags') && str_contains($msg, '32'),
393+
);
394+
}
395+
396+
public function test_workflow_start_accepts_valid_search_attributes(): void
397+
{
398+
$this->configureWorkflowTypes([
399+
ExternalGreetingWorkflow::class,
400+
]);
401+
402+
$this->postJson('/api/workflows', [
403+
'workflow_type' => 'ExternalGreetingWorkflow',
404+
'search_attributes' => [
405+
'Region' => 'us-east-1',
406+
'Priority' => 5,
407+
'Tags' => ['alpha', 'beta'],
408+
],
409+
], $this->apiHeaders())
410+
->assertSuccessful();
411+
}
412+
413+
// ── Signal / update / query name length validation ─────────────
414+
415+
public function test_workflow_signal_rejects_oversized_name(): void
416+
{
417+
config(['server.limits.max_operation_name_length' => 16]);
418+
419+
$longName = str_repeat('A', 64);
420+
421+
$this->postJson(
422+
"/api/workflows/wf-any/signal/{$longName}",
423+
['input' => []],
424+
$this->apiHeaders(),
425+
)
426+
->assertStatus(422)
427+
->assertJsonPath(
428+
'validation_errors.signal_name.0',
429+
fn (string $msg): bool => str_contains($msg, '16'),
430+
);
431+
}
432+
433+
public function test_workflow_query_rejects_oversized_name(): void
434+
{
435+
config(['server.limits.max_operation_name_length' => 16]);
436+
437+
$longName = str_repeat('Q', 64);
438+
439+
$this->postJson(
440+
"/api/workflows/wf-any/query/{$longName}",
441+
['input' => []],
442+
$this->apiHeaders(),
443+
)
444+
->assertStatus(422)
445+
->assertJsonPath(
446+
'validation_errors.query_name.0',
447+
fn (string $msg): bool => str_contains($msg, '16'),
448+
);
449+
}
450+
451+
public function test_workflow_update_rejects_oversized_name(): void
452+
{
453+
config(['server.limits.max_operation_name_length' => 16]);
454+
455+
$longName = str_repeat('U', 64);
456+
457+
$this->postJson(
458+
"/api/workflows/wf-any/update/{$longName}",
459+
['input' => []],
460+
$this->apiHeaders(),
461+
)
462+
->assertStatus(422)
463+
->assertJsonPath(
464+
'validation_errors.update_name.0',
465+
fn (string $msg): bool => str_contains($msg, '16'),
466+
);
467+
}
468+
469+
public function test_workflow_signal_rejects_control_characters_in_name(): void
470+
{
471+
$badName = rawurlencode("my\x01signal");
472+
473+
$this->postJson(
474+
"/api/workflows/wf-any/signal/{$badName}",
475+
['input' => []],
476+
$this->apiHeaders(),
477+
)
478+
->assertStatus(422)
479+
->assertJsonPath(
480+
'validation_errors.signal_name.0',
481+
fn (string $msg): bool => str_contains($msg, 'control characters'),
482+
);
483+
}
484+
485+
public function test_workflow_signal_name_validation_runs_before_workflow_lookup(): void
486+
{
487+
// Even when the workflow does not exist, the name-validation
488+
// failure must surface as 422 (not 404) so clients learn the
489+
// name itself is the problem.
490+
config(['server.limits.max_operation_name_length' => 8]);
491+
492+
$longName = str_repeat('A', 16);
493+
494+
$this->postJson(
495+
"/api/workflows/does-not-exist/signal/{$longName}",
496+
['input' => []],
497+
$this->apiHeaders(),
498+
)
499+
->assertStatus(422);
500+
}
317501
}

0 commit comments

Comments
 (0)