Skip to content

Insufficient Permission on set_course_mode_price()

High
feanil published GHSA-rqq6-w4pv-7pjv Jun 1, 2026

Package

pip openedx-platform (pip)

Affected versions

>= aspen.1

Patched versions

None

Description

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&currency=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

  1. 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
  1. 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.

  2. 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

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L

CVE ID

CVE-2026-53635

Weaknesses

Missing Authorization

The product does not perform an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Credits