@@ -185,6 +185,9 @@ public function was_task_completed( $task_id ): bool {
185185 * Primarly this is used for deeplinking, ie user is testing if the emails are working
186186 * He gets an email with a link which automatically completes the task.
187187 *
188+ * Verify token to prevent CSRF attacks.
189+ * Tokens are one-time use and expire after 24 hours.
190+ *
188191 * @return void
189192 */
190193 public function maybe_complete_task () {
@@ -197,6 +200,19 @@ public function maybe_complete_task() {
197200 return ;
198201 }
199202
203+ // Verify token to prevent CSRF attacks.
204+ if ( ! isset ( $ _GET ['token ' ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
205+ return ;
206+ }
207+
208+ $ provided_token = \sanitize_text_field ( \wp_unslash ( $ _GET ['token ' ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
209+ $ user_id = \get_current_user_id ();
210+
211+ // Verify the token.
212+ if ( ! $ this ->verify_task_completion_token ( $ task_id , $ user_id , $ provided_token ) ) {
213+ return ;
214+ }
215+
200216 if ( ! $ this ->was_task_completed ( $ task_id ) ) {
201217 $ task = \progress_planner ()->get_suggested_tasks_db ()->get_post ( $ task_id );
202218
@@ -205,10 +221,71 @@ public function maybe_complete_task() {
205221
206222 // Insert an activity.
207223 $ this ->insert_activity ( $ task_id );
224+
225+ // Delete the token after successful use (one-time use).
226+ $ this ->delete_task_completion_token ( $ task_id , $ user_id );
208227 }
209228 }
210229 }
211230
231+ /**
232+ * Generate a secure token for task completion via email link.
233+ *
234+ * This token prevents CSRF attacks by ensuring only legitimate email
235+ * links can mark tasks as complete.
236+ *
237+ * @param string $task_id The task ID.
238+ * @param int $user_id The user ID.
239+ *
240+ * @return string The generated token.
241+ */
242+ public function generate_task_completion_token ( $ task_id , $ user_id ) {
243+ // Generate a cryptographically secure random token.
244+ $ random = \wp_generate_password ( 32 , false );
245+ $ token = \wp_hash ( $ task_id . $ user_id . $ random . \wp_salt ( 'auth ' ) );
246+
247+ // Store the token in a transient (expires after 24 hours).
248+ \set_transient (
249+ 'prpl_complete_ ' . $ task_id . '_ ' . $ user_id ,
250+ $ token ,
251+ DAY_IN_SECONDS
252+ );
253+
254+ return $ token ;
255+ }
256+
257+ /**
258+ * Verify a task completion token.
259+ *
260+ * @param string $task_id The task ID.
261+ * @param int $user_id The user ID.
262+ * @param string $provided_token The token to verify.
263+ *
264+ * @return bool True if token is valid, false otherwise.
265+ */
266+ protected function verify_task_completion_token ( $ task_id , $ user_id , $ provided_token ) {
267+ $ stored_token = \get_transient ( 'prpl_complete_ ' . $ task_id . '_ ' . $ user_id );
268+
269+ if ( ! $ stored_token ) {
270+ return false ; // Token expired or doesn't exist.
271+ }
272+
273+ // Use hash_equals to prevent timing attacks.
274+ return \hash_equals ( $ stored_token , $ provided_token );
275+ }
276+
277+ /**
278+ * Delete a task completion token after use.
279+ *
280+ * @param string $task_id The task ID.
281+ * @param int $user_id The user ID.
282+ *
283+ * @return bool True if deleted, false otherwise.
284+ */
285+ protected function delete_task_completion_token ( $ task_id , $ user_id ) {
286+ return \delete_transient ( 'prpl_complete_ ' . $ task_id . '_ ' . $ user_id );
287+ }
288+
212289 /**
213290 * Handle the suggested task action.
214291 *
0 commit comments