$payload Body to encode. * @param int $form_id Form the payload belongs to. * @param int $submission_id Submission the payload belongs to. * * @return int The delivery ID, or 0 when nothing was recorded. */ public function dispatch( Webhook $webhook, array $payload, int $form_id, int $submission_id = 0 ): int { $body = (string) wp_json_encode( $payload ); $id = $this->log->open( array( 'form_id' => $form_id, 'submission_id' => $submission_id, 'webhook_key' => $webhook->key(), /* * The endpoint's identity, not the endpoint. A Slack, Discord, * Zapier or Make URL is the entire authorisation to post to that * workspace — there is no separate key — so a full copy in a table * that the editor screen renders and the REST API can reach is a * credential sitting in plain sight. The fingerprint tells two * endpoints on one host apart, which is all the log needs it for. */ 'url' => Redact::endpoint( $webhook->url() ), 'payload' => self::redact_payload( $body ), ) ); if ( 0 === $id ) { /* * The row could not be opened — a URL longer than the column, a full * disk, a locked table. The delivery used to be abandoned here without * a word: no request, no `formpilot_webhook_failed`, nothing logged, and the * caller discards the return. Say so, then stop. */ do_action( 'formpilot_webhook_failed', 0, $webhook, array( 'ok' => false, 'code' => 0, 'message' => 'The delivery could not be recorded, so it was not attempted.', 'body' => '', 'ms' => 0, ) ); return 0; } $this->attempt( $id, $webhook, $body, 1 ); return $id; } /** * Retries a delivery that is due. * * @param array $row Stored delivery. * @param Webhook $webhook Endpoint to post to. * * @return bool True when the delivery succeeded. */ public function retry( array $row, Webhook $webhook ): bool { $id = (int) ( $row['id'] ?? 0 ); if ( $id <= 0 ) { return false; } /** * Filters the body a delivery is retried with. * * The stored copy has had its credentials stripped, so anything that puts * one in the request body — the Google Sheets shared secret is the shipped * example — puts it back here. Rebuilding rather than replaying also means * a credential rotated since the first attempt is the one that gets used. * * @since 1.0.0 * * @param string $body Stored payload, credentials redacted. * @param Webhook $webhook Endpoint being retried. * @param array $row Stored delivery. */ $body = (string) apply_filters( 'formpilot_webhook_retry_payload', (string) ( $row['payload'] ?? '' ), $webhook, $row ); return $this->attempt( $id, $webhook, $body, (int) ( $row['attempts'] ?? 0 ) + 1 ); } /** * Removes credentials from a payload before it is written to the log. * * The log row is a second copy of everything that was sent, kept for a month * by default, readable through the editor screen and the REST API, and it * outlives the entry it came from. Anything in it that authorises something * has to go; the submitted values stay, because the point of the row is to * show what was delivered. * * @param string $body Encoded payload as it will be sent. * * @return string The payload with credential-shaped values masked. */ private static function redact_payload( string $body ): string { $decoded = json_decode( $body, true ); if ( ! is_array( $decoded ) ) { return $body; } return (string) wp_json_encode( self::mask_credentials( $decoded ) ); } /** * Masks credential-shaped keys anywhere in a decoded payload. * * @param array $data Decoded payload. * * @return array The payload with its credentials masked. */ private static function mask_credentials( array $data ): array { /* * `fields` is the visitor's own answers, and their keys are field keys the * administrator chose. A form with a field keyed `token`, `auth` or * `password` had that answer replaced with the mask in the stored payload — * so a retry posted `********` where the first attempt had posted the real * value, and the endpoint saw two different submissions. Credentials are * something this plugin adds to a payload; they never arrive inside it. */ /** * Filters the payload keys treated as credentials by the delivery log. * * Matched case-insensitively against the key name, at any depth. * * @since 1.0.0 * * @param array $keys Key names whose values are masked. */ $secrets = (array) apply_filters( 'formpilot_webhook_redacted_keys', array( 'secret', 'api_key', 'apikey', 'token', 'access_token', 'password', 'passwd', 'authorization', 'auth', 'signature' ) ); $secrets = array_map( 'strtolower', array_map( 'strval', $secrets ) ); foreach ( $data as $key => $value ) { if ( 'fields' === $key ) { continue; } if ( is_array( $value ) ) { $data[ $key ] = self::mask_credentials( $value ); continue; } if ( is_string( $key ) && in_array( strtolower( $key ), $secrets, true ) && '' !== (string) $value ) { $data[ $key ] = Redact::MASK; } } return $data; } /** * Sends a payload without recording anything. * * Used by the test button, where the point is to show the answer to the * person pressing it rather than to keep a delivery on the books. * * @param Webhook $webhook Endpoint to post to. * @param array $payload Body to encode. * * @return array{ok: bool, code: int, message: string, body: string, ms: int} The outcome. */ public function probe( Webhook $webhook, array $payload ): array { return $this->post( $webhook, (string) wp_json_encode( $payload ) ); } /** * Returns how many times a delivery is attempted in total. */ public static function max_attempts(): int { /** * Filters how many times a failed delivery is retried. * * @since 1.0.0 * * @param int $attempts Total attempts, the first one included. */ return max( 1, (int) apply_filters( 'formpilot_webhook_max_attempts', count( self::BACKOFF ) ) ); } /** * Makes one attempt and records the outcome. * * @param int $id Delivery being attempted. * @param Webhook $webhook Endpoint to post to. * @param string $body Encoded payload. * @param int $attempt Number of this attempt, one based. * * @return bool True when the endpoint accepted it. */ private function attempt( int $id, Webhook $webhook, string $body, int $attempt ): bool { $result = $this->post( $webhook, $body ); $data = array( 'attempts' => $attempt, 'response_code' => $result['code'], 'response' => $result['body'], 'message' => $result['message'], 'duration_ms' => $result['ms'], ); if ( $result['ok'] ) { $data['status'] = DeliveryLog::SUCCESS; $data['next_attempt_at'] = null; $this->log->close( $id, $data ); /** * Fires after a webhook accepted a delivery. * * @since 1.0.0 * * @param int $id Delivery ID. * @param Webhook $webhook Endpoint that accepted it. * @param array $result Response details. */ do_action( 'formpilot_webhook_delivered', $id, $webhook, $result ); return true; } $next = self::BACKOFF[ $attempt - 1 ] ?? null; if ( null === $next || $attempt >= self::max_attempts() ) { $data['status'] = DeliveryLog::FAILED; $data['next_attempt_at'] = null; $this->log->close( $id, $data ); /** * Fires after a webhook was given up on. * * @since 1.0.0 * * @param int $id Delivery ID. * @param Webhook $webhook Endpoint that kept refusing. * @param array $result Response details of the last attempt. */ do_action( 'formpilot_webhook_failed', $id, $webhook, $result ); return false; } $due = time() + $next; $data['status'] = DeliveryLog::PENDING; // Written in the same clock the queue reads it back in. See DeliveryLog::stamp(). $data['next_attempt_at'] = DeliveryLog::stamp( $due ); $this->log->close( $id, $data ); self::schedule( $due ); return false; } /** * Posts the body to the endpoint. * * @param Webhook $webhook Endpoint to post to. * @param string $body Encoded payload. * * @return array{ok: bool, code: int, message: string, body: string, ms: int} The outcome. */ private function post( Webhook $webhook, string $body ): array { $now = time(); $signature = Signature::headers( $body, $webhook->secret(), $now ); $headers = array_merge( array( 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json', 'User-Agent' => 'FormPilot/' . FORMPILOT_VERSION . '; ' . home_url( '/' ), ), $signature, /* * The site's own headers last, so an endpoint that needs a particular * Content-Type or its own User-Agent can say so. The signature headers * can be overwritten too; that is the site's call to make. */ $webhook->headers() ); /* * Both ways a delivery ends up unsigned are worth saying out loud, because * neither is visible anywhere else: an empty secret means `Signature` emits * nothing at all, and a header typed into the editor's headers box can * overwrite the signature header with anything. An endpoint that verifies * signatures would simply start rejecting deliveries with no clue why. */ $reason = ''; if ( array() === $signature ) { $reason = 'no_secret'; } else { /* * Compared case-insensitively, because HTTP header names are. The headers * box preserves whatever case was typed, so `x-formpilot-signature` created a * separate PHP array key: an exact-key comparison saw the signature still * present and stayed quiet, while the HTTP client collapsed the two and * sent the site's value instead. */ $folded = array_change_key_case( $headers, CASE_LOWER ); foreach ( $signature as $name => $value ) { if ( ( $folded[ strtolower( $name ) ] ?? null ) !== $value ) { $reason = 'header_override'; break; } } } if ( '' !== $reason ) { /** * Fires when a delivery goes out without this plugin's signature on it. * * @since 1.0.0 * * @param Webhook $webhook Endpoint being posted to. * @param string $reason Either `no_secret` or `header_override`. */ do_action( 'formpilot_webhook_unsigned', $webhook, $reason ); } $started = microtime( true ); /* * `wp_safe_remote_post()` rather than `wp_remote_post()`, and * `reject_unsafe_urls` set explicitly so a site filtering the arguments * below cannot drop it by rebuilding the array. * * The safe client applies `wp_http_validate_url()` to the *redirect * targets* as well as the first request, which is the half that was * missing: the URL was screened once when it was saved, then a 302 to * `169.254.169.254` or `127.0.0.1` was followed without a second look. * `redirection => 0` closes the rest of that door — a webhook endpoint * answering 3xx is a misconfigured endpoint, not one worth chasing. * * Core's validation is not complete on its own (it misses IPv6, the literal * `localhost` and `100.64.0.0/10`), so `Webhook::url()` screens for those * before this point. The two together are what makes the delivery safe. */ $response = wp_safe_remote_post( $webhook->url(), /** * Filters the request a webhook is delivered with. * * @since 1.0.0 * * @param array $args Arguments for `wp_remote_post()`. * @param Webhook $webhook Endpoint being posted to. */ array_merge( (array) apply_filters( 'formpilot_webhook_request', array( /* * The first attempt happens inside the submission request, so * this timeout is time the visitor spends watching a spinner. * Ten seconds is long enough for a healthy endpoint on a bad * day and short enough that a dead one does not hold up the * confirmation for a minute. A site with several endpoints, or * a slow one, should lower it through this filter. */ 'timeout' => 10, 'redirection' => 0, 'blocking' => true, 'headers' => $headers, 'body' => $body, ), $webhook ), // Not filterable: the safety of the request is not a site preference. array( 'reject_unsafe_urls' => true ) ) ); $elapsed = (int) round( ( microtime( true ) - $started ) * 1000 ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'code' => 0, 'message' => $response->get_error_message(), 'body' => '', 'ms' => $elapsed, ); } $code = (int) wp_remote_retrieve_response_code( $response ); return array( // Anything outside 2xx is a refusal, including a redirect the client // was not willing to follow any further. 'ok' => $code >= 200 && $code < 300, 'code' => $code, /* * Both fields are cut with the multi-byte aware functions and both are * capped. `substr()` at a byte boundary splits a UTF-8 character in * half, and MySQL in strict mode refuses the resulting string for a * utf8mb4 column — so the row's closing write failed, the delivery * stayed `pending` for ever, was never retried because `next_attempt_at` * was never set, and was never purged because the sweeper skips pending * rows. One malformed response held a full submission payload in the * log permanently. The reason phrase is remote-controlled text and had * no cap at all. */ 'message' => mb_substr( (string) wp_remote_retrieve_response_message( $response ), 0, self::MAX_MESSAGE ), 'body' => mb_substr( (string) wp_remote_retrieve_body( $response ), 0, self::MAX_RESPONSE ), 'ms' => $elapsed, ); } /** * Makes sure a run is scheduled no later than a given time. * * @param int $timestamp Unix time the next attempt is due. */ public static function schedule( int $timestamp ): void { $existing = wp_next_scheduled( self::HOOK ); if ( false !== $existing && $existing <= $timestamp ) { return; } if ( false !== $existing ) { // An event further out than this one would make the retry late. wp_unschedule_event( $existing, self::HOOK ); } wp_schedule_single_event( max( time() + 30, $timestamp ), self::HOOK ); } }