424 lines
12 KiB
PHP
424 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Form field sanitization.
|
|
*
|
|
* Every form value passes through this class. No exceptions.
|
|
*
|
|
* @package GenerateBlocksPro
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Form sanitizer class.
|
|
*/
|
|
class GenerateBlocks_Pro_Form_Sanitizer {
|
|
|
|
/**
|
|
* Allowed field types and their sanitization callbacks.
|
|
*
|
|
* @var array
|
|
*/
|
|
private static $type_sanitizers = [
|
|
'text' => 'sanitize_text_field',
|
|
'textarea' => 'sanitize_textarea_field',
|
|
'email' => 'sanitize_email',
|
|
'url' => 'esc_url_raw',
|
|
'tel' => [ __CLASS__, 'sanitize_tel' ],
|
|
'number' => [ __CLASS__, 'sanitize_number' ],
|
|
'hidden' => 'sanitize_text_field',
|
|
'select' => 'sanitize_text_field',
|
|
'checkbox' => 'sanitize_text_field',
|
|
'radio' => 'sanitize_text_field',
|
|
'checkbox-group' => 'sanitize_text_field',
|
|
];
|
|
|
|
/**
|
|
* Maximum allowed length for any single field value.
|
|
*
|
|
* @var int
|
|
*/
|
|
const MAX_FIELD_LENGTH = 10000;
|
|
|
|
/**
|
|
* Sanitize a single field value based on its registered type.
|
|
*
|
|
* @param string $value The raw field value.
|
|
* @param string $field_type The field type (text, email, url, tel, number, hidden).
|
|
* @return string Sanitized value.
|
|
*/
|
|
public static function sanitize_field( $value, $field_type ) {
|
|
// Force string.
|
|
$value = (string) $value;
|
|
|
|
// Strip null bytes.
|
|
$value = str_replace( "\0", '', $value );
|
|
|
|
// Enforce max length. mbstring is near-universal on modern PHP but
|
|
// we fall back to byte substr so a missing extension doesn't fatal.
|
|
$value = function_exists( 'mb_substr' )
|
|
? mb_substr( $value, 0, self::MAX_FIELD_LENGTH )
|
|
: substr( $value, 0, self::MAX_FIELD_LENGTH );
|
|
|
|
/**
|
|
* Filter the sanitizers for form field types.
|
|
*
|
|
* Allows third-party developers to register custom field type sanitizers.
|
|
*
|
|
* @since 2.6.0
|
|
* @param array $type_sanitizers Field type => sanitizer callback map.
|
|
*/
|
|
$type_sanitizers = apply_filters( 'generateblocks_form_type_sanitizers', self::$type_sanitizers );
|
|
|
|
// Get the sanitizer for this type, default to sanitize_text_field.
|
|
$sanitizer = $type_sanitizers[ $field_type ] ?? 'sanitize_text_field';
|
|
|
|
if ( is_callable( $sanitizer ) ) {
|
|
return call_user_func( $sanitizer, $value );
|
|
}
|
|
|
|
return sanitize_text_field( $value );
|
|
}
|
|
|
|
/**
|
|
* Sanitize all fields from a submission against the registered schema.
|
|
*
|
|
* Iterates over the SCHEMA (server-derived), not the user input.
|
|
* Unknown fields are silently dropped.
|
|
*
|
|
* @param array $raw_data The raw submitted data (field_name => value).
|
|
* @param array $field_schema The registered field schema (field_name => ['type' => ..., 'required' => ...]).
|
|
* @return array|WP_Error Sanitized data array or WP_Error on validation failure.
|
|
*/
|
|
public static function sanitize_submission( $raw_data, $field_schema ) {
|
|
$sanitized = [];
|
|
$errors = [];
|
|
|
|
foreach ( $field_schema as $field_name => $field_config ) {
|
|
$type = $field_config['type'] ?? 'text';
|
|
$required = $field_config['required'] ?? false;
|
|
$label = $field_config['label'] ?? $field_name;
|
|
$value = $raw_data[ $field_name ] ?? '';
|
|
|
|
// Check required fields.
|
|
$is_empty = is_array( $value ) ? empty( $value ) : '' === trim( (string) $value );
|
|
|
|
if ( $required && $is_empty ) {
|
|
$errors[] = $field_name;
|
|
continue;
|
|
}
|
|
|
|
// Checkbox-group: value arrives as an array from FormData's getAll().
|
|
// Sanitize each item individually, validate against allowed options, then join.
|
|
if ( 'checkbox-group' === $type ) {
|
|
$raw_values = is_array( $value ) ? $value : ( '' !== $value ? [ $value ] : [] );
|
|
$allowed_values = $field_config['allowedValues'] ?? [];
|
|
$clean_parts = [];
|
|
|
|
foreach ( $raw_values as $raw_item ) {
|
|
$clean_item = self::sanitize_field( (string) $raw_item, $type );
|
|
|
|
if ( '' === $clean_item ) {
|
|
continue;
|
|
}
|
|
|
|
if ( ! empty( $allowed_values ) && ! in_array( $clean_item, $allowed_values, true ) ) {
|
|
continue; // Skip invalid options silently.
|
|
}
|
|
|
|
$clean_parts[] = $clean_item;
|
|
}
|
|
|
|
$clean_value = implode( ', ', $clean_parts );
|
|
|
|
if ( $required && '' === $clean_value ) {
|
|
$errors[] = $field_name;
|
|
continue;
|
|
}
|
|
|
|
$sanitized[ $field_name ] = [
|
|
'value' => $clean_value,
|
|
'label' => sanitize_text_field( $label ),
|
|
'type' => $type,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
// Reject array-shaped values for scalar field types. Only
|
|
// checkbox-group expects an array (handled above); for every
|
|
// other type a non-scalar arrives only via tampered submissions.
|
|
// Casting an array to string would raise a PHP warning and
|
|
// persist the literal "Array" — fail cleanly instead.
|
|
if ( ! is_scalar( $value ) && null !== $value ) {
|
|
if ( $required ) {
|
|
$errors[] = $field_name;
|
|
continue;
|
|
}
|
|
|
|
$value = '';
|
|
}
|
|
|
|
// Sanitize the value.
|
|
$clean_value = self::sanitize_field( $value, $type );
|
|
|
|
// Required + invalid typed value: sanitize_email/sanitize_number/
|
|
// esc_url_raw return '' for malformed input. The pre-sanitize
|
|
// emptiness check above only catches values that were empty as
|
|
// submitted, so a required number field with "abc" or a required
|
|
// email with "notanemail" would otherwise persist as an empty
|
|
// string. Treat post-sanitize empty as the same failure mode.
|
|
if ( $required && '' === trim( (string) $clean_value ) ) {
|
|
$errors[] = $field_name;
|
|
continue;
|
|
}
|
|
|
|
// Validate select and radio values against allowed options.
|
|
if ( ( 'select' === $type || 'radio' === $type ) && '' !== $clean_value ) {
|
|
$allowed_values = $field_config['allowedValues'] ?? [];
|
|
|
|
if ( ! in_array( $clean_value, $allowed_values, true ) ) {
|
|
$errors[] = $field_name;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Validate checkbox values.
|
|
if ( 'checkbox' === $type && '' !== $clean_value ) {
|
|
$checked_value = $field_config['checkedValue'] ?? 'yes';
|
|
|
|
if ( $clean_value !== $checked_value ) {
|
|
// Tampered value — reject if required, otherwise blank it.
|
|
if ( $required ) {
|
|
$errors[] = $field_name;
|
|
continue;
|
|
}
|
|
|
|
$clean_value = '';
|
|
}
|
|
}
|
|
|
|
$sanitized[ $field_name ] = [
|
|
'value' => $clean_value,
|
|
'label' => sanitize_text_field( $label ),
|
|
'type' => $type,
|
|
];
|
|
}
|
|
|
|
if ( ! empty( $errors ) ) {
|
|
return new WP_Error(
|
|
'missing_required_fields',
|
|
__( 'Required fields are missing.', 'generateblocks-pro' ),
|
|
[ 'fields' => $errors ]
|
|
);
|
|
}
|
|
|
|
$mismatched = self::validate_matching_fields( $sanitized, $field_schema );
|
|
|
|
if ( ! empty( $mismatched['fields'] ) ) {
|
|
return new WP_Error(
|
|
'mismatched_fields',
|
|
__( 'Fields do not match.', 'generateblocks-pro' ),
|
|
[
|
|
'fields' => $mismatched['fields'],
|
|
'public_message' => $mismatched['public_message'],
|
|
]
|
|
);
|
|
}
|
|
|
|
return $sanitized;
|
|
}
|
|
|
|
/**
|
|
* Validate same-value field match rules after sanitization.
|
|
*
|
|
* @param array $sanitized Sanitized field data.
|
|
* @param array $field_schema Schema map.
|
|
* @return array Mismatched field names and public message.
|
|
*/
|
|
private static function validate_matching_fields( $sanitized, $field_schema ) {
|
|
$errors = [];
|
|
$public_message = '';
|
|
|
|
foreach ( $field_schema as $field_name => $field_config ) {
|
|
$matches_field = sanitize_key( $field_config['matchesField'] ?? '' );
|
|
|
|
if (
|
|
'' === $matches_field ||
|
|
$field_name === $matches_field ||
|
|
! isset( $field_schema[ $matches_field ] ) ||
|
|
! isset( $sanitized[ $field_name ], $sanitized[ $matches_field ] )
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$value = (string) ( $sanitized[ $field_name ]['value'] ?? '' );
|
|
$target_value = (string) ( $sanitized[ $matches_field ]['value'] ?? '' );
|
|
|
|
if ( $value !== $target_value ) {
|
|
$errors[] = $field_name;
|
|
|
|
if ( '' === $public_message ) {
|
|
$public_message = self::get_match_public_message(
|
|
$field_name,
|
|
$matches_field,
|
|
$sanitized,
|
|
$field_schema
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'fields' => $errors,
|
|
'public_message' => '' !== $public_message
|
|
? $public_message
|
|
: __( 'The matching fields must have the same value.', 'generateblocks-pro' ),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build the public message for a failed match rule.
|
|
*
|
|
* @param string $field_name Source field name.
|
|
* @param string $matches_field Target field name.
|
|
* @param array $sanitized Sanitized field data.
|
|
* @param array $field_schema Schema map.
|
|
* @return string Public-facing message.
|
|
*/
|
|
private static function get_match_public_message( $field_name, $matches_field, $sanitized, $field_schema ) {
|
|
$field_label = self::get_field_label( $field_name, $sanitized, $field_schema );
|
|
$target_label = self::get_field_label( $matches_field, $sanitized, $field_schema );
|
|
|
|
if ( '' !== $field_label && '' !== $target_label ) {
|
|
return sprintf(
|
|
/* translators: %1$s: source field label, %2$s: target field label. */
|
|
__( 'Please make sure "%1$s" matches "%2$s".', 'generateblocks-pro' ),
|
|
$field_label,
|
|
$target_label
|
|
);
|
|
}
|
|
|
|
if ( '' !== $target_label ) {
|
|
return sprintf(
|
|
/* translators: %s: target field label. */
|
|
__( 'This field must match "%s".', 'generateblocks-pro' ),
|
|
$target_label
|
|
);
|
|
}
|
|
|
|
return __( 'The matching fields must have the same value.', 'generateblocks-pro' );
|
|
}
|
|
|
|
/**
|
|
* Get a sanitized display label for a schema field.
|
|
*
|
|
* @param string $field_name Field name.
|
|
* @param array $sanitized Sanitized field data.
|
|
* @param array $field_schema Schema map.
|
|
* @return string Field label, or an empty string when unavailable.
|
|
*/
|
|
private static function get_field_label( $field_name, $sanitized, $field_schema ) {
|
|
$label = $sanitized[ $field_name ]['label'] ?? $field_schema[ $field_name ]['label'] ?? '';
|
|
$label = trim( sanitize_text_field( (string) $label ) );
|
|
|
|
return $label;
|
|
}
|
|
|
|
/**
|
|
* Sanitize a value for use in email headers.
|
|
*
|
|
* Strips newlines and carriage returns to prevent header injection.
|
|
*
|
|
* @param string $value The raw value.
|
|
* @return string Sanitized value safe for email headers.
|
|
*/
|
|
public static function sanitize_email_header( $value ) {
|
|
// Strip all variations of newlines that could be used for header injection.
|
|
$value = preg_replace( '/[\r\n\t]/', '', $value );
|
|
$value = str_replace( [ '%0a', '%0d', '%0A', '%0D' ], '', $value );
|
|
|
|
return sanitize_text_field( $value );
|
|
}
|
|
|
|
/**
|
|
* Replace {field:name} merge tags with sanitized field values.
|
|
*
|
|
* Uses sanitize_textarea_field() for textarea fields to preserve newlines,
|
|
* and sanitize_text_field() for everything else.
|
|
*
|
|
* @param mixed $text The text containing merge tags.
|
|
* @param array $sanitized_data The sanitized form data.
|
|
* @return string Text with merge tags replaced.
|
|
*/
|
|
public static function replace_merge_tags( $text, $sanitized_data ) {
|
|
$text = is_scalar( $text ) ? (string) $text : '';
|
|
|
|
if ( ! is_array( $sanitized_data ) || false === strpos( $text, '{' ) ) {
|
|
return $text;
|
|
}
|
|
|
|
$replaced = preg_replace_callback(
|
|
'/\{field:([a-z0-9_-]+)\}/i',
|
|
function ( $matches ) use ( $sanitized_data ) {
|
|
$field_name = sanitize_key( $matches[1] );
|
|
$field = $sanitized_data[ $field_name ] ?? null;
|
|
|
|
if ( is_array( $field ) ) {
|
|
$value = (string) ( $field['value'] ?? '' );
|
|
$type = $field['type'] ?? 'text';
|
|
|
|
return 'textarea' === $type
|
|
? sanitize_textarea_field( $value )
|
|
: sanitize_text_field( $value );
|
|
}
|
|
|
|
// Unknown tag — leave as-is for debugging.
|
|
return $matches[0];
|
|
},
|
|
$text
|
|
);
|
|
|
|
return is_string( $replaced ) ? $replaced : $text;
|
|
}
|
|
|
|
/**
|
|
* Sanitize a telephone number.
|
|
*
|
|
* @param string $value The raw value.
|
|
* @return string Sanitized telephone number.
|
|
*/
|
|
public static function sanitize_tel( $value ) {
|
|
return preg_replace( '/[^\d\s\+\-\(\)\.]/', '', $value );
|
|
}
|
|
|
|
/**
|
|
* Sanitize a number value.
|
|
*
|
|
* Rejects non-numeric input (returns '') instead of coercing to 0 —
|
|
* storing "0" for "abc" silently corrupts downstream data.
|
|
*
|
|
* @param string $value The raw value.
|
|
* @return string Sanitized number as string, or '' if not numeric.
|
|
*/
|
|
public static function sanitize_number( $value ) {
|
|
if ( '' === $value ) {
|
|
return '';
|
|
}
|
|
|
|
if ( ! is_numeric( $value ) ) {
|
|
return '';
|
|
}
|
|
|
|
$num = floatval( $value );
|
|
|
|
if ( is_infinite( $num ) || is_nan( $num ) ) {
|
|
return '';
|
|
}
|
|
|
|
return (string) $num;
|
|
}
|
|
}
|