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>
196 lines
5.3 KiB
PHP
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;
|
|
}
|
|
}
|