PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/database/DatabaseTableNameResolver.php

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