Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.52% covered (warning)
88.52%
108 / 122
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Slack
88.52% covered (warning)
88.52%
108 / 122
50.00% covered (danger)
50.00%
4 / 8
27.02
0.00% covered (danger)
0.00%
0 / 1
 is_valid_webhook_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 send
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 build_payload
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
3
 build_field_blocks
72.73% covered (warning)
72.73%
24 / 33
0.00% covered (danger)
0.00%
0 / 1
9.30
 get_form_title
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 escape
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 truncate
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 record_result
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
4.13
1<?php
2/**
3 * Posts new form responses to Slack.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\Service;
9
10use Automattic\Jetpack\Forms\Dashboard\Dashboard;
11
12/**
13 * Sends a Block Kit message to a Slack incoming webhook when a form receives a response.
14 *
15 * The webhook URL is the credential: anyone holding it can post to the channel it was created
16 * for. It is stored per form, in the form's block attributes, which means it is readable by
17 * anyone who can edit the form. That is the same exposure the existing webhooks feature has,
18 * but it is why the URL is pinned to Slack's own host — a value that cannot be pointed
19 * anywhere else cannot be used to reach the site's own network, and cannot quietly become an
20 * exfiltration target for response data.
21 */
22class Slack {
23
24    /**
25     * The only URLs accepted as a Slack webhook.
26     *
27     * Slack issues incoming webhooks on exactly this path. Pinning to it makes the field
28     * unusable for anything but Slack, which rules out SSRF without needing to reason about
29     * address ranges and DNS rebinding the way a general-purpose webhook must.
30     */
31    private const WEBHOOK_URL_PATTERN = '#^https://hooks\.slack\.com/services/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$#';
32
33    /**
34     * Slack rejects a header longer than this.
35     */
36    private const HEADER_LIMIT = 150;
37
38    /**
39     * Slack rejects a section field longer than this.
40     */
41    private const FIELD_LIMIT = 2000;
42
43    /**
44     * Slack rejects a section body longer than this.
45     */
46    private const SECTION_LIMIT = 3000;
47
48    /**
49     * A value at or below this length is shown in the two-column grid; longer values get a
50     * block of their own so they are not truncated into uselessness.
51     */
52    private const SHORT_VALUE_LENGTH = 60;
53
54    /**
55     * Slack renders at most this many fields in one section.
56     */
57    private const MAX_GRID_FIELDS = 10;
58
59    /**
60     * Whether a string is a usable Slack incoming webhook URL.
61     *
62     * @param mixed $url The candidate URL.
63     * @return bool
64     */
65    public static function is_valid_webhook_url( $url ) {
66        return is_string( $url ) && (bool) preg_match( self::WEBHOOK_URL_PATTERN, trim( $url ) );
67    }
68
69    /**
70     * Post a new response to Slack.
71     *
72     * Registered as the `on_submission` callback for the Slack integration.
73     *
74     * @param array $context Submission context supplied by Integration_Dispatcher.
75     * @return void
76     */
77    public static function send( array $context ) {
78        $settings = $context['settings'] ?? array();
79        $url      = isset( $settings['webhookUrl'] ) ? trim( (string) $settings['webhookUrl'] ) : '';
80
81        // A form can be enabled while the URL is missing or has been edited into something
82        // invalid. Nothing is sent, and nothing is logged that would leak the value.
83        if ( ! self::is_valid_webhook_url( $url ) ) {
84            return;
85        }
86
87        $payload = self::build_payload( $context );
88
89        $response = wp_remote_post(
90            $url,
91            array(
92                'timeout'     => 10,
93                'redirection' => 0,
94                'headers'     => array( 'Content-Type' => 'application/json' ),
95                'body'        => wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
96            )
97        );
98
99        self::record_result( $context['post_id'], $response );
100    }
101
102    /**
103     * Build the Block Kit message for a response.
104     *
105     * @param array $context Submission context.
106     * @return array The Slack message payload.
107     */
108    private static function build_payload( array $context ) {
109        $settings   = $context['settings'];
110        $feedback   = $context['feedback'];
111        $form       = $context['form'];
112        $post_id    = $context['post_id'];
113        $form_title = self::get_form_title( $form );
114
115        $response_url = Dashboard::get_single_response_admin_url( $post_id );
116
117        /* translators: %s is the title of the form that received a response. */
118        $heading = sprintf( __( 'New response: %s', 'jetpack-forms' ), $form_title );
119
120        $blocks = array(
121            array(
122                'type' => 'header',
123                'text' => array(
124                    'type' => 'plain_text',
125                    'text' => self::truncate( $heading, self::HEADER_LIMIT ),
126                ),
127            ),
128        );
129
130        // Sending the response itself is opt-out per form: form responses routinely carry
131        // personal data, and copying it into Slack puts it under different retention and
132        // access rules than the site's own.
133        $include_content = ! isset( $settings['includeContent'] ) || (bool) $settings['includeContent'];
134
135        if ( $include_content ) {
136            $blocks = array_merge( $blocks, self::build_field_blocks( $feedback ) );
137        }
138
139        $blocks[] = array(
140            'type'     => 'actions',
141            'elements' => array(
142                array(
143                    'type'  => 'button',
144                    'text'  => array(
145                        'type' => 'plain_text',
146                        'text' => __( 'View response', 'jetpack-forms' ),
147                    ),
148                    'url'   => $response_url,
149                    'style' => 'primary',
150                ),
151            ),
152        );
153
154        $blocks[] = array(
155            'type'     => 'context',
156            'elements' => array(
157                array(
158                    'type' => 'mrkdwn',
159                    'text' => sprintf(
160                        '%s · %s',
161                        self::escape( wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),
162                        self::escape( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) )
163                    ),
164                ),
165            ),
166        );
167
168        return array(
169            // Shown in notifications and by clients that cannot render blocks.
170            'text'   => self::truncate( $heading, self::SECTION_LIMIT ),
171            'blocks' => $blocks,
172        );
173    }
174
175    /**
176     * Turn a response's answers into Block Kit blocks.
177     *
178     * Short answers are grouped into a two-column grid; anything longer gets its own block so
179     * it stays readable.
180     *
181     * @param object $feedback The Feedback object for the response.
182     * @return array Block Kit blocks.
183     */
184    private static function build_field_blocks( $feedback ) {
185        $blocks = array();
186        $grid   = array();
187
188        foreach ( (array) $feedback->get_compiled_fields( 'email', 'label|value' ) as $field ) {
189            $label = isset( $field['label'] ) ? (string) $field['label'] : '';
190            $value = $field['value'] ?? '';
191
192            if ( is_array( $value ) ) {
193                $value = implode( ', ', array_map( 'strval', $value ) );
194            }
195
196            $value = trim( (string) $value );
197
198            if ( '' === $value ) {
199                continue;
200            }
201
202            $rendered = sprintf( "*%s*\n%s", self::escape( $label ), self::escape( $value ) );
203
204            if ( mb_strlen( $value ) <= self::SHORT_VALUE_LENGTH && count( $grid ) < self::MAX_GRID_FIELDS ) {
205                $grid[] = array(
206                    'type' => 'mrkdwn',
207                    'text' => self::truncate( $rendered, self::FIELD_LIMIT ),
208                );
209                continue;
210            }
211
212            $blocks[] = array(
213                'type' => 'section',
214                'text' => array(
215                    'type' => 'mrkdwn',
216                    'text' => self::truncate( $rendered, self::SECTION_LIMIT ),
217                ),
218            );
219        }
220
221        if ( $grid ) {
222            // The grid renders before the long-form answers, matching the order fields are
223            // usually laid out: short identifying answers first, then the message body.
224            array_unshift(
225                $blocks,
226                array(
227                    'type'   => 'section',
228                    'fields' => $grid,
229                )
230            );
231        }
232
233        return $blocks;
234    }
235
236    /**
237     * The title to show for a form.
238     *
239     * @param object $form The Contact_Form the response belongs to.
240     * @return string
241     */
242    private static function get_form_title( $form ) {
243        $title = isset( $form->attributes['formTitle'] ) ? trim( (string) $form->attributes['formTitle'] ) : '';
244
245        if ( '' !== $title ) {
246            return $title;
247        }
248
249        return __( 'Untitled form', 'jetpack-forms' );
250    }
251
252    /**
253     * Escape the three characters Slack treats as markup.
254     *
255     * @param string $text Raw text.
256     * @return string
257     */
258    private static function escape( $text ) {
259        return str_replace( array( '&', '<', '>' ), array( '&amp;', '&lt;', '&gt;' ), (string) $text );
260    }
261
262    /**
263     * Shorten text to fit one of Slack's limits.
264     *
265     * @param string $text  Text to shorten.
266     * @param int    $limit Maximum length Slack accepts.
267     * @return string
268     */
269    private static function truncate( $text, $limit ) {
270        if ( mb_strlen( $text ) <= $limit ) {
271            return $text;
272        }
273
274        return mb_substr( $text, 0, $limit - 1 ) . '…';
275    }
276
277    /**
278     * Record whether the message went out, for stats and for debugging a silent integration.
279     *
280     * @param int             $post_id  Feedback post ID.
281     * @param array|\WP_Error $response The result of the request to Slack.
282     * @return void
283     */
284    private static function record_result( $post_id, $response ) {
285        if ( is_wp_error( $response ) ) {
286            update_post_meta( $post_id, '_jetpack_forms_slack_error', sanitize_text_field( $response->get_error_message() ) );
287            /** This action is documented in src/service/class-form-webhooks.php */
288            do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_slack_request', 'error' );
289            return;
290        }
291
292        $code = wp_remote_retrieve_response_code( $response );
293
294        if ( $code >= 200 && $code < 300 ) {
295            delete_post_meta( $post_id, '_jetpack_forms_slack_error' );
296            /** This action is documented in src/service/class-form-webhooks.php */
297            do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_slack_request', 'success' );
298            return;
299        }
300
301        // Slack answers a bad or revoked webhook with a short plain-text reason such as
302        // "no_service" or "invalid_payload", which is what someone debugging needs to see.
303        update_post_meta(
304            $post_id,
305            '_jetpack_forms_slack_error',
306            sanitize_text_field( sprintf( 'HTTP %d: %s', $code, wp_remote_retrieve_body( $response ) ) )
307        );
308        /** This action is documented in src/service/class-form-webhooks.php */
309        do_action( 'jetpack_bump_stats_extras', 'jetpack_forms_slack_request', 'failed' );
310    }
311}