$options): array * * @phpstan-import-type SqlFragment from ABJ_404_Solution_DatabaseQueryBuilderInterface */ class ABJ_404_Solution_DatabaseTableNameResolver { /** @var ABJ_404_Solution_Functions */ private $f; /** @var callable(string, array): array */ private $queryRunner; /** @var ABJ_404_Solution_DatabaseMetadataLockWaitGuard */ private $metadataLockWaitGuard; /** * @param ABJ_404_Solution_Functions $functions * @param callable(string, array): array $queryRunner * Runs a SQL query through the centralized error-handling pipeline and * returns its result array. Supplied by DatabaseCore as a bound closure * over queryAndGetResults() so this class needs no DatabaseCore reference. * @param ABJ_404_Solution_Logging|null $logger */ public function __construct($functions, callable $queryRunner, $logger = null) { $this->f = $functions; $this->queryRunner = $queryRunner; $this->metadataLockWaitGuard = new ABJ_404_Solution_DatabaseMetadataLockWaitGuard($logger); } /** The engine answered, and the table is there. */ const TABLE_PRESENT = 'present'; /** The engine answered, and the table is not there. */ const TABLE_ABSENT = 'absent'; /** The engine did not answer, so presence is not known either way. */ const TABLE_UNKNOWN = 'unknown'; /** * Check if a database table exists. * * Answers the question callers who are about to CREATE or upgrade a table * ask -- "can I count on it being there?" -- so an unanswerable probe reads * as false, the same as absence. Callers deciding whether to SUPPRESS a read * must use {@see tableExistenceStatus()} instead, because for them the two * are not the same answer at all. * * @param string $tableName Full table name to check (including prefix) * @return bool */ public function tableExists($tableName): bool { return $this->tableExistenceStatus((string)$tableName) === self::TABLE_PRESENT; } /** * Whether a table is there, is not there, or could not be asked about. * * SHOW TABLES LIKE answers with a name or with nothing, and wpdb renders * "nothing" as NULL -- the same NULL it returns when the query never ran at * all. A lost connection, a revoked SHOW grant and a driver that does not * speak the statement are therefore indistinguishable from a genuinely * missing table unless last_error is read alongside the value, which is the * pair getTableColumnNames() below already reads for the same reason. * * The distinction is the whole point of this method: a caller that * suppresses a query on "absent" turns a transient database fault into a * confident, query-free zero on screen if it also suppresses on "could not * ask" -- silent by construction, because no query means nothing for the * centralized error handler to log. Unknown belongs to the caller to decide, * and the safe decision is to attempt the read and let that handler speak. * * @param string $tableName Full table name to check (including prefix) * @return string One of TABLE_PRESENT, TABLE_ABSENT, TABLE_UNKNOWN. */ public function tableExistenceStatus(string $tableName): string { global $wpdb; if (!isset($wpdb) || !is_object($wpdb) || !is_callable(array($wpdb, 'get_var'))) { return self::TABLE_UNKNOWN; } // @utf8-audit: opt-out - tableExistenceStatus receives system-generated plugin table names from DAO/core callers. // DAO-bypass-approved: metadata table existence probe for system-generated plugin table names. $guarded = $this->metadataLockWaitGuard->runWithBoundedWait($wpdb, array( 'description' => 'probing whether database table ' . $tableName . ' exists', 'operation' => function () use ($wpdb, $tableName) { // DAO-bypass-approved: bounded metadata existence probe for a system-generated table name. return $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'"); }, )); $table = $guarded['value']; if ($table == $tableName) { return self::TABLE_PRESENT; } // Read after the probe, never before: wpdb clears last_error at the // start of every query, so what is there now belongs to this one. $lastError = isset($wpdb->last_error) && is_string($wpdb->last_error) ? trim($wpdb->last_error) : ''; return $lastError === '' ? self::TABLE_ABSENT : self::TABLE_UNKNOWN; } /** * Get the column names of an actual database table via SHOW COLUMNS. * * @param string $tableName Full table name (including prefix) * @return array */ public function getTableColumnNames(string $tableName): array { global $wpdb; if (!isset($wpdb) || !is_object($wpdb) || !is_callable(array($wpdb, 'get_results'))) { return []; } // @utf8-audit: opt-out - getTableColumnNames receives system-generated plugin table names only. // DAO-bypass-approved: metadata column probe for system-generated plugin table names. $guarded = $this->metadataLockWaitGuard->runWithBoundedWait($wpdb, array( 'description' => 'reading database columns for ' . $tableName, 'operation' => function () use ($wpdb, $tableName) { // DAO-bypass-approved: bounded metadata column probe for a system-generated table name. return $wpdb->get_results("SHOW COLUMNS FROM `" . esc_sql($tableName) . "`", ARRAY_A); }, )); $rows = $guarded['value']; if (!is_array($rows) || !empty($wpdb->last_error)) { return []; } $columns = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } // Case-insensitive: MySQL drivers return SHOW COLUMNS metadata // key casing inconsistently (Field vs field). $row = array_change_key_case($row, CASE_LOWER); if (isset($row['field']) && is_scalar($row['field'])) { $columns[] = (string)$row['field']; } } return $columns; } /** * @param string $query * @return string */ public function doTableNameReplacements($query): string { global $wpdb; $replacements = array(); $tables = (isset($wpdb->tables) && is_array($wpdb->tables)) ? $wpdb->tables : array(); $prefix = isset($wpdb->prefix) && is_scalar($wpdb->prefix) ? (string)$wpdb->prefix : 'wp_'; foreach ($tables as $tableName) { if (!is_scalar($tableName)) { continue; } $tableNameStr = (string)$tableName; $replacements['{wp_' . $tableNameStr . '}'] = $prefix . $tableNameStr; } $replacements['{wp_users}'] = (isset($wpdb->users) && is_scalar($wpdb->users)) ? (string)$wpdb->users : ($prefix . 'users'); $replacements['{wp_prefix}'] = $prefix; $replacements['{wp_prefix_lower}'] = $this->getLowercasePrefix(); // Every template that uses this token pins it on an expression already // CONVERTed to utf8mb4, so the collation has to belong to that charset. // Handing back $wpdb->collate raw is what made a latin1-configured site // fail every one of those statements with errno 1253; the token is named // for the charset it is valid under so no future template can read it as // "whatever the site collation happens to be". $rawCollate = (isset($wpdb->collate) && is_scalar($wpdb->collate)) ? (string)$wpdb->collate : ''; $replacements['{utf8mb4_collate}'] = ABJ_404_Solution_DatabaseCollationHelper::utf8mb4CollationOrFallback($rawCollate); $query = $this->f->str_replace(array_keys($replacements), array_values($replacements), $query); $fpreg = ABJ_404_Solution_FunctionsPreg::getInstance(); $query = $fpreg->regexReplace('[{]wp_abj404_(.*?)[}]', $this->getLowercasePrefix() . "abj404_\\1", $query); return $query !== null ? $query : ''; } /** @return string */ public function getLowercasePrefix(): string { global $wpdb; return $this->f->strtolower($wpdb->prefix ?? 'wp_'); } /** * @param string $tableSuffix * @return string */ public function getPrefixedTableName($tableSuffix): string { return $this->getLowercasePrefix() . ltrim($tableSuffix, '_'); } /** * @param string $tableName * @return string */ public function getCreateTableDDL($tableName): string { $query = "show create table " . $tableName; $result = ($this->queryRunner)($query, array('log_errors' => false, 'skip_repair' => true)); $rows = $result['rows']; if (!is_array($rows) || empty($rows) || !isset($rows[0]) || !is_array($rows[0])) { return ''; } $row1 = array_values($rows[0]); $existingTableSQL = $row1[1] ?? ''; return is_scalar($existingTableSQL) ? (string)$existingTableSQL : ''; } /** * @param array $options * @return SqlFragment The IN (...) list for recognized_post_types: one `%s` per type * and the types to bind. Empty when the setting is empty. */ public function buildPostTypeSqlList(array $options): array { return $this->buildPlaceholderList($options, 'recognized_post_types'); } /** * @param array $options * @return SqlFragment The IN (...) list for recognized_categories: one `%s` per category * and the categories to bind. Empty when the setting is empty. */ public function buildCategorySqlList(array $options): array { return $this->buildPlaceholderList($options, 'recognized_categories'); } /** * Turn one free-text setting into an IN (...) list of `%s` placeholders and the values * that fill them. The values never enter the SQL text. * * The setting is a free-text textarea, stored through wp_kses_post() (an HTML sanitizer * that does nothing to a single quote), and its content reaches three live queries * against wp_posts and wp_term_taxonomy. Two earlier designs failed on it. Splicing the * raw value let a quote close its own literal and run as syntax (a stored injection * whose trigger is separated from the write by however long it takes someone to ask for * published content). Quoting it here with esc_sql() and splicing the result stopped * that but still put data in the statement text, where the executor's `{wp_...}` token * pass runs, so a value containing `{wp_posts}` came out as `wp_posts` and never * matched. Binding through query_params is the one form where neither can happen: the * executor's pass has already run when the values are bound, and wpdb::prepare() owns * the quoting, including the NO_BACKSLASH_ESCAPES mode a hand-rolled quote would miss. * * Bound rather than allowlisted on purpose. recognized_post_types would be safe under * a strict [a-z0-9_-] identifier rule, but recognized_categories is matched against * lower(wp_terms.name) as well as the taxonomy key (getPublishedCategories.sql), and a * term name is display text: "women's shoes" is a legitimate setting. One rule for both * builders is also what keeps them from drifting apart. * * @param array $options * @param string $optionName * @return SqlFragment */ private function buildPlaceholderList(array $options, string $optionName): array { $rawValue = $options[$optionName] ?? ''; // explodeNewlineOrComma() already lowercases, trims and drops empties. $values = $this->f->explodeNewlineOrComma(is_string($rawValue) ? $rawValue : ''); if (empty($values)) { return array('sql' => '', 'params' => array()); } $params = array(); foreach ($values as $value) { // Sanitize BEFORE binding, and do it here rather than trusting a caller. // wpdb::prepare() escapes through mysqli_real_escape_string(), which escapes // quotes and passes malformed byte sequences through untouched; on a connection // whose charset disagrees with those bytes a truncated lead byte can absorb the // escaping backslash and hand the next quote to the parser as syntax. Pattern 10 // ("invalid UTF-8 reaches SQL") is this project's own recurring class, and these // two settings are free-text textareas, so their bytes are entirely // attacker-chosen. // // It looked safe without this: explodeNewlineOrComma() lowercases, and with // mbstring loaded mb_strtolower() substitutes malformed bytes as a side effect. // MbStringAdapterPreg::strtolower() is plain strtolower() and does not, so every // host without the mbstring extension -- a configuration this plugin supports on // purpose -- had no sanitization at all here. A security property resting on an // incidental side effect of a lowercasing call is not a security property. $params[] = $this->f->sanitizeInvalidUTF8($value); } return ABJ_404_Solution_SqlFragmentTemplate::inList($params); } /** @return void */ public function setSqlBigSelects(): void { $ignoreErrorsOptions = array('log_errors' => false); ($this->queryRunner)("set session max_join_size = 18446744073709551615", $ignoreErrorsOptions); ($this->queryRunner)("set session sql_big_selects = 1", $ignoreErrorsOptions); } }