breznflow/includes/Security/WorkflowValidator.php
Michael fd83e4810b BreznFlow 1.0.0 — WordPress.org submission
Initial public release of BreznFlow, an n8n workflow renderer for WordPress.
Fully PHPCS-compliant (WordPress Coding Standards), security-hardened,
and ready for WordPress.org plugin review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:27:36 +00:00

196 lines
5.3 KiB
PHP

<?php
/**
* Validates raw n8n workflow JSON against expected schema.
*
* @package BreznFlow
* @since 1.0.0
*/
namespace BreznFlow\Security;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Validates raw n8n workflow JSON against size, structure and format constraints.
*
* @since 1.0.0
*/
class WorkflowValidator {
const MAX_NODES = 500;
const MAX_SIZE = 2_097_152; // 2MB.
const UUID_PATTERN = '/^[0-9a-f-]{36}$/i';
const TYPE_PATTERN = '/^[a-zA-Z0-9@.\/_-]+$/';
/**
* Validates a raw n8n workflow JSON string.
*
* @param string $raw Raw JSON string from user input.
* @return array|\WP_Error Decoded array on success, WP_Error on failure.
*/
public static function validate( string $raw ) {
// Check 5: Size limit (fail-fast before heavy processing).
if ( strlen( $raw ) > self::MAX_SIZE ) {
return new \WP_Error(
'breznflow_too_large',
__( 'Workflow JSON exceeds the 2MB size limit.', 'breznflow' )
);
}
// Check 1: JSON parse.
try {
$data = json_decode( $raw, true, 512, JSON_THROW_ON_ERROR );
} catch ( \JsonException $e ) {
return new \WP_Error(
'breznflow_invalid_json',
sprintf(
/* translators: %s: JSON parse error message */
__( 'Invalid JSON: %s', 'breznflow' ),
$e->getMessage()
)
);
}
// Check 2: Top-level schema.
if ( ! is_array( $data ) ) {
return new \WP_Error( 'breznflow_not_object', __( 'Workflow must be a JSON object.', 'breznflow' ) );
}
if ( empty( $data['name'] ) || ! is_string( $data['name'] ) ) {
return new \WP_Error( 'breznflow_missing_name', __( 'Workflow must have a non-empty "name" field.', 'breznflow' ) );
}
if ( ! isset( $data['nodes'] ) || ! is_array( $data['nodes'] ) || empty( $data['nodes'] ) ) {
return new \WP_Error( 'breznflow_missing_nodes', __( 'Workflow must have a non-empty "nodes" array.', 'breznflow' ) );
}
if ( ! isset( $data['connections'] ) || ! is_array( $data['connections'] ) ) {
return new \WP_Error( 'breznflow_missing_connections', __( 'Workflow must have a "connections" object.', 'breznflow' ) );
}
// Check 3: Node structure + count.
if ( count( $data['nodes'] ) > self::MAX_NODES ) {
return new \WP_Error(
'breznflow_too_many_nodes',
sprintf(
/* translators: %d: maximum node count */
__( 'Workflow has too many nodes (max %d).', 'breznflow' ),
self::MAX_NODES
)
);
}
foreach ( $data['nodes'] as $index => $node ) {
$err = self::validate_node( $node, $index );
if ( is_wp_error( $err ) ) {
return $err;
}
}
// Check 4: Connection integrity.
$node_names = array_column( $data['nodes'], 'name' );
foreach ( array_keys( $data['connections'] ) as $conn_node_name ) {
if ( ! in_array( $conn_node_name, $node_names, true ) ) {
return new \WP_Error(
'breznflow_invalid_connection',
sprintf(
/* translators: %s: node name from connections */
__( 'Connection references unknown node: "%s".', 'breznflow' ),
esc_html( $conn_node_name )
)
);
}
}
return $data;
}
/**
* Validates a single node structure.
*
* @param mixed $node Node data (should be array).
* @param int $index Node index for error messages.
* @return true|\WP_Error
*/
private static function validate_node( $node, int $index ) {
if ( ! is_array( $node ) ) {
return new \WP_Error(
'breznflow_invalid_node',
sprintf(
/* translators: %d: node index */
__( 'Node %d must be an object.', 'breznflow' ),
$index
)
);
}
// id: UUID format.
if ( empty( $node['id'] ) || ! is_string( $node['id'] ) || ! preg_match( self::UUID_PATTERN, $node['id'] ) ) {
return new \WP_Error(
'breznflow_invalid_node_id',
sprintf(
/* translators: %d: node index */
__( 'Node %d has an invalid or missing "id" (must be UUID format).', 'breznflow' ),
$index
)
);
}
// name: non-empty string.
if ( ! isset( $node['name'] ) || ! is_string( $node['name'] ) || '' === $node['name'] ) {
return new \WP_Error(
'breznflow_invalid_node_name',
sprintf(
/* translators: %d: node index */
__( 'Node %d has an invalid or missing "name".', 'breznflow' ),
$index
)
);
}
// type: alphanumeric + allowed chars.
if ( empty( $node['type'] ) || ! is_string( $node['type'] ) || ! preg_match( self::TYPE_PATTERN, $node['type'] ) ) {
return new \WP_Error(
'breznflow_invalid_node_type',
sprintf(
/* translators: %d: node index */
__( 'Node %d has an invalid or missing "type".', 'breznflow' ),
$index
)
);
}
// position: [int, int].
if (
! isset( $node['position'] ) ||
! is_array( $node['position'] ) ||
count( $node['position'] ) < 2 ||
! is_numeric( $node['position'][0] ) ||
! is_numeric( $node['position'][1] )
) {
return new \WP_Error(
'breznflow_invalid_node_position',
sprintf(
/* translators: %d: node index */
__( 'Node %d has an invalid or missing "position" ([x, y] required).', 'breznflow' ),
$index
)
);
}
// typeVersion: numeric.
if ( ! isset( $node['typeVersion'] ) || ! is_numeric( $node['typeVersion'] ) ) {
return new \WP_Error(
'breznflow_invalid_node_version',
sprintf(
/* translators: %d: node index */
__( 'Node %d has an invalid or missing "typeVersion".', 'breznflow' ),
$index
)
);
}
return true;
}
}