Skip to content

fix/SQL injection + missing authorization in the string auto-translate AJAX - #41

Open
SNO7E-G wants to merge 1 commit into
OnTheGoSystems:masterfrom
SNO7E-G:fix/strings-ajax-sqli-and-cap-check
Open

fix/SQL injection + missing authorization in the string auto-translate AJAX#41
SNO7E-G wants to merge 1 commit into
OnTheGoSystems:masterfrom
SNO7E-G:fix/strings-ajax-sqli-and-cap-check

Conversation

@SNO7E-G

@SNO7E-G SNO7E-G commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary
Sanitizes the paging parameters that reach a SQL LIMIT clause and requires manage_options on the wp_ajax_generate_strings_translations_action endpoint.

Problem
generate_strings_translations() built ... LIMIT {$offset}, {$limit} with $offset taken directly from $_POST['offset']. $contexts was already esc_sql()-quoted and $limit is a hardcoded 100, but $offset was unsanitized — an injection point. The if ( $offset <= $count ) guard does not sanitize: PHP's loose <= compares a non-numeric string without rejecting it. The endpoint was protected only by a nonce, which is a CSRF token, not an authorization check.

Fix & changes
inc/wpml-compatibility-test-tools.class.php, one hunk:

  • $offset and $count are cast with absint().
  • $count keeps its false sentinel (isset(...) ? absint(...) : false) — line 197 relies on $count === false to detect the first batch and run the COUNT(*).
  • Added current_user_can( 'manage_options' )wp_send_json_error( 'forbidden', 403 ) after the nonce check.

Risk
Low. absint() is the identity on the non-negative integers the server itself emitted in the previous response (res/js/mt-script.js:265-273 echoes offset/count back), so paging, the $offset += $limit accumulation and the progress math are unchanged. manage_options matches all five menu registrations (class.php:309-325). Review noted the practical pre-fix exposure was lower than "any logged-in user" — WP nonces are bound to user ID and session, and the nonce field is only rendered on a manage_options page — so this is defence in depth plus a real injection fix. The commit message is worded accordingly.

Verified tests (t1-sqli-cap.php)

  • php -l clean.
  • Real generate_strings_translations() executed, SQL captured from $wpdb. With $_POST['offset'] = "0 UNION SELECT ID,user_login,user_pass,user_email FROM wp_users -- ":
    • master: … WHERE context IN ('my-theme') LIMIT 0 UNION SELECT ID,user_login,user_pass,user_email FROM wp_users -- , 100
    • branch: … WHERE context IN ('my-theme') LIMIT 0, 100
  • Authorization, executed end to end. Subscriber holding a valid nonce: master returns {"offset":100,"count":"250","progress":40} and runs 1 query; branch returns {"success":false,"data":"forbidden"} with HTTP 403 and runs 0 queries. Administrator unaffected; a bad nonce still wp_die()s through the real check_ajax_referer.
  • Real absint() (confirmed live via ReflectionFunction to be load.php:1468) on 100 UNION SELECT …100; 1'; DROP TABLE wp_users; --1; 0 PROCEDURE ANALYSE(EXTRACTVALUE(1,CONCAT(0x3a,version())),1)0; abc/''0; -55; 300300.
  • wp_send_json_error( $value, $status_code, $flags ) confirmed at functions.php:4614; terminates via wp_die().
  • Scope note — what this PR fixes vs. what it leaves alone. The injection this PR fixes (offset) is proven by execution above. The other variable in the same query, context IN ({$esc_contexts}), is pre-existing code that this PR does not modify. It was audited separately and is safe, confirmed by reading core:
    • esc_sql()wpdb::_escape()wpdb::_real_escape() (class-wpdb.php:1271), which uses mysqli_real_escape_string() whenever a DB connection exists (line 1276-1277) — i.e. always in production. That is connection-charset aware, which is what defeats the multibyte/GBK-style bypass that plain addslashes() is vulnerable to. addslashes() is only the fallback for a connectionless wpdb, which core itself flags via _doing_it_wrong() (line 1283).
    • Each escaped context is then wrapped in single quotes ("'" . esc_sql( $context ) . "'"), so it lands as a quoted string literal — the canonical safe pattern. (Escaping is only insufficient when a value is interpolated unquoted, which was exactly the offset case this PR fixes.)
    • Hostile shapes degrade safely, not into injection: a non-scalar reaching _real_escape() returns '' (line 1272-1274), and a nested array (contexts[0][]=x) makes _escape() return an array, so concatenation yields the literal 'Array' plus a PHP notice — a query that matches nothing. Worth tidying one day; not a vulnerability, and not a regression from this PR.
    • The integration harness could not exercise this path because it stubs wpdb::_escape with addslashes (proving real mysqli_real_escape_string behaviour would need a live MySQL connection), so this conclusion rests on
      source review, not execution. Recorded that way deliberately.
    • The independent reviewer reached the same conclusion: "no remaining unsanitized path into SQL in this method."
  • Minor behaviour note: the JSON count changes from string "250" to int 250; the JS only posts it back.

The generate_strings_translations() AJAX handler interpolated $_POST['offset']
(and $_POST['count']) directly into a LIMIT clause, allowing SQL injection, and
was protected only by a nonce so any logged-in user could reach it.

Cast offset and count with absint() before use (keeping the `false` sentinel that
detects the first batch), and require the manage_options capability that already
gates the plugin's menus.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant