@automattic/newspack-blocks 4.28.0 → 4.28.1-hotfix-blocks-input-validation.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## @automattic/newspack-blocks [4.28.1-hotfix-blocks-input-validation.1](https://github.com/Automattic/newspack-workspace/compare/newspack-blocks@4.28.0...newspack-blocks@4.28.1-hotfix-blocks-input-validation.1) (2026-07-31)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **blocks:** keep author list and checkout fallbacks intact ([53012be](https://github.com/Automattic/newspack-workspace/commit/53012be987c2001aa8a855dfbf6b857b70c4463b))
7
+ * **blocks:** keep iframe imports inside the uploads directory ([a0118cc](https://github.com/Automattic/newspack-workspace/commit/a0118cc501ca0bfbb851c9e0fdb0f005702b99da))
8
+ * **blocks:** restrict author contact fields to user managers ([c872eb9](https://github.com/Automattic/newspack-workspace/commit/c872eb90589d056a354228adc0a2a65afa6245d3))
9
+ * **blocks:** validate post-checkout redirect destinations ([f418142](https://github.com/Automattic/newspack-workspace/commit/f418142055e14fc6e8fe82726996c7a7028452d5))
10
+
1
11
  # @automattic/newspack-blocks [4.28.0](https://github.com/Automattic/newspack-workspace/compare/newspack-blocks@4.27.1...newspack-blocks@4.28.0) (2026-07-20)
2
12
 
3
13
 
@@ -1188,6 +1188,27 @@ final class Modal_Checkout {
1188
1188
  ob_end_flush();
1189
1189
  }
1190
1190
 
1191
+ /**
1192
+ * Reduce a post-checkout destination to one this site is willing to send readers to.
1193
+ *
1194
+ * The destination reaches the thank-you page through the request, whether it came from
1195
+ * the block's own settings or from the link the reader followed, and by that point the
1196
+ * two are indistinguishable. Core's redirect validation decides: this site's host is
1197
+ * always allowed, and a publisher who wants to send readers somewhere else adds that
1198
+ * host through the standard `allowed_redirect_hosts` filter.
1199
+ *
1200
+ * @param string $url The requested destination.
1201
+ *
1202
+ * @return string The destination, or an empty string if it is not allowed.
1203
+ */
1204
+ public static function sanitize_after_success_url( $url ) {
1205
+ if ( empty( $url ) ) {
1206
+ return '';
1207
+ }
1208
+
1209
+ return (string) wp_validate_redirect( $url, '' );
1210
+ }
1211
+
1191
1212
  /**
1192
1213
  * Get after success button params.
1193
1214
  */
@@ -1201,14 +1222,22 @@ final class Modal_Checkout {
1201
1222
  \wp_parse_str( $referrer_query, $request_params );
1202
1223
  }
1203
1224
  }
1204
- return array_filter(
1225
+ $params = array_filter(
1205
1226
  [
1206
1227
  'after_success_behavior' => isset( $request_params['after_success_behavior'] ) ? sanitize_text_field( wp_unslash( $request_params['after_success_behavior'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1207
- 'after_success_url' => isset( $request_params['after_success_url'] ) ? sanitize_url( wp_unslash( $request_params['after_success_url'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1228
+ 'after_success_url' => isset( $request_params['after_success_url'] ) ? self::sanitize_after_success_url( sanitize_url( wp_unslash( $request_params['after_success_url'] ) ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1208
1229
  'after_success_button_label' => isset( $request_params['after_success_button_label'] ) ? sanitize_text_field( wp_unslash( $request_params['after_success_button_label'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1209
1230
  'action_type' => isset( $request_params['action_type'] ) ? sanitize_text_field( wp_unslash( $request_params['action_type'] ) ) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1210
1231
  ]
1211
1232
  );
1233
+
1234
+ // A custom destination this site won't redirect to falls back to closing the modal, so
1235
+ // the reader gets a finished checkout rather than an empty destination.
1236
+ if ( 'custom' === ( $params['after_success_behavior'] ?? '' ) && empty( $params['after_success_url'] ) ) {
1237
+ unset( $params['after_success_behavior'] );
1238
+ }
1239
+
1240
+ return $params;
1212
1241
  }
1213
1242
 
1214
1243
  /**
@@ -7,7 +7,7 @@
7
7
  * Author URI: https://newspack.com/
8
8
  * Text Domain: newspack-blocks
9
9
  * Domain Path: /languages
10
- * Version: 4.28.0
10
+ * Version: 4.28.1-hotfix-blocks-input-validation.1
11
11
  *
12
12
  * @package Newspack_Blocks
13
13
  */
@@ -15,7 +15,7 @@
15
15
  define( 'NEWSPACK_BLOCKS__PLUGIN_FILE', __FILE__ );
16
16
  define( 'NEWSPACK_BLOCKS__BLOCKS_DIRECTORY', 'dist/' );
17
17
  define( 'NEWSPACK_BLOCKS__PLUGIN_DIR', plugin_dir_path( NEWSPACK_BLOCKS__PLUGIN_FILE ) );
18
- define( 'NEWSPACK_BLOCKS__VERSION', '4.28.0' );
18
+ define( 'NEWSPACK_BLOCKS__VERSION', '4.28.1-hotfix-blocks-input-validation.1' );
19
19
 
20
20
  require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-newspack-blocks.php';
21
21
  require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-newspack-blocks-api.php';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/newspack-blocks",
3
- "version": "4.28.0",
3
+ "version": "4.28.1-hotfix-blocks-input-validation.1",
4
4
  "author": "Automattic",
5
5
  "description": "=== Newspack Blocks === Contributors: (this should be a list of wordpress.org userid's) Donate link: https://example.com/ Tags: comments, spam Requires at least: 4.5 Tested up to: 5.1.1 Stable tag: 0.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html",
6
6
  "repository": {
@@ -12,6 +12,13 @@
12
12
  class WP_REST_Newspack_Author_List_Controller extends WP_REST_Newspack_Authors_Controller {
13
13
  // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
14
14
 
15
+ /**
16
+ * Fields returned when a caller doesn't ask for a specific set.
17
+ *
18
+ * @var string[]
19
+ */
20
+ const DEFAULT_FIELDS = [ 'id', 'name', 'bio', 'email', 'social', 'avatar', 'url' ];
21
+
15
22
  /**
16
23
  * Constructs the controller.
17
24
  *
@@ -136,6 +143,10 @@ class WP_REST_Newspack_Author_List_Controller extends WP_REST_Newspack_Authors_C
136
143
  $options['fields'] = explode( ',', $request->get_param( 'fields' ) );
137
144
  }
138
145
 
146
+ // Restricted here rather than in get_all_authors(), which also renders the block on
147
+ // the front end for visitors and must keep returning whatever a publisher publishes.
148
+ $options['fields'] = self::restrict_fields( $options['fields'] ?? self::DEFAULT_FIELDS );
149
+
139
150
  $combined_authors = $this->get_all_authors( $options );
140
151
  $response = new \WP_REST_Response( $combined_authors );
141
152
  $response->header( 'x-wp-total', count( $combined_authors ) );
@@ -157,7 +168,7 @@ class WP_REST_Newspack_Author_List_Controller extends WP_REST_Newspack_Authors_C
157
168
  'avatar_hide_default' => false,
158
169
  'exclude' => [], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
159
170
  'exclude_empty' => false,
160
- 'fields' => [ 'id', 'name', 'bio', 'email', 'social', 'avatar', 'url' ],
171
+ 'fields' => self::DEFAULT_FIELDS,
161
172
  'per_page' => 10,
162
173
  ];
163
174
  $options = wp_parse_args( $options, $default_options );
@@ -12,6 +12,14 @@
12
12
  class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
13
13
  // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
14
14
 
15
+ /**
16
+ * Fields carrying contact details that WordPress reserves for people who manage users.
17
+ *
18
+ * These endpoints are open to anyone who can edit posts, which is a much wider audience
19
+ * than core allows to read an address.
20
+ */
21
+ const USER_MANAGEMENT_FIELDS = [ 'email' ];
22
+
15
23
  /**
16
24
  * Constructs the controller.
17
25
  *
@@ -22,6 +30,39 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
22
30
  $this->rest_base = 'authors';
23
31
  }
24
32
 
33
+ /**
34
+ * Whether the current user may read author contact details.
35
+ *
36
+ * Matches the capabilities core requires before exposing a user's address.
37
+ *
38
+ * @return boolean
39
+ */
40
+ public static function can_read_user_management_fields() {
41
+ return current_user_can( 'list_users' ) || current_user_can( 'edit_users' );
42
+ }
43
+
44
+ /**
45
+ * Drop any requested fields the current user is not allowed to read.
46
+ *
47
+ * Applied per request rather than inside the formatting helpers, which also render the
48
+ * front-end author blocks for visitors and must keep returning whatever a publisher has
49
+ * chosen to publish there.
50
+ *
51
+ * `false` is the helpers' "every field" convention, used by the front-end blocks; it is
52
+ * passed through untouched so those renders are unaffected.
53
+ *
54
+ * @param array|false $fields Requested fields, or false for every field.
55
+ *
56
+ * @return array|false Fields the current user may receive.
57
+ */
58
+ public static function restrict_fields( $fields ) {
59
+ if ( ! is_array( $fields ) || self::can_read_user_management_fields() ) {
60
+ return $fields;
61
+ }
62
+
63
+ return array_values( array_diff( $fields, self::USER_MANAGEMENT_FIELDS ) );
64
+ }
65
+
25
66
  /**
26
67
  * Registers the necessary REST API routes.
27
68
  *
@@ -84,6 +125,7 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
84
125
  $per_page = ! empty( $request->get_param( 'perPage' ) ) ? $request->get_param( 'perPage' ) : 10; // Number of results to return per page. This is applied to each query, so the actual number of results returned may be up to 2x this number.
85
126
  $avatar_hide_default = ! empty( $request->get_param( 'avatar_hide_default' ) ) ? true : false; // Hide the default avatar if the user has no custom avatar.
86
127
  $fields = ! empty( $request->get_param( 'fields' ) ) ? explode( ',', $request->get_param( 'fields' ) ) : [ 'id' ]; // Fields to get. Will return at least id.
128
+ $fields = self::restrict_fields( $fields );
87
129
  $include = ! empty( $request->get_param( 'include' ) ) ? explode( ',', $request->get_param( 'include' ) ) : null; // Fetch authors by multiple IDs.
88
130
  $post_id = ! empty( $request->get_param( 'post_id' ) ) ? $request->get_param( 'post_id' ) : 0; // Fetch authors for a specific post (contextual mode).
89
131
 
@@ -246,6 +246,88 @@ class WP_REST_Newspack_Iframe_Controller extends WP_REST_Controller {
246
246
  return in_array( $mime_type, array_keys( self::iframe_archive_accepted_file_mimes() ), true );
247
247
  }
248
248
 
249
+ /**
250
+ * Reduce a caller-supplied name to a single safe path segment.
251
+ *
252
+ * Used for names this plugin generates but builds out of caller-supplied parts, such as
253
+ * the destination folder derived from an attachment title.
254
+ *
255
+ * @param string $segment The name to reduce.
256
+ *
257
+ * @return string|false The safe segment, or false if nothing usable is left.
258
+ */
259
+ private function sanitize_path_segment( $segment ) {
260
+ $segment = sanitize_file_name( basename( str_replace( '\\', '/', (string) $segment ) ) );
261
+
262
+ if ( '' === $segment || '.' === $segment || '..' === $segment ) {
263
+ return false;
264
+ }
265
+
266
+ return $segment;
267
+ }
268
+
269
+ /**
270
+ * Reduce a caller-supplied relative path to safe segments, keeping subdirectories.
271
+ *
272
+ * Archive entries may legitimately sit in subdirectories and are referenced by name from
273
+ * the archive's own markup, so the segments are checked rather than rewritten: renaming
274
+ * them would break those references. Anything that could point outside the destination —
275
+ * an upward segment, an absolute path, a drive letter or a null byte — is refused
276
+ * outright rather than stripped, since stripping is what makes layered payloads such as
277
+ * `....//` work.
278
+ *
279
+ * @param string $path The relative path to reduce.
280
+ *
281
+ * @return string|false The safe relative path, or false if it is not usable.
282
+ */
283
+ private function sanitize_relative_path( $path ) {
284
+ $path = str_replace( '\\', '/', (string) $path );
285
+
286
+ if ( '' === $path || false !== strpos( $path, "\0" ) ) {
287
+ return false;
288
+ }
289
+
290
+ // Absolute paths, including Windows drive letters.
291
+ if ( '/' === $path[0] || preg_match( '#^[a-zA-Z]:#', $path ) ) {
292
+ return false;
293
+ }
294
+
295
+ $segments = [];
296
+ foreach ( explode( '/', $path ) as $segment ) {
297
+ if ( '' === $segment || '.' === $segment ) {
298
+ continue;
299
+ }
300
+ if ( '..' === $segment ) {
301
+ return false;
302
+ }
303
+ $segments[] = $segment;
304
+ }
305
+
306
+ return empty( $segments ) ? false : implode( '/', $segments );
307
+ }
308
+
309
+ /**
310
+ * Confirm that a destination resolves inside a directory.
311
+ *
312
+ * A last check on the resolved paths, after the destination's parent exists, so that a
313
+ * symlink or a gap in the checks above still cannot place a file outside the directory.
314
+ *
315
+ * @param string $directory Absolute path to the directory the write must stay inside.
316
+ * @param string $destination Absolute path to the intended destination.
317
+ *
318
+ * @return boolean
319
+ */
320
+ private function is_inside_directory( $directory, $destination ) {
321
+ $directory = realpath( $directory );
322
+ $destination = realpath( dirname( $destination ) );
323
+
324
+ if ( ! $directory || ! $destination ) {
325
+ return false;
326
+ }
327
+
328
+ return 0 === strpos( trailingslashit( $destination ), trailingslashit( $directory ) );
329
+ }
330
+
249
331
  /**
250
332
  * Receive the iframe content, handle it, and returns the URL and the folder name.
251
333
  *
@@ -363,7 +445,11 @@ class WP_REST_Newspack_Iframe_Controller extends WP_REST_Controller {
363
445
  * @return WP_REST_Response
364
446
  */
365
447
  private function process_iframe_document( $request, $document_filename, $media_source_path ) {
366
- if ( ! $this->validate_file_extension( $document_filename, false ) ) {
448
+ // The filename is built from the source attachment's title, so it is only a name once
449
+ // it has been reduced to a single segment.
450
+ $document_filename = $this->sanitize_path_segment( $document_filename );
451
+
452
+ if ( ! $document_filename || ! $this->validate_file_extension( $document_filename, false ) ) {
367
453
  return new WP_Error(
368
454
  'newspack_blocks',
369
455
  sprintf(
@@ -393,6 +479,14 @@ class WP_REST_Newspack_Iframe_Controller extends WP_REST_Controller {
393
479
  }
394
480
  }
395
481
 
482
+ if ( ! $this->is_inside_directory( $iframe_upload_dir, $iframe_path ) ) {
483
+ return new WP_Error(
484
+ 'newspack_blocks',
485
+ __( 'Could not upload your document.', 'newspack-blocks' ),
486
+ [ 'status' => '400' ]
487
+ );
488
+ }
489
+
396
490
  // Copy uploaded document file to destination.
397
491
  if ( copy( $media_source_path, $iframe_path ) ) {
398
492
  return [
@@ -434,6 +528,13 @@ class WP_REST_Newspack_Iframe_Controller extends WP_REST_Controller {
434
528
  }
435
529
  global $wp_filesystem;
436
530
 
531
+ // The folder is built from the source attachment's title, so it is only a name once it
532
+ // has been reduced to a single segment.
533
+ $iframe_folder = $this->sanitize_path_segment( $iframe_folder );
534
+ if ( ! $iframe_folder ) {
535
+ return $invalid_file_error;
536
+ }
537
+
437
538
  $wp_upload_dir = wp_upload_dir();
438
539
  $iframe_upload_dir = $wp_upload_dir['path'] . self::IFRAME_UPLOAD_DIR;
439
540
  $iframe_path = trailingslashit( $iframe_upload_dir . $iframe_folder );
@@ -464,10 +565,21 @@ class WP_REST_Newspack_Iframe_Controller extends WP_REST_Controller {
464
565
  }
465
566
  $stat = $zip->statIndex( $file_index );
466
567
  if ( $stat && isset( $stat['name'] ) ) {
467
- if ( ! file_exists( dirname( $iframe_path . $stat['name'] ) ) ) {
468
- wp_mkdir_p( dirname( $iframe_path . $stat['name'] ) );
568
+ // An entry names its own destination, so it decides where the write lands.
569
+ $entry_relative_path = $this->sanitize_relative_path( $stat['name'] );
570
+ if ( ! $entry_relative_path ) {
571
+ return $invalid_file_error;
572
+ }
573
+
574
+ $destination = $iframe_path . $entry_relative_path;
575
+ if ( ! file_exists( dirname( $destination ) ) ) {
576
+ wp_mkdir_p( dirname( $destination ) );
469
577
  }
470
- $put = $wp_filesystem->put_contents( $iframe_path . $stat['name'], $contents );
578
+ if ( ! $this->is_inside_directory( $iframe_upload_dir, $destination ) ) {
579
+ return $invalid_file_error;
580
+ }
581
+
582
+ $put = $wp_filesystem->put_contents( $destination, $contents );
471
583
  if ( ! $put ) {
472
584
  return $invalid_file_error;
473
585
  }
@@ -47,9 +47,15 @@ if ( ! $is_valid ) {
47
47
 
48
48
  $is_success = ! $order->has_status( 'failed' );
49
49
  $after_success_behavior = isset( $_GET['after_success_behavior'] ) ? \sanitize_text_field( \wp_unslash( $_GET['after_success_behavior'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
50
- $after_success_url = isset( $_GET['after_success_url'] ) ? esc_url( \sanitize_url( \wp_unslash( $_GET['after_success_url'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
50
+ $after_success_url = isset( $_GET['after_success_url'] ) ? esc_url( \Newspack_Blocks\Modal_Checkout::sanitize_after_success_url( \sanitize_url( \wp_unslash( $_GET['after_success_url'] ) ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
51
51
  $after_success_label = isset( $_GET['after_success_button_label'] ) ? \sanitize_text_field( \wp_unslash( $_GET['after_success_button_label'] ) ) : \Newspack_Blocks\Modal_Checkout::get_modal_checkout_labels( 'after_success' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
52
52
  $checkout_data = Checkout_Data::get_checkout_data( $order );
53
+
54
+ // A custom destination this site won't redirect to falls back to closing the modal, so the
55
+ // reader gets a finished checkout rather than an empty destination.
56
+ if ( 'custom' === $after_success_behavior && empty( $after_success_url ) ) {
57
+ $after_success_behavior = '';
58
+ }
53
59
  ?>
54
60
  <div class="woocommerce-order">
55
61
  <?php if ( $is_success ) : ?>
@@ -3,7 +3,7 @@
3
3
  'name' => 'automattic/newspack-blocks',
4
4
  'pretty_version' => 'dev-main',
5
5
  'version' => 'dev-main',
6
- 'reference' => 'f937e9f691dbdb938f7478a05bc22d51b771311f',
6
+ 'reference' => '53012be987c2001aa8a855dfbf6b857b70c4463b',
7
7
  'type' => 'wordpress-plugin',
8
8
  'install_path' => __DIR__ . '/../../',
9
9
  'aliases' => array(),
@@ -13,7 +13,7 @@
13
13
  'automattic/newspack-blocks' => array(
14
14
  'pretty_version' => 'dev-main',
15
15
  'version' => 'dev-main',
16
- 'reference' => 'f937e9f691dbdb938f7478a05bc22d51b771311f',
16
+ 'reference' => '53012be987c2001aa8a855dfbf6b857b70c4463b',
17
17
  'type' => 'wordpress-plugin',
18
18
  'install_path' => __DIR__ . '/../../',
19
19
  'aliases' => array(),