PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.2
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.2
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 / src / reprint-export / class-reprint-exporter.php
jetpack / src / reprint-export Last commit date
class-reprint-exporter.php 6 days ago class-rest-controller.php 1 week ago
class-reprint-exporter.php
284 lines
1 <?php
2 /**
3 * Reprint export support for Jetpack on Pressable and WordPress.com (Atomic).
4 *
5 * Mirrors the behavior shipped in wpcomsh for WordPress.com on Atomic, but
6 * hosted in Jetpack so it also serves Pressable and can eventually replace the
7 * wpcomsh copy. It exposes an HMAC-authenticated, time-limited full-site export
8 * endpoint backed by the wp-php-toolkit/reprint-exporter package.
9 *
10 * On Atomic this runs alongside the wpcomsh copy without colliding: it uses a
11 * distinct query var (?reprint-api-jetpack) and REST namespace (jetpack/v4/*), so
12 * clients can migrate off the old ?reprint-api / wpcomsh/v1 surface at their
13 * own pace.
14 *
15 * Gating, in two phases that use different auth and network paths:
16 *
17 * 1. Secret rotation via the generic Jetpack REST proxy. The
18 * /jetpack/v4/reprint/rotate-export-secret route only accepts
19 * Jetpack-signed requests, so it can only be invoked through the
20 * WordPress.com public API proxy. On success the site generates a random
21 * secret, stores it in the `reprint_exporter_secret` option, opens the
22 * export window, and returns the secret.
23 *
24 * 2. Export streaming — the client (now holding the shared secret) talks
25 * directly to the site at ?reprint-api-jetpack using HMAC-signed requests. This
26 * bypasses the public API entirely because public-api does not support
27 * streaming and extra hops add latency and complexity.
28 *
29 * The whole feature is gated behind the host check (overridable via the
30 * `jetpack_reprint_export_available` filter), so generic self-hosted Jetpack
31 * sites never register or expose any of it.
32 *
33 * @package automattic/jetpack
34 */
35
36 namespace Automattic\Jetpack\Reprint_Export;
37
38 use Automattic\Jetpack\Constants;
39 use Automattic\Jetpack\Status\Host;
40
41 /**
42 * Reprint exporter for Jetpack (Pressable and WordPress.com/Atomic).
43 */
44 class Reprint_Exporter {
45
46 /**
47 * Option holding the per-site HMAC shared secret.
48 *
49 * @var string
50 */
51 const SECRET_OPTION = 'reprint_exporter_secret';
52
53 /**
54 * Option holding the unix timestamp of the last time the export window
55 * was opened. The window is a sliding 60-minute one.
56 *
57 * @var string
58 */
59 const ENABLED_OPTION = 'reprint_exporter_enabled';
60
61 /**
62 * Clock-skew tolerance, in seconds, allowed for HMAC signatures.
63 *
64 * @var int
65 */
66 const HMAC_CLOCK_SKEW = 300;
67
68 /**
69 * Registers the WordPress hooks. Only ever called on sites where
70 * is_available() is true (see Jetpack bootstrap).
71 */
72 public static function init() {
73 add_action( 'parse_request', array( new self(), 'handle_request' ), 0 );
74 add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
75 }
76
77 /**
78 * Whether Reprint export support is available on the current site.
79 *
80 * Defaults to true on Pressable and WordPress.com (Atomic) hosts, false
81 * everywhere else. The filter acts as both an override for testing and an
82 * emergency kill switch.
83 *
84 * On Atomic this coexists with the copy shipped in wpcomsh: the two use
85 * different query vars (?reprint-api-jetpack here vs ?reprint-api there) and
86 * REST namespaces (jetpack/v4 vs wpcomsh/v1), so both can run side by side
87 * while clients migrate to the Jetpack surface. Atomic detection uses
88 * is_atomic_platform() rather than is_woa_site() so it keeps working once
89 * wpcomsh (and its reprint copy) is removed.
90 *
91 * @return bool
92 */
93 public static function is_available() {
94 $host = new Host();
95
96 // Host::is_pressable() was added in jetpack-status 6.2.0. Another plugin on the
97 // site can ship an older copy of the package that wins autoloading, in which case
98 // calling the method is fatal, so fall back to the constant it reads.
99 $is_pressable = method_exists( $host, 'is_pressable' )
100 ? $host->is_pressable()
101 : Constants::is_true( 'IS_PRESSABLE' );
102
103 $available = $is_pressable || $host->is_atomic_platform();
104
105 /**
106 * Filters whether Jetpack Reprint export support is available on the
107 * current site.
108 *
109 * Default: true on Pressable and WordPress.com (Atomic), false elsewhere.
110 *
111 * @since 16.1
112 *
113 * @param bool $available Whether Reprint export support is available.
114 */
115 return (bool) apply_filters( 'jetpack_reprint_export_available', $available );
116 }
117
118 /**
119 * Registers the secret-rotation REST route.
120 */
121 public static function register_rest_routes() {
122 ( new REST_Controller() )->register_routes();
123 }
124
125 /**
126 * Handles the ?reprint-api-jetpack request.
127 *
128 * Hooked on `parse_request` at priority 0 so we run before WordPress
129 * resolves the query and long before any template output (important on
130 * Private Sites, whose template_redirect hooks redirect + exit).
131 *
132 * @param \WP $wp The WordPress environment instance.
133 */
134 public function handle_request( $wp ) {
135 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
136 if ( ! isset( $_GET['reprint-api-jetpack'] ) ) {
137 return;
138 }
139
140 // Defense in depth: the hook is only registered when available, but
141 // re-check so the filter kill switch also short-circuits live requests.
142 if ( ! self::is_available() ) {
143 return;
144 }
145
146 // Only respond on the root path, matching the wpcomsh behavior.
147 if ( '' !== $wp->request ) {
148 return;
149 }
150
151 // Sliding activation window: the export stays open only for 60
152 // minutes since the last accepted request, so an idle site
153 // auto-closes the gate. HMAC verification happens separately below.
154 if ( ! self::is_export_window_open() ) {
155 return;
156 }
157
158 // -- CORS -------------------------------------------------------------
159 // Allow CORS from any origin. The export client (e.g. Playground)
160 // runs on many different deployments and new ones appear regularly.
161 // Since every export request requires a dedicated HMAC secret, the
162 // origin header is not a meaningful security boundary — an attacker
163 // without the secret cannot export anything regardless of origin.
164 //
165 // Must run before authentication: browsers send the OPTIONS preflight
166 // without credentials, so auth must not be required for that method.
167 if ( ! headers_sent() ) {
168 header( 'Access-Control-Allow-Origin: *' );
169 header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
170 header( 'Access-Control-Allow-Headers: *' );
171 }
172
173 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash
174 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
175 if ( 'OPTIONS' === $request_method ) {
176 if ( ! headers_sent() ) {
177 header( 'Allow: GET, POST, OPTIONS' );
178 }
179 $this->terminate();
180 return;
181 }
182
183 // -- Authenticate via HMAC --------------------------------------------
184 $secret = get_option( self::SECRET_OPTION, '' );
185 if ( ! is_string( $secret ) || '' === $secret ) {
186 $this->error( 503, 'Export not configured. Please rotate the shared secret via POST /jetpack/v4/reprint/rotate-export-secret.' );
187 return;
188 }
189
190 $auth_error = $this->verify_hmac( $secret );
191 if ( null !== $auth_error ) {
192 $this->error( 403, $auth_error );
193 return;
194 }
195
196 // Bump the timestamp now that we know this request is legit.
197 self::open_export_window();
198
199 // WordPress is already loaded at this point. Run Reprint!
200 $this->serve_export();
201 $this->terminate();
202 }
203
204 /**
205 * Gate for the export handler: the enabled option must hold a unix
206 * timestamp within the last 60 minutes.
207 *
208 * @return bool
209 */
210 public static function is_export_window_open() {
211 $enabled_at = (int) get_option( self::ENABLED_OPTION, 0 );
212 return $enabled_at > 0 && ( time() - $enabled_at ) <= HOUR_IN_SECONDS;
213 }
214
215 /**
216 * Opens (or slides forward) the 60-minute export window by stamping the
217 * enabled option with the current time.
218 *
219 * Shared by the REST enable/rotate routes and the request handler's
220 * post-auth bump, so the window is opened the same way everywhere.
221 *
222 * @return int The unix timestamp the window was opened at.
223 */
224 public static function open_export_window() {
225 $now = time();
226 update_option( self::ENABLED_OPTION, $now );
227 return $now;
228 }
229
230 /**
231 * Verifies the HMAC signature of the current request.
232 *
233 * Seam for tests to override without instantiating the real server.
234 *
235 * @param string $secret The per-site shared secret.
236 * @return string|null Error message on failure, null on success.
237 */
238 protected function verify_hmac( $secret ) {
239 $hmac_server = new \Site_Export_HMAC_Server( $secret, self::HMAC_CLOCK_SKEW );
240 return $hmac_server->verify_globals();
241 }
242
243 /**
244 * Streams the export response.
245 *
246 * Seam for tests to override so they don't perform a real export.
247 */
248 protected function serve_export() {
249 \Site_Export_HTTP_Server::serve( array( 'default_directory' => ABSPATH ) );
250 }
251
252 /**
253 * Sends a JSON error response and terminates.
254 *
255 * @param int $code HTTP status code.
256 * @param string $message Error description.
257 */
258 protected function error( $code, $message ) {
259 if ( ! headers_sent() ) {
260 http_response_code( $code );
261 header( 'Content-Type: application/json' );
262 }
263 // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
264 echo json_encode(
265 array(
266 'error' => $message,
267 'code' => $code,
268 ),
269 JSON_FORCE_OBJECT
270 );
271 $this->terminate();
272 }
273
274 /**
275 * Terminates the request.
276 *
277 * Seam wrapping exit() so tests (which redefine exit via patchwork) can
278 * assert termination without killing the process.
279 */
280 protected function terminate() {
281 exit;
282 }
283 }
284