Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.39% covered (success)
95.39%
145 / 152
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Form_Webhooks
95.39% covered (success)
95.39%
145 / 152
60.00% covered (warning)
60.00%
6 / 10
70
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 send_webhooks
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
9.09
 log_response_to_post_meta
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 normalize_response_headers
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 track_webhook_request
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_blocked_ip
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 validate_webhook_url
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
16.12
 get_enabled_webhooks
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
11.02
 send_webhook
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Form Webhooks for Jetpack Contact Forms.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\Service;
9
10use Automattic\Jetpack\Forms\ContactForm\Feedback;
11use WP_Error;
12
13/**
14 * Class Form_Webhooks
15 *
16 * Hooks on Jetpack's Contact form to send form data to configured webhooks.
17 */
18class 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}