is_string( $route ) && str_starts_with( $route, self::EXPORT_PREFIX ) ) : []; return array_merge( self::ROUTES, array_diff( $extra, self::PUBLIC_ROUTES ) ); } /** Whether the forwarded headers name a connection, by token or by key id. */ public static function has_credential( array $headers ): bool { return ( isset( $headers['X-Shiftora-Token'] ) && '' !== $headers['X-Shiftora-Token'] ) || ( isset( $headers['X-Shiftora-Key-Id'] ) && '' !== $headers['X-Shiftora-Key-Id'] ); } public static function clean_method( string $method ): ?string { $method = '' === $method ? 'GET' : strtoupper( $method ); return in_array( $method, [ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ? $method : null; } /** * @return array Only the signing headers, each a plain token-safe value. */ public static function clean_headers( string $json ): array { $decoded = json_decode( $json, true ); $clean = []; if ( ! is_array( $decoded ) ) { return $clean; } foreach ( $decoded as $name => $value ) { if ( ! is_string( $name ) || ! in_array( strtolower( $name ), self::FORWARDED_HEADERS, true ) ) { continue; } if ( is_string( $value ) && 1 === preg_match( '/\A[A-Za-z0-9_\-]*\z/', $value ) ) { $clean[ $name ] = $value; } } return $clean; } /** * POST params: `route`, `method`, optional `body` and `headers` (JSON) and `query` * (the original query string). */ public static function handle(): void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Server to server: runs as user 0 and the target route verifies a signed connection token. $route = isset( $_POST['route'] ) && is_string( $_POST['route'] ) ? sanitize_text_field( wp_unslash( $_POST['route'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Server to server: runs as user 0 and the target route verifies a signed connection token. $method = isset( $_POST['method'] ) && is_string( $_POST['method'] ) ? self::clean_method( sanitize_text_field( wp_unslash( $_POST['method'] ) ) ) : 'GET'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Server to server: runs as user 0 and the target route verifies a signed connection token. $headers = isset( $_POST['headers'] ) && is_string( $_POST['headers'] ) ? self::clean_headers( sanitize_text_field( wp_unslash( $_POST['headers'] ) ) ) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Server to server, and ForwardedQuery::validate() holds the query to a strict charset because sanitize_text_field() would strip the %xx octets the request signature covers. $query = isset( $_POST['query'] ) && is_string( $_POST['query'] ) ? ForwardedQuery::validate( wp_unslash( $_POST['query'] ) ) : ''; if ( '' === $route ) { wp_send_json_error( [ 'message' => 'Missing route parameter.' ], 400 ); } if ( null === $method || null === $query ) { wp_send_json_error( [ 'message' => 'Invalid request.' ], 400 ); } if ( ! self::route_allowed( $route, self::has_credential( $headers ) ) ) { wp_send_json_error( [ 'message' => 'This route is not available through admin-ajax.' ], 403 ); } // Raw on purpose: the signature covers the body, so sanitizing it would break the hash, and a forged body fails the check. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Server to server, and the raw body is exactly what the signature covers. $body = isset( $_POST['body'] ) && is_string( $_POST['body'] ) ? wp_unslash( $_POST['body'] ) : ''; $request = self::build_request( $method, $route, $body, $headers, $query ); // dispatch() skips the REST cookie nonce, so a browser session must not carry into it. // Nothing needs the user back afterwards: the request ends with this response. wp_set_current_user( 0 ); ForwardedQuery::set( $query ); $response = rest_get_server()->dispatch( $request ); $status = $response->get_status(); $data = $response->get_data(); // The caller takes `data` out of the envelope and encodes it again, so that // re-encoding is what the signature covers. Headers rather than a field, so // a receiver on an older build sees the envelope it always did. if ( ResponseSignature::covers( $route ) ) { $signed = ResponseSignature::envelope_body( json_decode( (string) wp_json_encode( $data ), true ) ); $headers = ( new ResponseSigner( ServiceFactory::connections() ) )->headers( $request, $status, $signed ); foreach ( $headers as $name => $value ) { rest_get_server()->send_header( $name, $value ); } } if ( $status >= 200 && $status < 300 ) { wp_send_json_success( $data, $status ); } else { wp_send_json_error( $data, $status ); } } /** * @param array $headers */ private static function build_request( string $method, string $route, string $body, array $headers, string $query ): WP_REST_Request { $request = new WP_REST_Request( $method, $route ); if ( '' !== $body ) { $data = json_decode( $body, true ); if ( is_array( $data ) ) { $request->set_body_params( $data ); } $request->set_body( $body ); } foreach ( $headers as $name => $value ) { $request->set_header( $name, $value ); } $request->set_header( 'Content-Type', 'application/json' ); // Restore the sender's query params. if ( '' !== $query ) { $parsed = []; wp_parse_str( $query, $parsed ); $request->set_query_params( $parsed ); } return $request; } }