Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.30% |
142 / 149 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
| Form_Webhooks | |
95.30% |
142 / 149 |
|
55.56% |
5 / 9 |
66 | |
0.00% |
0 / 1 |
| init | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| send_webhooks | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
9.09 | |||
| log_response_to_post_meta | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| track_webhook_request | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_blocked_ip | |
96.55% |
28 / 29 |
|
0.00% |
0 / 1 |
20 | |||
| validate_webhook_url | |
92.31% |
24 / 26 |
|
0.00% |
0 / 1 |
16.12 | |||
| get_enabled_webhooks | |
94.74% |
36 / 38 |
|
0.00% |
0 / 1 |
11.02 | |||
| send_webhook | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
| 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' => wp_remote_retrieve_headers( $response )->getAll(), |
| 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 | * Track webhook request stats. |
| 157 | * |
| 158 | * @param string $status The status of the webhook request ('success', 'failed', or 'error'). |
| 159 | */ |
| 160 | private function track_webhook_request( $status ) { |
| 161 | /** |
| 162 | * Fires when a webhook request is made, allowing stats tracking. |
| 163 | * |
| 164 | * @since 7.0.0 |
| 165 | * |
| 166 | * @param string $stat_group The stat group name. |
| 167 | * @param string $status The status of the request: 'success', 'failed', or 'error'. |
| 168 | */ |
| 169 | do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_webhook_request', $status ); |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Check if an IP address is in a blocked range. |
| 174 | * |
| 175 | * @param string $ip The IP address to check. |
| 176 | * @return bool True if the IP should be blocked. |
| 177 | */ |
| 178 | private function is_blocked_ip( $ip ) { |
| 179 | // Strip IPv6 zone identifier if present (e.g., fe80::1%eth0 -> fe80::1) |
| 180 | $ip = preg_replace( '/%.*$/', '', $ip ); |
| 181 | |
| 182 | // Check IPv4 link-local addresses (169.254.0.0/16) |
| 183 | // This range includes the AWS/cloud metadata endpoint (169.254.169.254) |
| 184 | if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { |
| 185 | $ip_long = ip2long( $ip ); |
| 186 | // 169.254.0.0/16 = 2851995648 to 2852061183 |
| 187 | if ( $ip_long !== false && $ip_long >= 2851995648 && $ip_long <= 2852061183 ) { |
| 188 | return true; |
| 189 | } |
| 190 | |
| 191 | // Block Azure Wire Server (168.63.129.16) |
| 192 | // Used for Azure internal services including Instance Metadata Service |
| 193 | if ( $ip === '168.63.129.16' ) { |
| 194 | return true; |
| 195 | } |
| 196 | |
| 197 | return false; |
| 198 | } |
| 199 | |
| 200 | // Check IPv6 addresses for private/internal ranges |
| 201 | if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { |
| 202 | $ip_binary = inet_pton( $ip ); |
| 203 | if ( $ip_binary === false || strlen( $ip_binary ) < 2 ) { |
| 204 | return false; |
| 205 | } |
| 206 | |
| 207 | // Check for IPv6 loopback (::1) using binary comparison |
| 208 | // This handles all valid representations (e.g., 0:0:0:0:0:0:0:1, ::0:1) |
| 209 | if ( $ip_binary === inet_pton( '::1' ) ) { |
| 210 | return true; |
| 211 | } |
| 212 | |
| 213 | // Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) |
| 214 | // These are 16 bytes where first 10 are zeros, next 2 are 0xff, last 4 are IPv4 |
| 215 | // phpcs:ignore Generic.Strings.UnnecessaryStringConcat.Found -- string concat for readability |
| 216 | if ( strlen( $ip_binary ) === 16 && |
| 217 | substr( $ip_binary, 0, 10 ) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" && |
| 218 | substr( $ip_binary, 10, 2 ) === "\xff\xff" ) { |
| 219 | // Extract the embedded IPv4 address (last 4 bytes) and check it |
| 220 | $ipv4 = inet_ntop( substr( $ip_binary, 12, 4 ) ); |
| 221 | if ( $ipv4 && $this->is_blocked_ip( $ipv4 ) ) { |
| 222 | return true; |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | $first_byte = ord( $ip_binary[0] ); |
| 227 | $second_byte = ord( $ip_binary[1] ); |
| 228 | |
| 229 | // Check for IPv6 link-local addresses (fe80::/10) |
| 230 | // First byte is 0xfe (254), second byte's top 2 bits are 10 (0x80-0xbf) |
| 231 | if ( $first_byte === 0xfe && ( $second_byte & 0xc0 ) === 0x80 ) { |
| 232 | return true; |
| 233 | } |
| 234 | |
| 235 | // Check for IPv6 unique local addresses (fc00::/7) |
| 236 | // Covers fc00::/8 and fd00::/8 (used for private networks, cloud metadata) |
| 237 | if ( ( $first_byte & 0xfe ) === 0xfc ) { |
| 238 | return true; |
| 239 | } |
| 240 | |
| 241 | // Check for IPv6 site-local addresses (fec0::/10) - deprecated but still blocked |
| 242 | // First byte is 0xfe (254), second byte's top 2 bits are 11 (0xc0-0xff) |
| 243 | if ( $first_byte === 0xfe && ( $second_byte & 0xc0 ) === 0xc0 ) { |
| 244 | return true; |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | return false; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Validate a webhook URL format, scheme, and check for blocked IP ranges. |
| 253 | * |
| 254 | * Performs validation: |
| 255 | * - Valid URL format |
| 256 | * - HTTPS scheme requirement |
| 257 | * - Blocks link-local and private IP ranges not covered by wp_safe_remote_request() |
| 258 | * |
| 259 | * @param string $url The webhook URL to validate. |
| 260 | * @return bool|WP_Error True if valid, WP_Error with reason if invalid. |
| 261 | */ |
| 262 | private function validate_webhook_url( $url ) { |
| 263 | // Validate URL format before parsing to catch malformed URLs |
| 264 | // e.g., "https:///example.com" or URLs with unusual syntax |
| 265 | if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { |
| 266 | return new WP_Error( 'invalid_url', __( 'Invalid webhook URL format.', 'jetpack-forms' ) ); |
| 267 | } |
| 268 | |
| 269 | $parsed = wp_parse_url( $url ); |
| 270 | |
| 271 | if ( ! $parsed || empty( $parsed['host'] ) ) { |
| 272 | return new WP_Error( 'invalid_url', __( 'Invalid webhook URL format.', 'jetpack-forms' ) ); |
| 273 | } |
| 274 | |
| 275 | // Require HTTPS scheme |
| 276 | if ( empty( $parsed['scheme'] ) || strtolower( $parsed['scheme'] ) !== 'https' ) { |
| 277 | return new WP_Error( 'https_required', __( 'Webhook URL must use HTTPS.', 'jetpack-forms' ) ); |
| 278 | } |
| 279 | |
| 280 | // Check for blocked IP ranges (link-local, private IPv6) |
| 281 | $host = $parsed['host']; |
| 282 | // Strip brackets from IPv6 addresses if present (e.g., [::1] -> ::1) |
| 283 | $host = trim( $host, '[]' ); |
| 284 | |
| 285 | // URL-decode the host to prevent bypass attempts using encoded characters |
| 286 | // e.g., 169%2e254%2e169%2e254 -> 169.254.169.254 |
| 287 | // e.g., fe80::1%25eth0 -> fe80::1%eth0 (zone identifier becomes visible) |
| 288 | $host = rawurldecode( $host ); |
| 289 | |
| 290 | // Strip IPv6 zone identifier if present (e.g., fe80::1%eth0 -> fe80::1) |
| 291 | // Zone identifiers are used for link-local addresses and should be blocked |
| 292 | // Must happen AFTER URL decoding since %25 decodes to % |
| 293 | if ( strpos( $host, '%' ) !== false ) { |
| 294 | $host = preg_replace( '/%.*$/', '', $host ); |
| 295 | } |
| 296 | |
| 297 | // If host is already an IP, check it directly |
| 298 | if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { |
| 299 | if ( $this->is_blocked_ip( $host ) ) { |
| 300 | return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) ); |
| 301 | } |
| 302 | return true; |
| 303 | } |
| 304 | |
| 305 | // For hostnames, check IPv4 via gethostbyname |
| 306 | $ipv4 = gethostbyname( $host ); |
| 307 | if ( $ipv4 !== $host && $this->is_blocked_ip( $ipv4 ) ) { |
| 308 | return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) ); |
| 309 | } |
| 310 | |
| 311 | // Check IPv6 via DNS AAAA records (gethostbyname only resolves IPv4) |
| 312 | // This catches hostnames that resolve to blocked IPv6 addresses |
| 313 | if ( function_exists( 'dns_get_record' ) ) { |
| 314 | // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record may fail on some systems |
| 315 | $aaaa_records = @dns_get_record( $host, DNS_AAAA ); |
| 316 | if ( $aaaa_records ) { |
| 317 | foreach ( $aaaa_records as $record ) { |
| 318 | if ( isset( $record['ipv6'] ) && $this->is_blocked_ip( $record['ipv6'] ) ) { |
| 319 | return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) ); |
| 320 | } |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | return true; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Get the enabled webhooks from the form attributes. |
| 330 | * |
| 331 | * @param array $attributes - the attributes of the contact form. |
| 332 | * @return array Array of enabled webhooks. |
| 333 | */ |
| 334 | private function get_enabled_webhooks( $attributes = array() ) { |
| 335 | if ( empty( $attributes['webhooks'] ) || ! is_array( $attributes['webhooks'] ) ) { |
| 336 | return array(); |
| 337 | } |
| 338 | |
| 339 | $enabled_webhooks = array(); |
| 340 | foreach ( $attributes['webhooks'] as $webhook ) { |
| 341 | $defaults = array( |
| 342 | 'webhook_id' => '', |
| 343 | 'url' => '', |
| 344 | 'method' => self::METHOD_POST, |
| 345 | 'verified' => false, |
| 346 | 'format' => self::FORMAT_JSON, |
| 347 | 'enabled' => false, |
| 348 | ); |
| 349 | |
| 350 | $setup = wp_parse_args( |
| 351 | is_array( $webhook ) && ! empty( $webhook ) ? $webhook : array(), |
| 352 | $defaults |
| 353 | ); |
| 354 | |
| 355 | // Validate webhook configuration |
| 356 | if ( empty( $setup['enabled'] ) ) { |
| 357 | continue; |
| 358 | } |
| 359 | // Validate webhook configuration |
| 360 | if ( empty( $setup['url'] ) ) { |
| 361 | do_action( 'jetpack_forms_log', 'webhook_skipped', 'url_empty' ); |
| 362 | continue; |
| 363 | } |
| 364 | |
| 365 | // Validate URL for security (SSRF protection) |
| 366 | $url_validation = $this->validate_webhook_url( $setup['url'] ); |
| 367 | if ( is_wp_error( $url_validation ) ) { |
| 368 | do_action( 'jetpack_forms_log', 'webhook_skipped', $url_validation->get_error_code(), $setup ); |
| 369 | continue; |
| 370 | } |
| 371 | |
| 372 | // Validate format |
| 373 | if ( ! array_key_exists( strtolower( $setup['format'] ), self::VALID_FORMATS_MAP ) ) { |
| 374 | do_action( 'jetpack_forms_log', 'webhook_skipped', 'format_invalid', $setup ); |
| 375 | continue; |
| 376 | } |
| 377 | |
| 378 | // Validate method |
| 379 | if ( ! in_array( strtoupper( $setup['method'] ), self::VALID_METHODS, true ) ) { |
| 380 | do_action( 'jetpack_forms_log', 'webhook_skipped', 'method_invalid', $setup ); |
| 381 | continue; |
| 382 | } |
| 383 | |
| 384 | $enabled_webhooks[] = array( |
| 385 | 'webhook_id' => $setup['webhook_id'], |
| 386 | 'url' => $setup['url'], |
| 387 | 'format' => $setup['format'], |
| 388 | 'method' => $setup['method'], |
| 389 | ); |
| 390 | } |
| 391 | |
| 392 | return $enabled_webhooks; |
| 393 | } |
| 394 | |
| 395 | /** |
| 396 | * Send webhook request |
| 397 | * |
| 398 | * Uses wp_safe_remote_request() for built-in SSRF protection including redirect validation. |
| 399 | * |
| 400 | * @param array $data The data key/value pairs to send. |
| 401 | * @param array $webhook Webhook configuration. |
| 402 | * @param int $feedback_id The unique identifier for the feedback post. |
| 403 | * |
| 404 | * @return array|WP_Error The result value from wp_safe_remote_request |
| 405 | */ |
| 406 | private function send_webhook( $data, $webhook, $feedback_id ) { |
| 407 | global $wp_version; |
| 408 | |
| 409 | /** |
| 410 | * Filters the form data before sending it to the webhook. |
| 411 | * |
| 412 | * Allows developers to modify or augment the form data before it's sent to the webhook endpoint. |
| 413 | * NOTE: data has to be the first argument so it can be defaulted. |
| 414 | * |
| 415 | * @since 6.21.0 |
| 416 | * |
| 417 | * @param array $form_data The form data to be sent (field IDs as keys, values as values). |
| 418 | * @param string $webhook_id The unique identifier for this webhook. |
| 419 | * @param int $feedback_id The unique identifier for the feedback post. |
| 420 | * |
| 421 | * @return array The form data to be sent (field IDs as keys, values as values). |
| 422 | */ |
| 423 | $data = apply_filters( 'jetpack_forms_before_webhook_request', $data, $webhook['webhook_id'], $feedback_id ); |
| 424 | |
| 425 | $user_agent = "WordPress/{$wp_version} | Jetpack/" . constant( 'JETPACK__VERSION' ) . '; ' . get_bloginfo( 'url' ); |
| 426 | $url = $webhook['url']; |
| 427 | $format = self::VALID_FORMATS_MAP[ $webhook['format'] ]; |
| 428 | $method = $webhook['method']; |
| 429 | // Encode body based on format |
| 430 | $body = $webhook['format'] === self::FORMAT_JSON ? wp_json_encode( $data, JSON_UNESCAPED_SLASHES ) : $data; |
| 431 | $args = array( |
| 432 | 'method' => $method, |
| 433 | 'body' => $body, |
| 434 | 'headers' => array( |
| 435 | 'Content-Type' => $format, |
| 436 | 'user-agent' => $user_agent, |
| 437 | ), |
| 438 | 'sslverify' => true, |
| 439 | ); |
| 440 | |
| 441 | // Use wp_safe_remote_request for built-in SSRF protection and redirect validation |
| 442 | return wp_safe_remote_request( $url, $args ); |
| 443 | } |
| 444 | } |