PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.2
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.2
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-forms / src / service / class-form-webhooks.php
jetpack / jetpack_vendor / automattic / jetpack-forms / src / service Last commit date
class-form-webhooks.php 1 month ago class-google-drive.php 9 months ago class-hostinger-reach-integration.php 9 months ago class-mailpoet-integration.php 1 month ago class-post-to-url.php 1 month ago
class-form-webhooks.php
444 lines
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 if ( strlen( $ip_binary ) === 16 &&
216 substr( $ip_binary, 0, 10 ) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" &&
217 substr( $ip_binary, 10, 2 ) === "\xff\xff" ) {
218 // Extract the embedded IPv4 address (last 4 bytes) and check it
219 $ipv4 = inet_ntop( substr( $ip_binary, 12, 4 ) );
220 if ( $ipv4 && $this->is_blocked_ip( $ipv4 ) ) {
221 return true;
222 }
223 }
224
225 $first_byte = ord( $ip_binary[0] );
226 $second_byte = ord( $ip_binary[1] );
227
228 // Check for IPv6 link-local addresses (fe80::/10)
229 // First byte is 0xfe (254), second byte's top 2 bits are 10 (0x80-0xbf)
230 if ( $first_byte === 0xfe && ( $second_byte & 0xc0 ) === 0x80 ) {
231 return true;
232 }
233
234 // Check for IPv6 unique local addresses (fc00::/7)
235 // Covers fc00::/8 and fd00::/8 (used for private networks, cloud metadata)
236 if ( ( $first_byte & 0xfe ) === 0xfc ) {
237 return true;
238 }
239
240 // Check for IPv6 site-local addresses (fec0::/10) - deprecated but still blocked
241 // First byte is 0xfe (254), second byte's top 2 bits are 11 (0xc0-0xff)
242 if ( $first_byte === 0xfe && ( $second_byte & 0xc0 ) === 0xc0 ) {
243 return true;
244 }
245 }
246
247 return false;
248 }
249
250 /**
251 * Validate a webhook URL format, scheme, and check for blocked IP ranges.
252 *
253 * Performs validation:
254 * - Valid URL format
255 * - HTTPS scheme requirement
256 * - Blocks link-local and private IP ranges not covered by wp_safe_remote_request()
257 *
258 * @param string $url The webhook URL to validate.
259 * @return bool|WP_Error True if valid, WP_Error with reason if invalid.
260 */
261 private function validate_webhook_url( $url ) {
262 // Validate URL format before parsing to catch malformed URLs
263 // e.g., "https:///example.com" or URLs with unusual syntax
264 if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
265 return new WP_Error( 'invalid_url', __( 'Invalid webhook URL format.', 'jetpack-forms' ) );
266 }
267
268 $parsed = wp_parse_url( $url );
269
270 if ( ! $parsed || empty( $parsed['host'] ) ) {
271 return new WP_Error( 'invalid_url', __( 'Invalid webhook URL format.', 'jetpack-forms' ) );
272 }
273
274 // Require HTTPS scheme
275 if ( empty( $parsed['scheme'] ) || strtolower( $parsed['scheme'] ) !== 'https' ) {
276 return new WP_Error( 'https_required', __( 'Webhook URL must use HTTPS.', 'jetpack-forms' ) );
277 }
278
279 // Check for blocked IP ranges (link-local, private IPv6)
280 $host = $parsed['host'];
281 // Strip brackets from IPv6 addresses if present (e.g., [::1] -> ::1)
282 $host = trim( $host, '[]' );
283
284 // URL-decode the host to prevent bypass attempts using encoded characters
285 // e.g., 169%2e254%2e169%2e254 -> 169.254.169.254
286 // e.g., fe80::1%25eth0 -> fe80::1%eth0 (zone identifier becomes visible)
287 $host = rawurldecode( $host );
288
289 // Strip IPv6 zone identifier if present (e.g., fe80::1%eth0 -> fe80::1)
290 // Zone identifiers are used for link-local addresses and should be blocked
291 // Must happen AFTER URL decoding since %25 decodes to %
292 if ( strpos( $host, '%' ) !== false ) {
293 $host = preg_replace( '/%.*$/', '', $host );
294 }
295
296 // If host is already an IP, check it directly
297 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
298 if ( $this->is_blocked_ip( $host ) ) {
299 return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) );
300 }
301 return true;
302 }
303
304 // For hostnames, check IPv4 via gethostbyname
305 $ipv4 = gethostbyname( $host );
306 if ( $ipv4 !== $host && $this->is_blocked_ip( $ipv4 ) ) {
307 return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) );
308 }
309
310 // Check IPv6 via DNS AAAA records (gethostbyname only resolves IPv4)
311 // This catches hostnames that resolve to blocked IPv6 addresses
312 if ( function_exists( 'dns_get_record' ) ) {
313 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- dns_get_record may fail on some systems
314 $aaaa_records = @dns_get_record( $host, DNS_AAAA );
315 if ( $aaaa_records ) {
316 foreach ( $aaaa_records as $record ) {
317 if ( isset( $record['ipv6'] ) && $this->is_blocked_ip( $record['ipv6'] ) ) {
318 return new WP_Error( 'blocked_ip', __( 'Webhook URL cannot point to private or internal networks.', 'jetpack-forms' ) );
319 }
320 }
321 }
322 }
323
324 return true;
325 }
326
327 /**
328 * Get the enabled webhooks from the form attributes.
329 *
330 * @param array $attributes - the attributes of the contact form.
331 * @return array Array of enabled webhooks.
332 */
333 private function get_enabled_webhooks( $attributes = array() ) {
334 if ( empty( $attributes['webhooks'] ) || ! is_array( $attributes['webhooks'] ) ) {
335 return array();
336 }
337
338 $enabled_webhooks = array();
339 foreach ( $attributes['webhooks'] as $webhook ) {
340 $defaults = array(
341 'webhook_id' => '',
342 'url' => '',
343 'method' => self::METHOD_POST,
344 'verified' => false,
345 'format' => self::FORMAT_JSON,
346 'enabled' => false,
347 );
348
349 $setup = wp_parse_args(
350 is_array( $webhook ) && ! empty( $webhook ) ? $webhook : array(),
351 $defaults
352 );
353
354 // Validate webhook configuration
355 if ( empty( $setup['enabled'] ) ) {
356 continue;
357 }
358 // Validate webhook configuration
359 if ( empty( $setup['url'] ) ) {
360 do_action( 'jetpack_forms_log', 'webhook_skipped', 'url_empty' );
361 continue;
362 }
363
364 // Validate URL for security (SSRF protection)
365 $url_validation = $this->validate_webhook_url( $setup['url'] );
366 if ( is_wp_error( $url_validation ) ) {
367 do_action( 'jetpack_forms_log', 'webhook_skipped', $url_validation->get_error_code(), $setup );
368 continue;
369 }
370
371 // Validate format
372 if ( ! array_key_exists( strtolower( $setup['format'] ), self::VALID_FORMATS_MAP ) ) {
373 do_action( 'jetpack_forms_log', 'webhook_skipped', 'format_invalid', $setup );
374 continue;
375 }
376
377 // Validate method
378 if ( ! in_array( strtoupper( $setup['method'] ), self::VALID_METHODS, true ) ) {
379 do_action( 'jetpack_forms_log', 'webhook_skipped', 'method_invalid', $setup );
380 continue;
381 }
382
383 $enabled_webhooks[] = array(
384 'webhook_id' => $setup['webhook_id'],
385 'url' => $setup['url'],
386 'format' => $setup['format'],
387 'method' => $setup['method'],
388 );
389 }
390
391 return $enabled_webhooks;
392 }
393
394 /**
395 * Send webhook request
396 *
397 * Uses wp_safe_remote_request() for built-in SSRF protection including redirect validation.
398 *
399 * @param array $data The data key/value pairs to send.
400 * @param array $webhook Webhook configuration.
401 * @param int $feedback_id The unique identifier for the feedback post.
402 *
403 * @return array|WP_Error The result value from wp_safe_remote_request
404 */
405 private function send_webhook( $data, $webhook, $feedback_id ) {
406 global $wp_version;
407
408 /**
409 * Filters the form data before sending it to the webhook.
410 *
411 * Allows developers to modify or augment the form data before it's sent to the webhook endpoint.
412 * NOTE: data has to be the first argument so it can be defaulted.
413 *
414 * @since 6.21.0
415 *
416 * @param array $form_data The form data to be sent (field IDs as keys, values as values).
417 * @param string $webhook_id The unique identifier for this webhook.
418 * @param int $feedback_id The unique identifier for the feedback post.
419 *
420 * @return array The form data to be sent (field IDs as keys, values as values).
421 */
422 $data = apply_filters( 'jetpack_forms_before_webhook_request', $data, $webhook['webhook_id'], $feedback_id );
423
424 $user_agent = "WordPress/{$wp_version} | Jetpack/" . constant( 'JETPACK__VERSION' ) . '; ' . get_bloginfo( 'url' );
425 $url = $webhook['url'];
426 $format = self::VALID_FORMATS_MAP[ $webhook['format'] ];
427 $method = $webhook['method'];
428 // Encode body based on format
429 $body = $webhook['format'] === self::FORMAT_JSON ? wp_json_encode( $data, JSON_UNESCAPED_SLASHES ) : $data;
430 $args = array(
431 'method' => $method,
432 'body' => $body,
433 'headers' => array(
434 'Content-Type' => $format,
435 'user-agent' => $user_agent,
436 ),
437 'sslverify' => true,
438 );
439
440 // Use wp_safe_remote_request for built-in SSRF protection and redirect validation
441 return wp_safe_remote_request( $url, $args );
442 }
443 }
444