Require a nonce and lesson access to toggle lesson progress - #143
Require a nonce and lesson access to toggle lesson progress#143kimcoleman wants to merge 1 commit into
Conversation
pmpro_courses_toggle_lesson_progress_ajax() verified nothing beyond a logged in user. The handler is registered on wp_ajax_ only, so a visitor had to be logged in, and it always writes for get_current_user_id(), so one member could not alter another's progress. Two gaps remained: - No nonce, so any page could drive a logged in member's browser into marking lessons complete. - No access check, so any logged in user could mark progress on any lesson, including ones their membership does not open. Adds a nonce to the localized frontend data and verifies it, rejects a lid that is not a published lesson, and requires pmpro_has_membership_access() for the lesson before writing. Checking access on the lesson rather than its parent course keeps this correct as lesson level restrictions are what pmpro_has_membership_access evaluates, and it picks up the free lesson bypass through the existing pmpro_has_membership_access_filter_pmpro_lesson filter. Verified: missing and invalid nonces are rejected with -1, a lesson the member's level does not grant returns 403, a non-lesson or empty lid returns 400, and the guards still pass for a free lesson. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
flintfromthebasement
left a comment
There was a problem hiding this comment.
PR: #143 — Require a nonce and lesson access to toggle lesson progress
kimcoleman → dev | 3 files, +20 -5
#143
Summary
The CSRF fix is solid, but the new authorization check doesn't do what the description claims. Not ready to merge until the access check accounts for course-level restrictions.
Issues
-
[Critical]
includes/progress.php:286—pmpro_has_membership_access( $lesson_id )checks restriction rows keyed to the lesson's own post ID only (verified in PMPro core,includes/content.php:65-85: the SQL querieswp_pmpro_memberships_pages/wp_pmpro_memberships_categoriesby$mypost->ID, with no parent traversal for thepmpro_lessonpost type — that fallback only exists forattachment/revisionatcontent.php:43-46). In this plugin's own admin UI, restriction is only ever set on the course, not individual lessons (includes/courses.php:211-228, the "Level" column readswp_pmpro_memberships_pageskeyed by$course_id). That's exactly why the plugin's own frontend gate checks the parent first:includes/courses.php:273-282callspmpro_has_membership_access( $post->post_parent )for a lesson, only falling back to the lesson's own ID to catch the Free Lesson bypass. The new AJAX handler skips that parent check entirely, so for a normal restricted course (no per-lesson restriction row),pmpro_has_membership_access( $lesson_id )returnstruefor any logged-in user regardless of their level — the write-access gate this PR adds doesn't actually block the scenario in its own test table ("lesson the member's level does not grant"). Fix: mirrorcourses.php:273-282— checkpmpro_has_membership_access( $lesson->post_parent )first, and only use the lesson-level check as the fallback that picks up the Free Lesson bypass. -
[Minor]
js/frontend.js:16-20—wp_die( '', '', array( 'response' => 400/403 ) )returns a non-2xx status, sojQuery.get()'s success callback never fires and the button silently does nothing. Not blocking — the goal is to block the write, not to be polite — but worth a.fail()handler that reverts the pending UI state so a legitimately-blocked user (stale nonce, page open too long) isn't left staring at an unresponsive toggle.
Looks Good
- Nonce action string matches between creation (
pmpro-courses.php:207,wp_create_nonce( 'pmpro_courses_toggle_lesson_progress' )) and verification (progress.php:274,check_ajax_referer( 'pmpro_courses_toggle_lesson_progress', 'nonce' )) — no mismatch. absint()plus an explicitempty()/get_post_type()check onlidis a real tightening over the old bareintval( $_REQUEST['lid'] ), and rejecting before any DB write closes offpmpro_has_membership_access()'s falsy-$post_id-returns-truetrap (content.php:22-23).- The handler is
wp_ajax_-only (nonopriv), so this was never reachable by anonymous visitors — the fix is scoped correctly to the actual CSRF/authorization gap.
Questions
js/frontend.js:15sends the nonce viajQuery.get, putting it in the URL query string and server access logs. Given the PR's theme is hardening this request, is a switch tojQuery.postin scope, or intentionally deferred to a follow-up?
Problem
pmpro_courses_toggle_lesson_progress_ajax()(includes/progress.php) verified nothing beyond a logged-in user before callingPMPro_Courses_User_Progress::toggle_lesson_progress().To be fair to the existing code, two things already limited the impact:
wp_ajax_only (nonopriv), so a visitor had to be logged in.get_current_user_id()and ignores any user passed in, so one member could not alter another member's progress.Two real gaps remained:
Change
ajaxurland send it with the toggle request (pmpro-courses.php,js/frontend.js).check_ajax_referer( 'pmpro_courses_toggle_lesson_progress', 'nonce' )in the handler.lidthat is not apmpro_lesson.pmpro_has_membership_access( $lesson_id )before writing.Access is checked on the lesson rather than the parent course because lesson-level restrictions are what
pmpro_has_membership_access()evaluates, and going through it picks up the Free Lesson bypass via the existingpmpro_has_membership_access_filter_pmpro_lessonfilter.Testing
Exercised the handler directly against a local site. The DB write path was deliberately not run, so no progress rows were touched.
-1-1403400lid=0400Note
Found while reviewing the lesson drip work (#142), but this is pre-existing and independent of it, so it is split out here against
dev. It composes with drip rather than duplicating it: once #142 lands,pmpro_has_membership_access()also consults the release date through the same post-type filter, so an unreleased lesson stops accepting progress writes with no further change here.