PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / database / DatabaseTableNameResolver.php

DatabaseTableNameResolver.php in 404 Solution 4.3.3, at includes/database/DatabaseTableNameResolver.php

292 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Resolves plugin table names and reads table schema metadata.
9 *
10 * Owns the pure, stateless half of the former DatabaseCore: expanding
11 * {wp_*} placeholders to physical prefixed names, building plugin table
12 * names from suffixes, checking table existence, reading column names and
13 * CREATE TABLE DDL, and producing option-derived SQL value lists.
14 *
15 * This class holds no error-handling state. The two methods that must run
16 * SQL (getCreateTableDDL, setSqlBigSelects) receive a query-runner callable
17 * at construction time, so the resolver depends on a function rather than
18 * on DatabaseCore itself (no cyclic coupling). The callable signature is
19 * the same as DatabaseCore::queryAndGetResults():
20 * function(string $query, array<string,mixed> $options): array<string,mixed>
21 */
22 class ABJ_404_Solution_DatabaseTableNameResolver {
23
24 /** @var ABJ_404_Solution_Functions */
25 private $f;
26
27 /** @var callable(string, array<string,mixed>): array<string,mixed> */
28 private $queryRunner;
29
30 /**
31 * @param ABJ_404_Solution_Functions $functions
32 * @param callable(string, array<string,mixed>): array<string,mixed> $queryRunner
33 * Runs a SQL query through the centralized error-handling pipeline and
34 * returns its result array. Supplied by DatabaseCore as a bound closure
35 * over queryAndGetResults() so this class needs no DatabaseCore reference.
36 */
37 public function __construct($functions, callable $queryRunner) {
38 $this->f = $functions;
39 $this->queryRunner = $queryRunner;
40 }
41
42 /** The engine answered, and the table is there. */
43 const TABLE_PRESENT = 'present';
44
45 /** The engine answered, and the table is not there. */
46 const TABLE_ABSENT = 'absent';
47
48 /** The engine did not answer, so presence is not known either way. */
49 const TABLE_UNKNOWN = 'unknown';
50
51 /**
52 * Check if a database table exists.
53 *
54 * Answers the question callers who are about to CREATE or upgrade a table
55 * ask -- "can I count on it being there?" -- so an unanswerable probe reads
56 * as false, the same as absence. Callers deciding whether to SUPPRESS a read
57 * must use {@see tableExistenceStatus()} instead, because for them the two
58 * are not the same answer at all.
59 *
60 * @param string $tableName Full table name to check (including prefix)
61 * @return bool
62 */
63 public function tableExists($tableName): bool {
64 return $this->tableExistenceStatus((string)$tableName) === self::TABLE_PRESENT;
65 }
66
67 /**
68 * Whether a table is there, is not there, or could not be asked about.
69 *
70 * SHOW TABLES LIKE answers with a name or with nothing, and wpdb renders
71 * "nothing" as NULL -- the same NULL it returns when the query never ran at
72 * all. A lost connection, a revoked SHOW grant and a driver that does not
73 * speak the statement are therefore indistinguishable from a genuinely
74 * missing table unless last_error is read alongside the value, which is the
75 * pair getTableColumnNames() below already reads for the same reason.
76 *
77 * The distinction is the whole point of this method: a caller that
78 * suppresses a query on "absent" turns a transient database fault into a
79 * confident, query-free zero on screen if it also suppresses on "could not
80 * ask" -- silent by construction, because no query means nothing for the
81 * centralized error handler to log. Unknown belongs to the caller to decide,
82 * and the safe decision is to attempt the read and let that handler speak.
83 *
84 * @param string $tableName Full table name to check (including prefix)
85 * @return string One of TABLE_PRESENT, TABLE_ABSENT, TABLE_UNKNOWN.
86 */
87 public function tableExistenceStatus(string $tableName): string {
88 global $wpdb;
89 if (!isset($wpdb) || !is_object($wpdb) || !is_callable(array($wpdb, 'get_var'))) {
90 return self::TABLE_UNKNOWN;
91 }
92 // @utf8-audit: opt-out - tableExistenceStatus receives system-generated plugin table names from DAO/core callers.
93 // DAO-bypass-approved: metadata table existence probe for system-generated plugin table names.
94 $table = $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'");
95 if ($table == $tableName) {
96 return self::TABLE_PRESENT;
97 }
98 // Read after the probe, never before: wpdb clears last_error at the
99 // start of every query, so what is there now belongs to this one.
100 $lastError = isset($wpdb->last_error) && is_string($wpdb->last_error) ? trim($wpdb->last_error) : '';
101 return $lastError === '' ? self::TABLE_ABSENT : self::TABLE_UNKNOWN;
102 }
103
104 /**
105 * Get the column names of an actual database table via SHOW COLUMNS.
106 *
107 * @param string $tableName Full table name (including prefix)
108 * @return array<int, string>
109 */
110 public function getTableColumnNames(string $tableName): array {
111 global $wpdb;
112 if (!isset($wpdb) || !is_object($wpdb) || !is_callable(array($wpdb, 'get_results'))) { return []; }
113 // @utf8-audit: opt-out - getTableColumnNames receives system-generated plugin table names only.
114 // DAO-bypass-approved: metadata column probe for system-generated plugin table names.
115 $rows = $wpdb->get_results("SHOW COLUMNS FROM `" . esc_sql($tableName) . "`", ARRAY_A);
116 if (!is_array($rows) || !empty($wpdb->last_error)) { return []; }
117 $columns = [];
118 foreach ($rows as $row) {
119 if (!is_array($row)) {
120 continue;
121 }
122 // Case-insensitive: MySQL drivers return SHOW COLUMNS metadata
123 // key casing inconsistently (Field vs field).
124 $row = array_change_key_case($row, CASE_LOWER);
125 if (isset($row['field']) && is_scalar($row['field'])) {
126 $columns[] = (string)$row['field'];
127 }
128 }
129 return $columns;
130 }
131
132 /**
133 * @param string $query
134 * @return string
135 */
136 public function doTableNameReplacements($query): string {
137 global $wpdb;
138
139 $replacements = array();
140 $tables = (isset($wpdb->tables) && is_array($wpdb->tables)) ? $wpdb->tables : array();
141 $prefix = isset($wpdb->prefix) && is_scalar($wpdb->prefix) ? (string)$wpdb->prefix : 'wp_';
142 foreach ($tables as $tableName) {
143 if (!is_scalar($tableName)) { continue; }
144 $tableNameStr = (string)$tableName;
145 $replacements['{wp_' . $tableNameStr . '}'] = $prefix . $tableNameStr;
146 }
147 $replacements['{wp_users}'] = (isset($wpdb->users) && is_scalar($wpdb->users))
148 ? (string)$wpdb->users : ($prefix . 'users');
149 $replacements['{wp_prefix}'] = $prefix;
150 $replacements['{wp_prefix_lower}'] = $this->getLowercasePrefix();
151
152 $wpdbCollate = 'utf8mb4_unicode_ci';
153 if (isset($wpdb->collate) && !empty($wpdb->collate)) {
154 $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $wpdb->collate);
155 if ($sanitized !== '' && $sanitized !== null) {
156 $wpdbCollate = $sanitized;
157 }
158 }
159 $replacements['{wpdb_collate}'] = $wpdbCollate;
160
161 $query = $this->f->str_replace(array_keys($replacements), array_values($replacements), $query);
162
163 $fpreg = ABJ_404_Solution_FunctionsPreg::getInstance();
164 $query = $fpreg->regexReplace('[{]wp_abj404_(.*?)[}]',
165 $this->getLowercasePrefix() . "abj404_\\1", $query);
166
167 return $query !== null ? $query : '';
168 }
169
170 /** @return string */
171 public function getLowercasePrefix(): string {
172 global $wpdb;
173 return $this->f->strtolower($wpdb->prefix ?? 'wp_');
174 }
175
176 /**
177 * @param string $tableSuffix
178 * @return string
179 */
180 public function getPrefixedTableName($tableSuffix): string {
181 return $this->getLowercasePrefix() . ltrim($tableSuffix, '_');
182 }
183
184 /**
185 * @param string $tableName
186 * @return string
187 */
188 public function getCreateTableDDL($tableName): string {
189 $query = "show create table " . $tableName;
190 $result = ($this->queryRunner)($query, array('log_errors' => false, 'skip_repair' => true));
191 $rows = $result['rows'];
192 if (!is_array($rows) || empty($rows) || !isset($rows[0]) || !is_array($rows[0])) {
193 return '';
194 }
195 $row1 = array_values($rows[0]);
196 $existingTableSQL = $row1[1] ?? '';
197 return is_scalar($existingTableSQL) ? (string)$existingTableSQL : '';
198 }
199
200 /**
201 * @param array<string, mixed> $options
202 * @return string A comma-separated list of quoted SQL literals, or '' when
203 * the setting is empty. Callers splice it into IN (...).
204 */
205 public function buildPostTypeSqlList(array $options): string {
206 return $this->buildQuotedSqlList($options, 'recognized_post_types');
207 }
208
209 /**
210 * @param array<string, mixed> $options
211 * @return string A comma-separated list of quoted SQL literals, or '' when
212 * the setting is empty. Callers splice it into IN (...).
213 */
214 public function buildCategorySqlList(array $options): string {
215 return $this->buildQuotedSqlList($options, 'recognized_categories');
216 }
217
218 /**
219 * Turn one free-text setting into a list of quoted SQL literals safe to
220 * splice into an IN (...) clause.
221 *
222 * The escaping lives here, at the only point that writes the quotes, and
223 * not at the settings screen or the four call sites. Both of those were
224 * tried by omission and failed: SettingsWordPressPolicy stores these values
225 * through wp_kses_post(), an HTML sanitizer that does nothing whatever to a
226 * single quote, and the call sites hand the fragment straight to
227 * str_replace() against a .sql template. A value carrying a quote therefore
228 * closed its own literal and ran as syntax inside three live queries
229 * against wp_posts and wp_term_taxonomy -- a stored injection whose trigger
230 * is separated from the write by however long it takes someone to ask for
231 * published content.
232 *
233 * Escaped rather than allowlisted on purpose. recognized_post_types would
234 * be safe under a strict [a-z0-9_-] identifier rule, but
235 * recognized_categories is matched against lower(wp_terms.name) as well as
236 * the taxonomy key (getPublishedCategories.sql), and a term name is display
237 * text: "women's shoes" is a legitimate setting. One rule for both builders
238 * is also what keeps them from drifting apart again, which is how one of
239 * them ended up unescaped while three sibling list builders elsewhere in
240 * the plugin were not.
241 *
242 * esc_sql() is the right primitive and not merely the conventional one: it
243 * reaches mysqli_real_escape_string(), which honours the server's SQL mode
244 * and switches to doubled quotes under NO_BACKSLASH_ESCAPES, where a
245 * hand-rolled addslashes() would silently stop escaping. It is also a no-op
246 * for values with nothing to escape, so ordinary post-type keys still
247 * compare byte-identically.
248 *
249 * @param array<string, mixed> $options
250 * @param string $optionName
251 * @return string
252 */
253 private function buildQuotedSqlList(array $options, string $optionName): string {
254 $rawValue = $options[$optionName] ?? '';
255 // explodeNewlineOrComma() already lowercases, trims and drops empties.
256 $values = $this->f->explodeNewlineOrComma(is_string($rawValue) ? $rawValue : '');
257
258 $quoted = array();
259 foreach ($values as $value) {
260 // Sanitize BEFORE escaping, and do it here rather than trusting a
261 // caller. esc_sql() reaches mysqli_real_escape_string(), which
262 // escapes quotes and passes malformed byte sequences through
263 // untouched; on a connection whose charset disagrees with those
264 // bytes a truncated lead byte can absorb the escaping backslash and
265 // hand the next quote to the parser as syntax. Pattern 10
266 // ("invalid UTF-8 reaches SQL") is this project's own recurring
267 // class, and these two settings are free-text textareas, so their
268 // bytes are entirely attacker-chosen.
269 //
270 // It looked safe without this: explodeNewlineOrComma() lowercases,
271 // and with mbstring loaded mb_strtolower() substitutes malformed
272 // bytes as a side effect. MbStringAdapterPreg::strtolower() is
273 // plain strtolower() and does not, so every host without the
274 // mbstring extension -- a configuration this plugin supports on
275 // purpose -- had no sanitization at all here. A security property
276 // resting on an incidental side effect of a lowercasing call is not
277 // a security property.
278 $quoted[] = "'" . esc_sql($this->f->sanitizeInvalidUTF8($value)) . "'";
279 }
280
281 return implode(', ', $quoted);
282 }
283
284 /** @return void */
285 public function setSqlBigSelects(): void {
286 $ignoreErrorsOptions = array('log_errors' => false);
287 ($this->queryRunner)("set session max_join_size = 18446744073709551615",
288 $ignoreErrorsOptions);
289 ($this->queryRunner)("set session sql_big_selects = 1", $ignoreErrorsOptions);
290 }
291 }
292