Passed
Push — master ( 378303...213640 )
by Nils
06:34
created

tpSafeUtf8String()   B

Complexity

Conditions 7
Paths 11

Size

Total Lines 29
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 16
c 1
b 0
f 0
nc 11
nop 1
dl 0
loc 29
rs 8.8333
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Teampass - Backup helper functions
7
 * This file provides reusable functions for database backup creation
8
 * (manual UI + scheduled/background tasks).
9
 */
10
11
if (!function_exists('tpCreateDatabaseBackup')) {
12
    /**
13
     * Create a Teampass database backup file (optionally encrypted) in files folder.
14
     *
15
     * @param array  $SETTINGS      Teampass settings array (must include path_to_files_folder)
16
     * @param string $encryptionKey Encryption key (Defuse). If empty => no encryption
17
     * @param array  $options       Optional:
18
     *                              - output_dir (string) default: $SETTINGS['path_to_files_folder']
19
     *                              - filename_prefix (string) default: '' (ex: 'scheduled-')
20
     *                              - chunk_rows (int) default: 1000
21
     *                              - flush_every_inserts (int) default: 200
22
     *                              - include_tables (array<string>) default: [] (empty => all)
23
     *                              - exclude_tables (array<string>) default: [] (empty => none)
24
     *
25
     * @return array{
0 ignored issues
show
Documentation Bug introduced by
The doc comment array{ at position 2 could not be parsed: the token is null at position 2.
Loading history...
26
     *   success: bool,
27
     *   filename: string,
28
     *   filepath: string,
29
     *   encrypted: bool,
30
     *   size_bytes: int,
31
     *   message: string
32
     * }
33
     */
34
    function tpCreateDatabaseBackup(array $SETTINGS, string $encryptionKey = '', array $options = []): array
35
    {
36
        // Ensure required dependencies are loaded
37
        $mainFunctionsPath = __DIR__ . '/main.functions.php';
38
        if ((!function_exists('GenerateCryptKey') || !function_exists('prefixTable')) && is_file($mainFunctionsPath)) {
39
            require_once $mainFunctionsPath;
40
        }
41
        if (function_exists('loadClasses') && !class_exists('DB')) {
42
            loadClasses('DB');
43
        }
44
45
        // Enable maintenance mode for the whole backup operation, then restore previous value at the end.
46
        // This is best-effort: a failure to toggle maintenance must not break the backup itself.
47
        $__tpMaintenanceGuard = new class() {
0 ignored issues
show
Unused Code introduced by
The assignment to $__tpMaintenanceGuard is dead and can be removed.
Loading history...
48
            private $prev = null;
49
            private $changed = false;
50
51
            public function __construct()
52
            {
53
                try {
54
                    $row = DB::queryFirstRow(
55
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE intitule=%s AND type=%s',
56
                        'maintenance_mode',
57
                        'admin'
58
                    );
59
60
                    if (is_array($row) && array_key_exists('valeur', $row)) {
61
                        $this->prev = (string) $row['valeur'];
62
                    }
63
64
                    // Only toggle if it was not already enabled
65
                    if ($this->prev !== '1') {
66
                        DB::update(
67
                            prefixTable('misc'),
68
                            array(
69
                                'valeur' => '1',
70
                                'updated_at' => time(),
71
                            ),
72
                            'intitule = %s AND type= %s',
73
                            'maintenance_mode',
74
                            'admin'
75
                        );
76
                        $this->changed = true;
77
                    }
78
                } catch (Throwable $ignored) {
79
                    // ignore
80
                }
81
            }
82
83
            public function __destruct()
84
            {
85
                if ($this->changed !== true) {
86
                    return;
87
                }
88
89
                try {
90
                    DB::update(
91
                        prefixTable('misc'),
92
                        array(
93
                            'valeur' => (string) ($this->prev ?? '0'),
94
                            'updated_at' => time(),
95
                        ),
96
                        'intitule = %s AND type= %s',
97
                        'maintenance_mode',
98
                        'admin'
99
                    );
100
                } catch (Throwable $ignored) {
101
                    // ignore
102
                }
103
            }
104
        };
105
106
        $outputDir = $options['output_dir'] ?? ($SETTINGS['path_to_files_folder'] ?? '');
107
        $prefix = (string)($options['filename_prefix'] ?? '');
108
        $chunkRows = (int)($options['chunk_rows'] ?? 1000);
109
        $flushEvery = (int)($options['flush_every_inserts'] ?? 200);
110
        $includeTables = $options['include_tables'] ?? [];
111
        $excludeTables = $options['exclude_tables'] ?? [];
112
113
        if ($outputDir === '' || !is_dir($outputDir) || !is_writable($outputDir)) {
114
            return [
115
                'success' => false,
116
                'filename' => '',
117
                'filepath' => '',
118
                'encrypted' => false,
119
                'size_bytes' => 0,
120
                'message' => 'Backup folder is not writable or not found: ' . $outputDir,
121
            ];
122
        }
123
124
        // Generate filename
125
        $token = function_exists('GenerateCryptKey')
126
            ? GenerateCryptKey(20, false, true, true, false, true)
127
            : bin2hex(random_bytes(10));
128
129
        $filename = $prefix . time() . '-' . $token . '.sql';
130
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
131
132
        $handle = @fopen($filepath, 'w+');
133
        if ($handle === false) {
134
            return [
135
                'success' => false,
136
                'filename' => $filename,
137
                'filepath' => $filepath,
138
                'encrypted' => false,
139
                'size_bytes' => 0,
140
                'message' => 'Could not create backup file: ' . $filepath,
141
            ];
142
        }
143
144
        $insertCount = 0;
145
146
        try {
147
            // Get all tables
148
            $tables = [];
149
            $result = DB::query('SHOW TABLES');
150
            foreach ($result as $row) {
151
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
152
                foreach ($row as $v) {
153
                    $tables[] = (string) $v;
154
                    break;
155
                }
156
            }
157
158
            // Filter tables if requested
159
            if (!empty($includeTables) && is_array($includeTables)) {
160
                $tables = array_values(array_intersect($tables, $includeTables));
161
            }
162
            if (!empty($excludeTables) && is_array($excludeTables)) {
163
                $tables = array_values(array_diff($tables, $excludeTables));
164
            }
165
166
            foreach ($tables as $tableName) {
167
                // Safety: only allow typical MySQL table identifiers
168
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
169
                    continue;
170
                }
171
172
                // Write drop and creation
173
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
174
175
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
176
                if (!is_array($row2) || empty($row2['Create Table'])) {
177
                    // Skip table if structure cannot be fetched
178
                    fwrite($handle, "\n");
179
                    continue;
180
                }
181
182
                fwrite($handle, $row2['Create Table'] . ";\n\n");
183
184
                // Process table data in chunks to reduce memory usage
185
                $offset = 0;
186
                while (true) {
187
                    $rows = DB::query(
188
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
189
                        $chunkRows,
190
                        $offset
191
                    );
192
193
                    if (empty($rows)) {
194
                        break;
195
                    }
196
197
                    foreach ($rows as $record) {
198
                        $values = [];
199
                        foreach ($record as $value) {
200
                            if ($value === null) {
201
                                $values[] = 'NULL';
202
                                continue;
203
                            }
204
205
                            // Force scalar/string
206
                            if (is_bool($value)) {
207
                                $value = $value ? '1' : '0';
208
                            } elseif (is_numeric($value)) {
209
                                // keep numeric as string but quoted (safe & consistent)
210
                                $value = (string) $value;
211
                            } else {
212
                                $value = (string) $value;
213
                            }
214
215
                            // Escape and keep newlines
216
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
217
                            $values[] = '"' . $value . '"';
218
                        }
219
220
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
221
                        fwrite($handle, $insertQuery);
222
223
                        $insertCount++;
224
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
225
                            fflush($handle);
226
                        }
227
                    }
228
229
                    $offset += $chunkRows;
230
                    fflush($handle);
231
                }
232
233
                fwrite($handle, "\n\n");
234
                fflush($handle);
235
            }
236
        } catch (Throwable $e) {
237
            fclose($handle);
238
            @unlink($filepath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

238
            /** @scrutinizer ignore-unhandled */ @unlink($filepath);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
239
240
            return [
241
                'success' => false,
242
                'filename' => $filename,
243
                'filepath' => $filepath,
244
                'encrypted' => false,
245
                'size_bytes' => 0,
246
                'message' => 'Backup failed: ' . $e->getMessage(),
247
            ];
248
        }
249
250
        fclose($handle);
251
252
        // Encrypt the file if key provided
253
        $encrypted = false;
254
        if ($encryptionKey !== '') {
255
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
256
257
            if (!function_exists('prepareFileWithDefuse')) {
258
                @unlink($filepath);
259
                return [
260
                    'success' => false,
261
                    'filename' => $filename,
262
                    'filepath' => $filepath,
263
                    'encrypted' => false,
264
                    'size_bytes' => 0,
265
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
266
                ];
267
            }
268
269
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
270
271
            // prepareFileWithDefuse usually returns true on success, otherwise message/false
272
            if ($ret !== true) {
273
                @unlink($filepath);
274
                @unlink($tmpPath);
275
                return [
276
                    'success' => false,
277
                    'filename' => $filename,
278
                    'filepath' => $filepath,
279
                    'encrypted' => false,
280
                    'size_bytes' => 0,
281
                    'message' => 'Encryption failed: ' . (is_string($ret) ? $ret : 'unknown error'),
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
282
                ];
283
            }
284
285
            // Replace original with encrypted version
286
            @unlink($filepath);
287
            if (!@rename($tmpPath, $filepath)) {
288
                @unlink($tmpPath);
289
                return [
290
                    'success' => false,
291
                    'filename' => $filename,
292
                    'filepath' => $filepath,
293
                    'encrypted' => false,
294
                    'size_bytes' => 0,
295
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
296
                ];
297
            }
298
299
            $encrypted = true;
300
        }
301
302
        $size = (int) (@filesize($filepath) ?: 0);
303
304
        return [
305
            'success' => true,
306
            'filename' => $filename,
307
            'filepath' => $filepath,
308
            'encrypted' => $encrypted,
309
            'size_bytes' => $size,
310
            'message' => '',
311
        ];
312
    }
313
}
314
315
316
// -----------------------------------------------------------------------------
317
// Helpers for restore logic (used by backups.queries.php)
318
// -----------------------------------------------------------------------------
319
320
if (function_exists('tpSafeUtf8String') === false) {
321
    /**
322
     * Ensure the returned string is valid UTF-8 and JSON-safe.
323
     *
324
     * Some crypto libraries can return messages containing non-UTF8 bytes.
325
     * Those would break json_encode() / prepareExchangedData().
326
     */
327
    function tpSafeUtf8String($value): string
328
    {
329
        if ($value === null) {
330
            return '';
331
        }
332
        if (is_bool($value)) {
333
            return $value ? '1' : '0';
334
        }
335
        if (is_scalar($value) === false) {
336
            $value = print_r($value, true);
337
        }
338
339
        $str = (string) $value;
340
341
        $isUtf8 = false;
342
        if (function_exists('mb_check_encoding')) {
343
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
344
        } else {
345
            $isUtf8 = (@preg_match('//u', $str) === 1);
346
        }
347
348
        if ($isUtf8 === false) {
349
            // ASCII safe fallback
350
            return '[hex]' . bin2hex($str);
351
        }
352
353
        // Strip ASCII control chars
354
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
355
        return $str;
356
    }
357
}
358
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
359
    /**
360
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
361
     *
362
     * @return array{success: bool, message: string}
363
     */
364
    function tpPrepareFileWithDefuseNormalized(
365
        string $mode,
366
        string $sourceFile,
367
        string $destFile,
368
        string $encryptionKey,
369
        array $SETTINGS = []
370
    ): array {
371
        if (function_exists('prepareFileWithDefuse') === false) {
372
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
373
        }
374
375
        try {
376
            // Some versions accept $SETTINGS as 5th parameter, others don't.
377
            try {
378
                $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey, $SETTINGS);
0 ignored issues
show
Unused Code introduced by
The call to prepareFileWithDefuse() has too many arguments starting with $SETTINGS. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

378
                $ret = /** @scrutinizer ignore-call */ prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey, $SETTINGS);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
379
            } catch (\ArgumentCountError $e) {
380
                $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
381
            }
382
383
            if ($ret === true) {
384
                return ['success' => true, 'message' => ''];
385
            }
386
387
            if (is_array($ret)) {
0 ignored issues
show
introduced by
The condition is_array($ret) is always false.
Loading history...
388
                $hasError = !empty($ret['error']);
389
                $msg = tpSafeUtf8String((string)($ret['message'] ?? $ret['details'] ?? $ret['error'] ?? ''));
390
                return ['success' => !$hasError, 'message' => $msg];
391
            }
392
393
            if (is_string($ret)) {
0 ignored issues
show
introduced by
The condition is_string($ret) is always true.
Loading history...
394
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
395
            }
396
397
            return ['success' => false, 'message' => 'Unknown error'];
398
        } catch (\Throwable $e) {
399
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
400
        }
401
    }
402
}
403
404
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
405
    /**
406
     * Try to decrypt a file using Defuse with multiple candidate keys.
407
     *
408
     * @param array<int,string> $candidateKeys
409
     * @return array{success: bool, message: string, key_used?: string}
410
     */
411
    function tpDefuseDecryptWithCandidates(
412
        string $encryptedFile,
413
        string $decryptedFile,
414
        array $candidateKeys,
415
        array $SETTINGS = []
416
    ): array {
417
        $lastMsg = '';
418
        foreach ($candidateKeys as $k) {
419
            $k = (string)$k;
420
            if ($k === '') {
421
                continue;
422
            }
423
424
            // Ensure we start from a clean slate.
425
            if (is_file($decryptedFile)) {
426
                @unlink($decryptedFile);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

426
                /** @scrutinizer ignore-unhandled */ @unlink($decryptedFile);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
427
            }
428
429
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k, $SETTINGS);
430
            if (!empty($r['success'])) {
431
                return ['success' => true, 'message' => '', 'key_used' => $k];
432
            }
433
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
434
        }
435
436
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
437
    }
438
}
439