Skip to content

Cross-language content leakage & progress-data corruption in learn_press_get_post_by_name() when used with Polylang #643

Description

@Marinski

Summary

learn_press_get_post_by_name() resolves a lesson/quiz slug with no course or language scoping. When a translation plugin (e.g. Polylang) creates a translated lesson with the same post_name as its English source — which is normal, intended behavior for those plugins — the lookup can silently return the wrong-language post instead of the one that actually belongs to the course being viewed. This causes two distinct problems on a live site:

  1. Wrong content is rendered. A learner viewing an English course can land on a lesson page that renders a German/Spanish/etc. lesson, inside the English course's own UI (title, curriculum sidebar, progress bar).
  2. Corrupted progress data is persisted. Because LearnPress's own lesson-view tracking trusts whatever post object the buggy lookup returns, it writes an lp_user_item row recording the wrong (translated) post's ID as a lesson viewed/completed inside the English course. That row references a post that was never in the course's section_items at all, so it becomes permanent orphaned junk in the user's progress history.

On our own site we found 119 corrupted progress rows across 15 users and 7 courses, all created in the same ~2 week window, all with the identical signature: translated-language item, English-language course.

Environment

  • LearnPress (latest, 4.x)
  • Polylang Pro + a machine-translation add-on that bulk-creates translated lesson/quiz posts
  • Any setup where a translated CPT post is allowed to share post_name with its source post (standard Polylang behavior — it does not enforce unique slugs across languages)

Root cause

wp-content/plugins/learnpress/inc/lp-core-functions.php, function learn_press_get_post_by_name() (~line 460):

function learn_press_get_post_by_name( $name, $type, $single = true ) {
    $post_name = sanitize_title( $name );
    $id = LP_Object_Cache::get( $type . '-' . $post_name, 'learn-press/post-names' );
    if ( false === $id ) {
        foreach ( array( $name, urldecode( $name ) ) as $_name ) {
            $args = array( 'name' => $_name, 'post_type' => array( $type ) );
            $posts = get_posts( $args );   // <-- no language/course scoping
            if ( $posts ) {
                $post = $posts[0];         // <-- get_posts() defaults to orderby=date DESC,
                $id = $post->ID;           //     so the *newest* same-slug post always wins
                ...
            }
        }
    }
    return $id ? get_post( $id ) : false;
}

Two compounding defaults make this exploitable whenever slugs collide across languages:

  • get_posts() defaults to orderby => date DESC — when a translated sibling post shares the slug, whichever was created most recently wins the lookup, regardless of which one actually belongs to the course/language being viewed.
  • get_posts() defaults to suppress_filters => true — this causes Polylang's own pre_get_posts language filtering to be skipped entirely, so even a lang query var set elsewhere has no effect on this specific call.

This function is called from the main course-item resolver in inc/class-lp-page-controller.php (~line 401) and again from inc/Gutenberg/GutenbergHandleMain.php (~line 161), both without any course/language disambiguation.

Compounding this: LP_Course::get_item() (inc/course/abstract-course.php, ~line 1043) performs no validation that the resolved item ID actually belongs to the course's section_items — it wraps whatever ID it's given. So there is no safety net downstream of the bad lookup either.

Suggested fix

Scope the lookup by the current course's Polylang language before running the query, and stop suppressing filters so Polylang's own language filtering can apply. We're running the following as a local pre_get_posts patch and have verified it resolves both the display and data-corruption symptoms with no regressions:

add_action( 'pre_get_posts', function ( $query ) {
    if ( ! ( $query instanceof WP_Query ) || is_admin() ) {
        return;
    }
    if ( ! function_exists( 'pll_current_language' ) && ! function_exists( 'pll_get_post_language' ) ) {
        return;
    }

    $name = $query->get( 'name' );
    if ( empty( $name ) ) {
        return;
    }

    $post_type = $query->get( 'post_type' );
    if ( ! is_array( $post_type ) || count( $post_type ) !== 1 ) {
        return;
    }
    if ( ! in_array( $post_type[0], array( 'lp_lesson', 'lp_quiz' ), true ) ) {
        return;
    }

    $lang = '';
    if ( isset( $GLOBALS['course'] ) && is_object( $GLOBALS['course'] )
         && method_exists( $GLOBALS['course'], 'get_id' ) && function_exists( 'pll_get_post_language' ) ) {
        $lang = (string) pll_get_post_language( $GLOBALS['course']->get_id() );
    }
    if ( '' === $lang && function_exists( 'pll_current_language' ) ) {
        $lang = (string) pll_current_language( 'slug' );
    }
    if ( '' === $lang ) {
        return;
    }

    $query->set( 'suppress_filters', false );
    $query->set( 'lang', $lang );
}, 5 );

We'd suggest folding an equivalent scoping directly into learn_press_get_post_by_name() itself (guarded behind function_exists('pll_get_post_language') so it's a no-op without Polylang), plus adding a section_items membership check in LP_Course::get_item() as defense in depth — that second check alone would have prevented the wrong content from ever rendering, independent of the lookup bug.

Happy to share our full patch, the exact repro steps (any Polylang + machine-translation setup that duplicates a lesson slug across languages inside a course reproduces this reliably), or test further against a patched build if useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions