| 1 |
<?php |
| 2 |
/** |
| 3 |
* Form Webhooks for Jetpack Contact Forms. |
| 4 |
* |
| 5 |
* @package automattic/jetpack-forms |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Automattic\Jetpack\Forms\Service; |
| 9 |
|
| 10 |
use Automattic\Jetpack\Forms\ContactForm\Feedback; |
| 11 |
use WP_Error; |
| 12 |
|
| 13 |
/** |
| 14 |
* Class Form_Webhooks |
| 15 |
* |
| 16 |
* Hooks on Jetpack's Contact form to send form data to configured webhooks. |
| 17 |
*/ |
| 18 |
class Form_Webhooks { |
| 19 |
/** |
| 20 |
* Singleton instance |
| 21 |
* |
| 22 |
* @var Form_Webhooks |
| 23 |
*/ |
| 24 |
private static $instance = null; |
| 25 |
|
| 26 |
private const FORMAT_URL_ENCODED = 'urlencoded'; |
| 27 |
private const FORMAT_JSON = 'json'; |
| 28 |
private const METHOD_POST = 'POST'; |
| 29 |
private const METHOD_GET = 'GET'; |
| 30 |
private const METHOD_PUT = 'PUT'; |
| 31 |
private const CONTENT_TYPE_URL_ENCODED = 'application/x-www-form-urlencoded'; |
| 32 |
private const CONTENT_TYPE_JSON = 'application/json'; |
| 33 |
|
| 34 |
/** |
| 35 |
* Valid methods for webhook requests. |
| 36 |
* |
| 37 |
* @var array |
| 38 |
*/ |
| 39 |
private const VALID_METHODS = array( self::METHOD_POST, self::METHOD_GET, self::METHOD_PUT ); |
| 40 |
|
| 41 |
/** |
| 42 |
* Valid formats for webhook requests. |
| 43 |
* |
| 44 |
* @var array |
| 45 |
*/ |
| 46 |
private const VALID_FORMATS_MAP = array( |
| 47 |
self::FORMAT_URL_ENCODED => self::CONTENT_TYPE_URL_ENCODED, |
| 48 |
self::FORMAT_JSON => self::CONTENT_TYPE_JSON, |
| 49 |
); |
| 50 |
|
| 51 |
/** |
| 52 |
* Initialize and return singleton instance. |
| 53 |
* |
| 54 |
* @return Form_Webhooks |
| 55 |
*/ |
| 56 |
public static function init() { |
| 57 |
if ( null === self::$instance ) { |
| 58 |
self::$instance = new self(); |
| 59 |
} |
| 60 |
|
| 61 |
return self::$instance; |
| 62 |
} |
| 63 |
|
| 64 |
/** |
| 65 |
* Form_Webhooks class constructor. |
| 66 |
* Hooks on `grunion_after_feedback_post_inserted` action to send form data to configured webhooks. |
| 67 |
* NOTE: As a singleton, this constructor is private and only callable from ::init, which will return the singleton instance, |
| 68 |
* effectively preventing multiple instances of this class (hence, multiple hooks triggering the webhook requests). |
| 69 |
*/ |
| 70 |
private function __construct() { |
| 71 |
add_action( 'grunion_after_feedback_post_inserted', array( $this, 'send_webhooks' ), 10, 4 ); |
| 72 |
} |
| 73 |
|
| 74 |
/** |
| 75 |
* Send form data to configured webhooks. |
| 76 |
* |
| 77 |
* @param int $post_id - the post_id for the CPT that is created. |
| 78 |
* @param array $fields - a collection of Automattic\Jetpack\Forms\ContactForm\Contact_Form_Field instances. |
| 79 |
* @param bool $is_spam - marked as spam by Akismet. |
| 80 |
* @param array $entry_values - extra fields added to from the contact form. |
| 81 |
* |
| 82 |
* @return null|void |
| 83 |
*/ |
| 84 |
public function send_webhooks( $post_id, $fields, $is_spam, $entry_values ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable |
| 85 |
// Get the Feedback object from the post_id |
| 86 |
$feedback = Feedback::get( $post_id ); |
| 87 |
|
| 88 |
if ( ! $feedback ) { |
| 89 |
return; |
| 90 |
} |
| 91 |
|
| 92 |
// if spam (hinted by akismet), don't process |
| 93 |
if ( $is_spam ) { |
| 94 |
return; |
| 95 |
} |
| 96 |
|
| 97 |
// Get the form from any of the fields to access form attributes (webhooks configuration) |
| 98 |
$form = null; |
| 99 |
foreach ( $fields as $field ) { |
| 100 |
if ( ! empty( $field->form ) ) { |
| 101 |
$form = $field->form; |
| 102 |
break; |
| 103 |
} |
| 104 |
} |
| 105 |
if ( ! $form || ! is_a( $form, 'Automattic\Jetpack\Forms\ContactForm\Contact_Form' ) ) { |
| 106 |
return; |
| 107 |
} |
| 108 |
|
| 109 |
$webhooks = $this->get_enabled_webhooks( $form->attributes ); |
| 110 |
|
| 111 |
if ( empty( $webhooks ) ) { |
| 112 |
return; |
| 113 |
} |
| 114 |
|
| 115 |
$form_data = $feedback->get_compiled_fields( 'webhook', 'id-value' ); |
| 116 |
|
| 117 |
// Iterate through each webhook and send the request |
| 118 |
foreach ( $webhooks as $webhook ) { |
| 119 |
$response = $this->send_webhook( $form_data, $webhook, $post_id ); |
| 120 |
$this->log_response_to_post_meta( $post_id, $response ); |
| 121 |
} |
| 122 |
} |
| 123 |
|
| 124 |
/** |
| 125 |
* Log the response to post meta. |
| 126 |
* |
| 127 |
* @param int $post_id The post ID. |
| 128 |
* @param array|WP_Error $response The response from the webhook or the WP_Error if the request failed. |
| 129 |
*/ |
| 130 |
private function log_response_to_post_meta( $post_id, $response ) { |
| 131 |
if ( is_wp_error( $response ) ) { |
| 132 |
update_post_meta( $post_id, '_jetpack_forms_webhook_error', sanitize_text_field( $response->get_error_message() ) ); |
| 133 |
$this->track_webhook_request( 'error' ); |
| 134 |
return $response; |
| 135 |
} |
| 136 |
|
| 137 |
$response_code = wp_remote_retrieve_response_code( $response ); |
| 138 |
$response_body = wp_remote_retrieve_body( $response ); |
| 139 |
$response_data = json_decode( $response_body, true ); |
| 140 |
|
| 141 |
$response_data = array( |
| 142 |
'timestamp' => gmdate( 'Y-m-d H:i:s', time() ), |
| 143 |
'http_code' => $response_code, |
| 144 |
'headers' => $this->normalize_response_headers( wp_remote_retrieve_headers( $response ) ), |
| 145 |
'body' => $response_data ?? $response_body, // If the response is not JSON, return the body as is. |
| 146 |
); |
| 147 |
|
| 148 |
update_post_meta( $post_id, '_jetpack_forms_webhook_response', sanitize_text_field( wp_json_encode( $response_data, JSON_UNESCAPED_SLASHES ) ) ); |
| 149 |
|
| 150 |
// Track success (2xx) or failure based on HTTP response code |
| 151 |
$status = ( $response_code >= 200 && $response_code < 300 ) ? 'success' : 'failed'; |
| 152 |
$this->track_webhook_request( $status ); |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* Normalize the headers of a webhook response into a plain array. |
| 157 |
* |
| 158 |
* The value returned by wp_remote_retrieve_headers() is usually a case-insensitive dictionary, |
| 159 |
* but it is a plain array when the response has no headers, and other shapes are possible when |
| 160 |
* a pre_http_request or http_response filter supplies the response. |
| 161 |
* |
| 162 |
* @param mixed $headers The value returned by wp_remote_retrieve_headers(). |
| 163 |
* @return array The headers as a plain array, empty when they cannot be read. |
| 164 |
*/ |
| 165 |
private function normalize_response_headers( $headers ) { |
| 166 |
if ( is_object( $headers ) && method_exists( $headers, 'getAll' ) ) { |
| 167 |
$headers = $headers->getAll(); |
| 168 |
} |
| 169 |
|
| 170 |
return is_array( $headers ) ? $headers : array(); |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Track webhook request stats. |
| 175 |
* |
| 176 |
* @param string $status The status of the webhook request ('success', 'failed', or 'error'). |
| 177 |
*/ |
| 178 |
private function track_webhook_request( $status ) { |
| 179 |
/** |
| 180 |
* Fires when a webhook request is made, allowing stats tracking. |
| 181 |
* |
| 182 |
* @since 7.0.0 |
| 183 |
* |
| 184 |
* @param string $stat_group The stat group name. |
| 185 |
* @param string $status The status of the request: 'success', 'failed', or 'error'. |
| 186 |
*/ |
| 187 |
do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_webhook_request', $status ); |
| 188 |
} |
| 189 |
|
| 190 |
/** |
| 191 |
* Check if an IP address is in a blocked range. |
| 192 |
* |
| 193 |
* @param string $ip The IP address to check. |
| 194 |
* @return bool True if the IP should be blocked. |
| 195 |
*/ |
| 196 |
private function is_blocked_ip( $ip ) { |
| 197 |
// Strip IPv6 zone identifier if present (e.g., fe80::1%eth0 -> fe80::1) |
| 198 |
$ip = preg_replace( '/%.*$/', '', $ip ); |
| 199 |
|
| 200 |
// Check IPv4 link-local addresses (169.254.0.0/16) |
| 201 |
// This range includes the AWS/cloud metadata endpoint (169.254.169.254) |
| 202 |
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { |
| 203 |
$ip_long = ip2long( $ip ); |
| 204 |
// 169.254.0.0/16 = 2851995648 to 2852061183 |
| 205 |
if ( $ip_long !== false && $ip_long >= 2851995648 && $ip_long <= 2852061183 ) { |
| 206 |
return true; |
| 207 |
} |
| 208 |
|
| 209 |
// Block Azure Wire Server (168.63.129.16) |
| 210 |
// Used for Azure internal services including Instance Metadata Service |
| 211 |
if ( $ip === '168.63.129.16' ) { |
| 212 |
return true; |
| 213 |
} |
| 214 |
|
| 215 |
return false; |
| 216 |
} |
| 217 |
|
| 218 |
// Check IPv6 addresses for private/internal ranges |
| 219 |
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { |
| 220 |
$ip_binary = inet_pton( $ip ); |
| 221 |
if ( $ip_binary === false || strlen( $ip_binary ) < 2 ) { |
| 222 |
return false; |
| 223 |
} |
| 224 |
|
| 225 |
// Check for IPv6 loopback (::1) using binary comparison |
| 226 |
// This handles all valid representations (e.g., 0:0:0:0:0:0:0:1, ::0:1) |
| 227 |
if ( $ip_binary === inet_pton( '::1' ) ) { |
| 228 |
return true; |
| 229 |
} |
| 230 |
|
| 231 |
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) |
| 232 |
// These are 16 bytes where first 10 are zeros, next 2 are 0xff, last 4 are IPv4 |
| 233 |
if ( strlen( $ip_binary ) === 16 && |
| 234 |
substr( $ip_binary, 0, 10 ) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" && |
| 235 |
substr( $ip_binary, 10, 2 ) === "\xff\xff" ) { |
| 236 |
// Extract the embedded IPv4 address (last 4 bytes) and check it |
| 237 |
$ipv4 = inet_ntop( substr( $ip_binary, 12, 4 ) ); |
| 238 |
if ( $ipv4 && $this->is_blocked_ip( $ipv4 ) ) { |
| 239 |
return true; |
| 240 |
} |
| 241 |
} |
| 242 |
|
| 243 |
$first_byte = ord( $ip_binary[0] ); |
| 244 |
$second_byte = ord( $ip_binary[1] ); |
| 245 |
|
| 246 |
// Check for IPv6 link-local addresses (fe80::/10) |
| 247 |
// First byte is 0xfe (254), second byte's top 2 bits are 10 (0x80-0xbf) |
| 248 |
if ( $first_byte === 0xfe && ( $second_byte & 0xc0 ) === 0x80 ) { |
| 249 |
return true; |
| 250 |
} |
| 251 |
|
| 252 |
// Check for IPv6 unique local addresses (fc00::/7) |
| 253 |
// Covers fc00::/8 and fd00::/8 (used for private networks, cloud metadata) |
| 254 |
if ( ( $first_byte & 0xfe ) === 0xfc ) { |
| 255 |
return true; |
| 256 |
} |
| 257 |
|
| 258 |
// Check for IPv6 site-local addresses (fec0::/10) - deprecated but still blocked |
| 259 |
// First byte is 0xfe (254), second byte's top 2 bits are 11 (0xc0-0xff) |
| 260 |
if ( $first_byte === 0xfe && ( $second_byte & 0xc0 ) === 0xc0 ) { |
| 261 |
return true; |
| 262 |
} |
| 263 |
} |
| 264 |
|
| 265 |
return false; |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Validate a webhook URL format, scheme, and check for blocked IP ranges. |
| 270 |
* |
| 271 |
* Performs validation: |
| 272 |
* - Valid URL format |
| 273 |
* - HTTPS scheme requirement |
| 274 |
* - Blocks link-local and private IP ranges not covered by wp_safe_remote_request() |
| 275 |
* |
| 276 |
* @param string $url The webhook URL to validate. |
| 277 |
* @return bool|WP_Error True if valid, WP_Error with reason if invalid. |
| 278 |
*/ |
| 279 |
private function validate_webhook_url( $url ) { |
| 280 |
// Validate URL format before parsing to catch malformed URLs |
| 281 |
// e.g., "https:///example.com" or URLs with unusual syntax |
| 282 |
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { |
| 283 |
return new WP_Error( 'invalid_url', __( 'Invalid webhook URL format.', 'jetpack-forms' ) ); |
| 284 |
} |
| 285 |
|
| 286 |
$parsed = wp_parse_url( $url ); |
| 287 |
|
| 288 |
if ( ! $parsed || empty( $parsed['host'] ) ) { |
| 289 |
return new WP_Error( 'invalid_url', __( 'Invalid webhook URL format.', 'jetpack-forms' ) ); |
| 290 |
} |
| 291 |
|
| 292 |
// Require HTTPS scheme |
| 293 |
if ( empty( $parsed['scheme'] ) || strtolower( $parsed['scheme'] ) !== 'https' ) { |
| 294 |
return new WP_Error( 'https_required', __( 'Webhook URL must use HTTPS.', 'jetpack-forms' ) ); |
| 295 |
} |
| 296 |
|
| 297 |
// Check for blocked IP ranges (link-local, private IPv6) |
| 298 |
$host = $parsed['host']; |
| 299 |
// Strip brackets from IPv6 addresses if present (e.g., [::1] -> ::1) |
| 300 |
$host = trim( $host, '[]' ); |
| 301 |
|
| 302 |
// URL-decode the host to prevent bypass attempts using encoded characters |
| 303 |
// e.g., 169%2e254%2e169%2e254 -> 169.254.169.254 |
| 304 |
// e.g., fe80::1%25eth0 -> fe80::1%eth0 (zone identifier becomes visible) |
| 305 |
$host = rawurldecode( $host ); |
| 306 |
|
| 307 |
// Strip IPv6 zone identifier if present (e.g., fe80::1%eth0 -> fe80::1) |
| 308 |
// Zone identifiers are used for link-local addresses and should be blocked |
| 309 |
// Must happen AFTER URL decoding since %25 decodes to % |
| 310 |
if ( strpos( $host, '%' ) !== false ) { |
| 311 |
$host = preg_replace( '/%.*$/', '', $host ); |
| 312 |
} |
| 313 |
|
| 314 |
// If host is already an IP, check it directly |
| 315 |
if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { |
| 316 |
if ( $this->is_blocked_ip( $host ) ) { |
| 317 |
return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) ); |
| 318 |
} |
| 319 |
return true; |
| 320 |
} |
| 321 |
|
| 322 |
// For hostnames, check IPv4 via gethostbyname |
| 323 |
$ipv4 = gethostbyname( $host ); |
| 324 |
if ( $ipv4 !== $host && $this->is_blocked_ip( $ipv4 ) ) { |
| 325 |
return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) ); |
| 326 |
} |
| 327 |
|
| 328 |
// Check IPv6 via DNS AAAA records (gethostbyname only resolves IPv4) |
| 329 |
// This catches hostnames that resolve to blocked IPv6 addresses |
| 330 |
if ( function_exists( 'dns_get_record' ) ) { |
| 331 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record may fail on some systems |
| 332 |
$aaaa_records = @dns_get_record( $host, DNS_AAAA ); |
| 333 |
if ( $aaaa_records ) { |
| 334 |
foreach ( $aaaa_records as $record ) { |
| 335 |
if ( isset( $record['ipv6'] ) && $this->is_blocked_ip( $record['ipv6'] ) ) { |
| 336 |
return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) ); |
| 337 |
} |
| 338 |
} |
| 339 |
} |
| 340 |
} |
| 341 |
|
| 342 |
return true; |
| 343 |
} |
| 344 |
|
| 345 |
/** |
| 346 |
* Get the enabled webhooks from the form attributes. |
| 347 |
* |
| 348 |
* @param array $attributes - the attributes of the contact form. |
| 349 |
* @return array Array of enabled webhooks. |
| 350 |
*/ |
| 351 |
private function get_enabled_webhooks( $attributes = array() ) { |
| 352 |
if ( empty( $attributes['webhooks'] ) || ! is_array( $attributes['webhooks'] ) ) { |
| 353 |
return array(); |
| 354 |
} |
| 355 |
|
| 356 |
$enabled_webhooks = array(); |
| 357 |
foreach ( $attributes['webhooks'] as $webhook ) { |
| 358 |
$defaults = array( |
| 359 |
'webhook_id' => '', |
| 360 |
'url' => '', |
| 361 |
'method' => self::METHOD_POST, |
| 362 |
'verified' => false, |
| 363 |
'format' => self::FORMAT_JSON, |
| 364 |
'enabled' => false, |
| 365 |
); |
| 366 |
|
| 367 |
$setup = wp_parse_args( |
| 368 |
is_array( $webhook ) && ! empty( $webhook ) ? $webhook : array(), |
| 369 |
$defaults |
| 370 |
); |
| 371 |
|
| 372 |
// Validate webhook configuration |
| 373 |
if ( empty( $setup['enabled'] ) ) { |
| 374 |
continue; |
| 375 |
} |
| 376 |
// Validate webhook configuration |
| 377 |
if ( empty( $setup['url'] ) ) { |
| 378 |
do_action( 'jetpack_forms_log', 'webhook_skipped', 'url_empty' ); |
| 379 |
continue; |
| 380 |
} |
| 381 |
|
| 382 |
// Validate URL for security (SSRF protection) |
| 383 |
$url_validation = $this->validate_webhook_url( $setup['url'] ); |
| 384 |
if ( is_wp_error( $url_validation ) ) { |
| 385 |
do_action( 'jetpack_forms_log', 'webhook_skipped', $url_validation->get_error_code(), $setup ); |
| 386 |
continue; |
| 387 |
} |
| 388 |
|
| 389 |
// Validate format |
| 390 |
if ( ! array_key_exists( strtolower( $setup['format'] ), self::VALID_FORMATS_MAP ) ) { |
| 391 |
do_action( 'jetpack_forms_log', 'webhook_skipped', 'format_invalid', $setup ); |
| 392 |
continue; |
| 393 |
} |
| 394 |
|
| 395 |
// Validate method |
| 396 |
if ( ! in_array( strtoupper( $setup['method'] ), self::VALID_METHODS, true ) ) { |
| 397 |
do_action( 'jetpack_forms_log', 'webhook_skipped', 'method_invalid', $setup ); |
| 398 |
continue; |
| 399 |
} |
| 400 |
|
| 401 |
$enabled_webhooks[] = array( |
| 402 |
'webhook_id' => $setup['webhook_id'], |
| 403 |
'url' => $setup['url'], |
| 404 |
'format' => $setup['format'], |
| 405 |
'method' => $setup['method'], |
| 406 |
); |
| 407 |
} |
| 408 |
|
| 409 |
return $enabled_webhooks; |
| 410 |
} |
| 411 |
|
| 412 |
/** |
| 413 |
* Send webhook request |
| 414 |
* |
| 415 |
* Uses wp_safe_remote_request() for built-in SSRF protection including redirect validation. |
| 416 |
* |
| 417 |
* @param array $data The data key/value pairs to send. |
| 418 |
* @param array $webhook Webhook configuration. |
| 419 |
* @param int $feedback_id The unique identifier for the feedback post. |
| 420 |
* |
| 421 |
* @return array|WP_Error The result value from wp_safe_remote_request |
| 422 |
*/ |
| 423 |
private function send_webhook( $data, $webhook, $feedback_id ) { |
| 424 |
global $wp_version; |
| 425 |
|
| 426 |
/** |
| 427 |
* Filters the form data before sending it to the webhook. |
| 428 |
* |
| 429 |
* Allows developers to modify or augment the form data before it's sent to the webhook endpoint. |
| 430 |
* NOTE: data has to be the first argument so it can be defaulted. |
| 431 |
* |
| 432 |
* @since 6.21.0 |
| 433 |
* |
| 434 |
* @param array $form_data The form data to be sent (field IDs as keys, values as values). |
| 435 |
* @param string $webhook_id The unique identifier for this webhook. |
| 436 |
* @param int $feedback_id The unique identifier for the feedback post. |
| 437 |
* |
| 438 |
* @return array The form data to be sent (field IDs as keys, values as values). |
| 439 |
*/ |
| 440 |
$data = apply_filters( 'jetpack_forms_before_webhook_request', $data, $webhook['webhook_id'], $feedback_id ); |
| 441 |
|
| 442 |
$user_agent = "WordPress/{$wp_version} | Jetpack/" . constant( 'JETPACK__VERSION' ) . '; ' . get_bloginfo( 'url' ); |
| 443 |
$url = $webhook['url']; |
| 444 |
$format = self::VALID_FORMATS_MAP[ $webhook['format'] ]; |
| 445 |
$method = $webhook['method']; |
| 446 |
// Encode body based on format |
| 447 |
$body = $webhook['format'] === self::FORMAT_JSON ? wp_json_encode( $data, JSON_UNESCAPED_SLASHES ) : $data; |
| 448 |
$args = array( |
| 449 |
'method' => $method, |
| 450 |
'body' => $body, |
| 451 |
'headers' => array( |
| 452 |
'Content-Type' => $format, |
| 453 |
'user-agent' => $user_agent, |
| 454 |
), |
| 455 |
'sslverify' => true, |
| 456 |
); |
| 457 |
|
| 458 |
// Use wp_safe_remote_request for built-in SSRF protection and redirect validation |
| 459 |
return wp_safe_remote_request( $url, $args ); |
| 460 |
} |
| 461 |
} |
| 462 |
|