PluginProbe
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.6
JetFormBuilder — Dynamic Blocks Form Builder v3.6.6
3.6.6.1 3.6.6 3.6.5.4 3.6.5.3 3.6.5.2 3.6.5.1 3.6.5 3.6.4.2 3.6.4.1 3.6.4 3.6.3.1 3.6.3 3.6.2.2 3.6.2.1 3.6.2 3.6.1.1 3.6.1 3.6.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 All 134 releases
← All changes | modules/media-cleanup/module.php +276 -4 3.6.3 → 3.6.6 View file →
@@ -1,8 +1,9 @@
1 1 <?php
2 2
3 3 namespace JFB_Modules\Media_Cleanup;
4 4
5 +use Jet_Form_Builder\Classes\Tools;
5 6 use JFB_Components\Module\Base_Module_It;
6 7
7 8 // If this file is called directly, abort.
8 9 if ( ! defined( 'WPINC' ) ) {
@@ -10,8 +11,11 @@
10 11 }
11 12
12 13 class Module implements Base_Module_It {
13 14
15 + /** @var Media_Ownership_Migration_Notice */
16 + private $media_ownership_migration_notice;
17 +
14 18 public function rep_item_id() {
15 19 return 'media-cleanup';
16 20 }
17 21
@@ -18,9 +22,12 @@
18 22 public function condition(): bool {
19 23 return true;
20 24 }
21 25
22 - public function init_hooks() {}
26 + public function init_hooks() {
27 + $this->media_ownership_migration_notice = new Media_Ownership_Migration_Notice();
28 + $this->media_ownership_migration_notice->init_hooks();
29 + }
23 30
24 31 public static function collect_post_meta_attachment_ids( int $post_id, array $meta_keys ): array {
25 32 $attachment_ids = array();
26 33
@@ -37,9 +44,10 @@
37 44 }
38 45
39 46 public static function maybe_delete_attachments(
40 47 array $old_attachment_ids,
41 - array $new_attachment_ids = array()
48 + array $new_attachment_ids = array(),
49 + int $context_post_id = 0
42 50 ): void {
43 51 $attachment_ids_to_delete = self::diff_attachment_ids(
44 52 $old_attachment_ids,
45 53 $new_attachment_ids
@@ -46,9 +54,9 @@
46 54 );
47 55 if ( empty( $attachment_ids_to_delete ) ) {
48 56 return;
49 57 }
50 - self::delete_attachments( $attachment_ids_to_delete );
58 + self::delete_attachments( $attachment_ids_to_delete, $context_post_id );
51 59 }
52 60
53 61 public static function diff_attachment_ids( array $old_attachment_ids, array $new_attachment_ids ): array {
54 62 $old_attachment_ids = self::normalize_attachment_ids( $old_attachment_ids );
@@ -96,22 +104,286 @@
96 104 )
97 105 );
98 106 }
99 107
100 - public static function delete_attachments( array $attachment_ids ): void {
108 + /**
109 + * Deletes plugin-owned attachments (marked with `_jfb_uploaded_by_form` at
110 + * upload time) that were removed from a post's media field on this request.
111 + *
112 + * These attachments may have `post_author = 0` (anonymous form submissions),
113 + * so WordPress core's `current_user_can( 'delete_post', $attachment_id )`
114 + * cannot express their real ownership: `map_meta_cap()` only recognizes
115 + * "author owns it" when `post_author` is truthy, so a `post_author = 0`
116 + * attachment can never pass that check for anyone, including the
117 + * legitimate logged-in editor of the post it's attached to. Ownership is
118 + * therefore asserted via a combination of:
119 + * - the request's already-verified authorization to edit the owning post
120 + * ($context_post_id), checked the same way `Post_Id_Property::can_attach()`
121 + * authorizes the request on the only call path that reaches this method
122 + * (post_author match OR edit_post OR webhook) - using only the narrower
123 + * edit_post-or-webhook check here would silently skip cleanup for a
124 + * legitimate post owner on a post type whose capability mapping doesn't
125 + * resolve edit_post true for its own author (e.g. a custom
126 + * map_meta_cap callback), even though insert_post already authorized
127 + * the very same request to update that post; and
128 + * - a per-attachment uploader check ($this->is_owned_by_current_actor()):
129 + * a logged-in uploader's attachments can only be deleted by that same
130 + * user, so an editor of $context_post_id cannot delete an attachment
131 + * another user legitimately uploaded for an unrelated post just because
132 + * both happen to carry the plugin's upload marker. Attachments uploaded
133 + * before this per-uploader binding existed (no `_jfb_uploaded_by_user`
134 + * meta at all) fall back to the pre-existing marker + edit_post check
135 + * only - a deliberate, documented compatibility trade-off (see
136 + * changelog) since there is no way to retroactively know their real
137 + * uploader. Attachments explicitly recorded as anonymously uploaded
138 + * (uploader id `0`) are different: that is an ongoing, current-day case
139 + * (every anonymous form submission), not a legacy gap, so it is never
140 + * allowed via the edit_post fallback - only `Tools::is_webhook()` may
141 + * delete them, since no verifiable per-request identity exists to bind
142 + * them to.
143 + *
144 + * @param array $attachment_ids
145 + * @param int $context_post_id The post these attachments are being
146 + * removed from; the caller must have already
147 + * authorized the current request to edit it.
148 + */
149 + public static function delete_attachments( array $attachment_ids, int $context_post_id = 0 ): void {
101 150
102 151 $attachment_ids = self::normalize_attachment_ids( $attachment_ids );
103 152
153 + if ( empty( $attachment_ids ) ) {
154 + return;
155 + }
156 +
157 + $authorization = self::authorize_context_post( $context_post_id );
158 +
159 + if ( ! $authorization['authorized'] ) {
160 + return;
161 + }
162 +
104 163 foreach ( $attachment_ids as $attachment_id ) {
105 164 if ( 'attachment' !== get_post_type( $attachment_id ) ) {
106 165 continue;
107 166 }
108 167
168 + if ( ! get_post_meta( $attachment_id, '_jfb_uploaded_by_form', true ) ) {
169 + continue;
170 + }
171 +
172 + /**
173 + * `authorize_context_post()` above already rejects every logged-out request
174 + * outright (see its own docblock for why the anonymous post_author-match branch
175 + * `Post_Id_Property::can_attach()` still grants - issues-tracker #20547's
176 + * still-open Defect 3 - is deliberately NOT accepted as authorization here), so
177 + * `$authorization['verified_identity']` is currently always `=== true` by the
178 + * time this line runs. It is still checked explicitly (rather than assuming
179 + * `authorized` alone is enough) so this call site stays correct on its own terms
180 + * even if `authorize_context_post()` is ever changed to reintroduce a
181 + * non-verified authorized branch - `is_owned_by_current_actor()`'s
182 + * anonymous-uploader exception must never be unlocked by anything less than a
183 + * verified identity, regardless of what else may authorize the request to edit
184 + * the post in the future.
185 + */
186 + if ( ! self::is_owned_by_current_actor( $attachment_id, $authorization['verified_identity'] ? $context_post_id : 0 ) ) {
187 + continue;
188 + }
189 +
109 190 wp_delete_attachment( $attachment_id, true );
110 191 }
111 192 }
112 193
194 + /**
195 + * Authorizes the current request to run CLEANUP against `$context_post_id`. Unlike
196 + * `Post_Id_Property::can_attach()` (which this otherwise mirrors for post_author match /
197 + * edit_post / webhook), a logged-out request is deliberately NEVER authorized here, even
198 + * though `can_attach()` currently authorizes it via `post_author === 0 === get_current_user_id()`
199 + * (issues-tracker #20547's still-open Defect 3, modules/actions-v2/insert-post/properties/
200 + * post-id-property.php - not fixed by this file). That anonymous match is not a
201 + * verifiable identity: because Defect 3 lets ANY anonymous visitor get authorized on
202 + * ANY `post_author = 0` post, not just one they created, relying on it here would let an
203 + * attacker delete a stranger's anonymously-uploaded attachment by first getting
204 + * "authorized" on the stranger's own carrier post through that same anonymous match -
205 + * reopening the exact "delete a stranger's file" primitive #20547 exists to close, just
206 + * laundered through Defect 3 instead of the original PoC's chain. Cleanup for a
207 + * logged-out request is therefore only ever performed via `Tools::is_webhook()`, which
208 + * `delete_attachments()` still allows.
209 + *
210 + * The trade-off: an anonymous visitor can no longer have their OWN, legitimately
211 + * removed-from-the-field attachment cleaned up automatically either - it is simply left
212 + * in the Media Library instead of being deleted. This is the same fail-closed posture
213 + * already documented for the anonymous-upload case elsewhere in this fix (see
214 + * `is_owned_by_current_actor()`'s docblock) and in `readme.md`'s "Что осознанно осталось
215 + * вне скоупа" section: anonymous form submitters never get automatic file cleanup
216 + * without going through a webhook or an admin, by design.
217 + *
218 + * `verified_identity` in the return value is kept (rather than collapsing this to a
219 + * plain bool) so `delete_attachments()`'s call to `is_owned_by_current_actor()` stays
220 + * self-documenting about WHY it passes `0` vs `$context_post_id` - it is always
221 + * `=== authorized` now that the anonymous branch is gone, but keeping the explicit field
222 + * name at the call site is clearer than an implicit "true always means verified" rule a
223 + * future change could silently invalidate by reintroducing an unverified branch here.
224 + *
225 + * @param int $context_post_id
226 + *
227 + * @return array{authorized: bool, verified_identity: bool}
228 + */
229 + private static function authorize_context_post( int $context_post_id ): array {
230 + if ( Tools::is_webhook() ) {
231 + return array(
232 + 'authorized' => true,
233 + 'verified_identity' => true,
234 + );
235 + }
236 +
237 + $current_user_id = get_current_user_id();
238 +
239 + if ( $current_user_id > 0 ) {
240 + $post = get_post( $context_post_id );
241 +
242 + if ( $post instanceof \WP_Post && absint( $post->post_author ) === $current_user_id ) {
243 + return array(
244 + 'authorized' => true,
245 + 'verified_identity' => true,
246 + );
247 + }
248 +
249 + if ( current_user_can( 'edit_post', $context_post_id ) ) {
250 + return array(
251 + 'authorized' => true,
252 + 'verified_identity' => true,
253 + );
254 + }
255 + }
256 +
257 + return array(
258 + 'authorized' => false,
259 + 'verified_identity' => false,
260 + );
261 + }
262 +
263 + /**
264 + * Shared ownership check for the plugin's `_jfb_uploaded_by_user` marker, used by BOTH
265 + * `delete_attachments()` here (may this attachment be deleted?) and
266 + * `Media_Field_Parser::is_allowed_submitted_attachment()` (may this attachment ID be
267 + * accepted into a post's meta?). The two questions apply the exact same trust model -
268 + * "does the current actor match who the plugin recorded as having uploaded this file?"
269 + * - so they share this one implementation instead of two independently maintained
270 + * copies that could silently drift apart.
271 + *
272 + * An attachment uploaded by a logged-in user may only be claimed (deleted OR newly
273 + * attached to a post) by that same user. Attachments carrying `_jfb_uploaded_by_form`
274 + * but NO `_jfb_uploaded_by_user` meta at all fall through to an unconditional allow
275 + * below, since there is nothing to bind them to. Both `Uploaded_File::add_attachment()`
276 + * (live uploads) and `Version_3_6_6::maybe_backfill_attachment()` (the migration) always
277 + * write BOTH markers together - including an explicit `_jfb_uploaded_by_user = 0` for
278 + * the anonymous case, precisely so a marked attachment never ends up in this
279 + * unconditional branch just because its real uploader happens to be "nobody" (see
280 + * `maybe_backfill_attachment()`'s docblock for why an earlier revision that left this
281 + * meta unset for `post_author = 0` was itself a security bug: this branch is meant only
282 + * for attachments that predate the marker system outright, and granting it to a marked
283 + * anonymous upload let any authenticated user claim/delete it via any post they
284 + * control). This branch is therefore not expected to be reachable through this fix's
285 + * own code paths going forward; it remains only as a documented fallback for an
286 + * attachment some other, unrelated code path marked `_jfb_uploaded_by_form` without
287 + * also setting an uploader value.
288 + *
289 + * An anonymously-uploaded attachment (uploader id `0`) cannot be told apart from a
290 + * stranger's anonymous upload by identity alone, since `0` is also what any logged-out
291 + * visitor's `get_current_user_id()` returns - claiming it on that basis would let any
292 + * anonymous visitor claim any other anonymous visitor's upload. `$owning_post_id` is
293 + * the one exception a caller can supply: when the caller has ALREADY (a) verified the
294 + * attachment ID was found in that specific post's own pre-save meta (proving it, not
295 + * some other post's anonymous upload, is what's being claimed) and (b) authorized the
296 + * current request to edit that same post via a VERIFIED IDENTITY - `Tools::is_webhook()`,
297 + * or a logged-in user's own `post_author` match / `edit_post` capability - never the
298 + * anonymous `post_author === 0 === get_current_user_id()` match alone. That last
299 + * distinction is load-bearing: `Post_Id_Property::can_attach()` (issues-tracker #20547's
300 + * still-open Defect 3) currently authorizes ANY anonymous visitor to edit ANY post with
301 + * `post_author = 0`, not just the post its own submission created, so an anonymous
302 + * post_author match proves nothing about who "the requester" actually is - passing it
303 + * through as ownership proof would let an attacker claim a stranger's anonymous upload
304 + * by first getting `Post_Id_Property::can_attach()` to authorize them on that stranger's
305 + * carrier post (trivial today, see Defect 3), then deleting the attachment through that
306 + * post's own cleanup. `delete_attachments()` is the only caller that can supply a
307 + * verified identity (via `authorize_context_post()` and the diff in
308 + * `maybe_delete_attachments()`), and only passes `$owning_post_id` when
309 + * `authorize_context_post()` reports `verified_identity => true`. `Media_Field_Parser::
310 + * is_allowed_submitted_attachment()` has no post context at all at the point it runs
311 + * (see `Uploaded_File::add_attachment()`'s docblock on why no post is authorized yet
312 + * when a field is parsed), so it always omits this parameter and an anonymous upload
313 + * remains claimable there only via `Tools::is_webhook()` - including by the very same
314 + * anonymous submitter resubmitting their own not-yet-authorized form. Concretely: if that
315 + * form's Media field has "Delete removed attachments" enabled, an anonymous visitor who
316 + * uploads a file and then resubmits the SAME form (e.g. multi-step form, validation
317 + * retry, or simply re-saving the post) WITHOUT changing that field will have their own
318 + * just-uploaded, still-wanted attachment physically deleted - `sanitize_submitted_
319 + * attachment_ids()` drops the ID from the "new" snapshot, `Media_Cleanup::
320 + * maybe_delete_attachments()` sees it missing from the diff, and `wp_delete_attachment()`
321 + * runs. This is a known, deliberate limitation, not an oversight - the request has no
322 + * way to prove "still the same visitor" beyond a session this check does not rely on -
323 + * but it is a real, user-facing data-loss risk for anonymous-upload + cleanup-enabled
324 + * forms, not merely a cosmetic "field forgets its value" annoyance. Closing it properly
325 + * needs a verifiable, request-scoped identity for anonymous uploads (e.g. binding the
326 + * upload to the plugin's own CSRF/session token infrastructure in modules/security/csrf/,
327 + * available at field-parse time) rather than `get_current_user_id()`, which cannot
328 + * distinguish anonymous visitors from each other; tracked as a follow-up, not fixed by
329 + * this parameter.
330 + *
331 + * @param int $attachment_id
332 + * @param int $owning_post_id Optional. A post ID the caller has already verified this
333 + * attachment ID came from AND authorized the current request
334 + * to edit via a verified (non-anonymous-match) identity. Only
335 + * ever passed by `delete_attachments()`.
336 + *
337 + * @return bool
338 + */
339 + public static function is_owned_by_current_actor( int $attachment_id, int $owning_post_id = 0 ): bool {
340 + $has_uploader_meta = metadata_exists( 'post', $attachment_id, '_jfb_uploaded_by_user' );
341 +
342 + if ( ! $has_uploader_meta ) {
343 + return true;
344 + }
345 +
346 + $uploader_id = (int) get_post_meta( $attachment_id, '_jfb_uploaded_by_user', true );
347 +
348 + if ( ! $uploader_id ) {
349 + return Tools::is_webhook() || $owning_post_id > 0;
350 + }
351 +
352 + if ( get_current_user_id() === $uploader_id || Tools::is_webhook() ) {
353 + return true;
354 + }
355 +
356 + /**
357 + * A non-zero `_jfb_uploaded_by_user` is normally ground truth - `Uploaded_File::
358 + * add_attachment()` records the actual `get_current_user_id()` at upload time, so
359 + * requiring an exact match is correct and intentionally has no `$owning_post_id`
360 + * fallback (unlike the anonymous-uploader branch above): that strictness is exactly
361 + * what stops one user from claiming another user's real upload.
362 + *
363 + * `Version_3_6_6::maybe_backfill_attachment()` is the one exception: it writes this
364 + * same meta key from `post_author` as a best-effort GUESS for pre-fix attachments,
365 + * not an observed fact, and marks that with `_jfb_uploaded_by_user_heuristic` (see
366 + * that method's docblock for why `post_author` can be stale/reassigned and is not
367 + * reliable enough to lock an attachment to forever). Without this exception, a
368 + * legacy attachment whose `post_author` no longer matches its post's actual current
369 + * editor would become permanently unmanageable by anyone but that one guessed user
370 + * or a webhook - worse than the pre-fix state, where an unmarked attachment was
371 + * freely manageable by any `edit_post`-authorized editor of its post. For a
372 + * heuristic value specifically, the same verified-identity `$owning_post_id`
373 + * exception the anonymous branch above uses also applies here.
374 + */
375 + if ( $owning_post_id > 0 && get_post_meta( $attachment_id, '_jfb_uploaded_by_user_heuristic', true ) ) {
376 + return true;
377 + }
378 +
379 + return false;
380 + }
381 +
113 382 public function remove_hooks() {
383 + if ( $this->media_ownership_migration_notice ) {
384 + $this->media_ownership_migration_notice->remove_hooks();
385 + }
114 386 }
115 387
116 388 public static function get_post_meta_keys_for_cleanup( $modifier ): array {
117 389 $form_id = jet_fb_action_handler()->get_form_id();