PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / service / class-form-webhooks.php

class-form-webhooks.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-forms/src/service/class-form-webhooks.php

462 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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