@@ -188,6 +188,9 @@ public function was_task_completed( $task_id ): bool {
188188 * Primarly this is used for deeplinking, ie user is testing if the emails are working
189189 * He gets an email with a link which automatically completes the task.
190190 *
191+ * SECURITY FIX: Added token verification to prevent CSRF attacks.
192+ * Tokens are one-time use and expire after 24 hours.
193+ *
191194 * @return void
192195 */
193196 public function maybe_complete_task () {
@@ -200,6 +203,19 @@ public function maybe_complete_task() {
200203 return ;
201204 }
202205
206+ // SECURITY FIX: Verify token to prevent CSRF attacks.
207+ if ( ! isset ( $ _GET ['token ' ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
208+ return ;
209+ }
210+
211+ $ provided_token = \sanitize_text_field ( \wp_unslash ( $ _GET ['token ' ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
212+ $ user_id = \get_current_user_id ();
213+
214+ // Verify the token.
215+ if ( ! $ this ->verify_task_completion_token ( $ task_id , $ user_id , $ provided_token ) ) {
216+ return ;
217+ }
218+
203219 if ( ! $ this ->was_task_completed ( $ task_id ) ) {
204220 $ task = \progress_planner ()->get_suggested_tasks_db ()->get_post ( $ task_id );
205221
@@ -208,10 +224,71 @@ public function maybe_complete_task() {
208224
209225 // Insert an activity.
210226 $ this ->insert_activity ( $ task_id );
227+
228+ // Delete the token after successful use (one-time use).
229+ $ this ->delete_task_completion_token ( $ task_id , $ user_id );
211230 }
212231 }
213232 }
214233
234+ /**
235+ * Generate a secure token for task completion via email link.
236+ *
237+ * This token prevents CSRF attacks by ensuring only legitimate email
238+ * links can mark tasks as complete.
239+ *
240+ * @param string $task_id The task ID.
241+ * @param int $user_id The user ID.
242+ *
243+ * @return string The generated token.
244+ */
245+ public function generate_task_completion_token ( $ task_id , $ user_id ) {
246+ // Generate a cryptographically secure random token.
247+ $ random = \wp_generate_password ( 32 , false );
248+ $ token = \wp_hash ( $ task_id . $ user_id . $ random . \wp_salt ( 'auth ' ) );
249+
250+ // Store the token in a transient (expires after 24 hours).
251+ \set_transient (
252+ 'prpl_complete_ ' . $ task_id . '_ ' . $ user_id ,
253+ $ token ,
254+ DAY_IN_SECONDS
255+ );
256+
257+ return $ token ;
258+ }
259+
260+ /**
261+ * Verify a task completion token.
262+ *
263+ * @param string $task_id The task ID.
264+ * @param int $user_id The user ID.
265+ * @param string $provided_token The token to verify.
266+ *
267+ * @return bool True if token is valid, false otherwise.
268+ */
269+ protected function verify_task_completion_token ( $ task_id , $ user_id , $ provided_token ) {
270+ $ stored_token = \get_transient ( 'prpl_complete_ ' . $ task_id . '_ ' . $ user_id );
271+
272+ if ( ! $ stored_token ) {
273+ return false ; // Token expired or doesn't exist.
274+ }
275+
276+ // Use hash_equals to prevent timing attacks.
277+ return \hash_equals ( $ stored_token , $ provided_token );
278+ }
279+
280+ /**
281+ * Delete a task completion token after use.
282+ *
283+ * @param string $task_id The task ID.
284+ * @param int $user_id The user ID.
285+ *
286+ * @return bool True if deleted, false otherwise.
287+ */
288+ protected function delete_task_completion_token ( $ task_id , $ user_id ) {
289+ return \delete_transient ( 'prpl_complete_ ' . $ task_id . '_ ' . $ user_id );
290+ }
291+
215292 /**
216293 * Handle the suggested task action.
217294 *
0 commit comments