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