Issues (40)

Security Analysis    no vulnerabilities found

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  Header Injection
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

sources/backup.functions.php (4 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Teampass - a collaborative passwords manager.
7
 * ---
8
 * This file is part of the TeamPass project.
9
 * 
10
 * TeamPass is free software: you can redistribute it and/or modify it
11
 * under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, version 3 of the License.
13
 * 
14
 * TeamPass is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU General Public License for more details.
18
 * 
19
 * You should have received a copy of the GNU General Public License
20
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
21
 * 
22
 * Certain components of this file may be under different licenses. For
23
 * details, see the `licenses` directory or individual file headers.
24
 * ---
25
 * @file      backups.functions.php
26
 * @author    Nils Laumaillé ([email protected])
27
 * @copyright 2009-2026 Teampass.net
28
 * @license   GPL-3.0
29
 * @see       https://www.teampass.net
30
 */
31
32
/**
33
 * Teampass - Backup helper functions
34
 * This file provides reusable functions for database backup creation
35
 * (manual UI + scheduled/background tasks).
36
 */
37
38
if (!function_exists('tpCreateDatabaseBackup')) {
39
    /**
40
     * Create a Teampass database backup file (optionally encrypted) in files folder.
41
     *
42
     * @param array  $SETTINGS      Teampass settings array (must include path_to_files_folder)
43
     * @param string $encryptionKey Encryption key (Defuse). If empty => no encryption
44
     * @param array  $options       Optional:
45
     *                              - output_dir (string) default: $SETTINGS['path_to_files_folder']
46
     *                              - filename_prefix (string) default: '' (ex: 'scheduled-')
47
     *                              - chunk_rows (int) default: 1000
48
     *                              - flush_every_inserts (int) default: 200
49
     *                              - include_tables (array<string>) default: [] (empty => all)
50
     *                              - exclude_tables (array<string>) default: [] (empty => none)
51
     * @return array
52
     * @psalm-return array{success: bool, filename: string, filepath: string, encrypted: bool, size_bytes: int, message: string}
53
     */
54
    function tpCreateDatabaseBackup(array $SETTINGS, string $encryptionKey = '', array $options = []): array
55
    {
56
        // Ensure required dependencies are loaded
57
        $mainFunctionsPath = __DIR__ . '/main.functions.php';
58
        if ((!function_exists('GenerateCryptKey') || !function_exists('prefixTable')) && is_file($mainFunctionsPath)) {
59
            require_once $mainFunctionsPath;
60
        }
61
        if (function_exists('loadClasses') && !class_exists('DB')) {
62
            loadClasses('DB');
63
        }
64
65
        // Enable maintenance mode for the whole backup operation, then restore previous value at the end.
66
        // This is best-effort: a failure to toggle maintenance must not break the backup itself.
67
        /** @scrutinizer ignore-unused */
68
        $__tpMaintenanceGuard = new class() {
0 ignored issues
show
The assignment to $__tpMaintenanceGuard is dead and can be removed.
Loading history...
69
            private $prev = null;
70
            private $changed = false;
71
72
            public function __construct()
73
            {
74
                try {
75
                    $row = DB::queryFirstRow(
76
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE intitule=%s AND type=%s',
77
                        'maintenance_mode',
78
                        'admin'
79
                    );
80
81
                    if (is_array($row) && array_key_exists('valeur', $row)) {
82
                        $this->prev = (string) $row['valeur'];
83
                    }
84
85
                    // Only toggle if it was not already enabled
86
                    if ($this->prev !== '1') {
87
                        DB::update(
88
                            prefixTable('misc'),
89
                            array(
90
                                'valeur' => '1',
91
                                'updated_at' => time(),
92
                            ),
93
                            'intitule = %s AND type= %s',
94
                            'maintenance_mode',
95
                            'admin'
96
                        );
97
                        $this->changed = true;
98
                    }
99
                } catch (Throwable $ignored) {
100
                    // ignore
101
                }
102
            }
103
104
            public function __destruct()
105
            {
106
                if ($this->changed !== true) {
107
                    return;
108
                }
109
110
                try {
111
                    DB::update(
112
                        prefixTable('misc'),
113
                        array(
114
                            'valeur' => (string) ($this->prev ?? '0'),
115
                            'updated_at' => time(),
116
                        ),
117
                        'intitule = %s AND type= %s',
118
                        'maintenance_mode',
119
                        'admin'
120
                    );
121
                } catch (Throwable $ignored) {
122
                    // ignore
123
                }
124
            }
125
        };
126
127
        $outputDir = $options['output_dir'] ?? ($SETTINGS['path_to_files_folder'] ?? '');
128
        $prefix = (string)($options['filename_prefix'] ?? '');
129
        $chunkRows = (int)($options['chunk_rows'] ?? 1000);
130
        $flushEvery = (int)($options['flush_every_inserts'] ?? 200);
131
        $includeTables = $options['include_tables'] ?? [];
132
        $excludeTables = $options['exclude_tables'] ?? [];
133
134
        if ($outputDir === '' || !is_dir($outputDir) || !is_writable($outputDir)) {
135
            return [
136
                'success' => false,
137
                'filename' => '',
138
                'filepath' => '',
139
                'encrypted' => false,
140
                'size_bytes' => 0,
141
                'message' => 'Backup folder is not writable or not found: ' . $outputDir,
142
            ];
143
        }
144
145
        // Generate filename
146
        $token = function_exists('GenerateCryptKey')
147
            ? GenerateCryptKey(20, false, true, true, false, true)
148
            : bin2hex(random_bytes(10));
149
150
        $filename = $prefix . time() . '-' . $token . '.sql';
151
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
152
153
        $handle = @fopen($filepath, 'w+');
154
        if ($handle === false) {
155
            return [
156
                'success' => false,
157
                'filename' => $filename,
158
                'filepath' => $filepath,
159
                'encrypted' => false,
160
                'size_bytes' => 0,
161
                'message' => 'Could not create backup file: ' . $filepath,
162
            ];
163
        }
164
165
        $insertCount = 0;
166
167
        try {
168
            // Get all tables
169
            $tables = [];
170
            $result = DB::query('SHOW TABLES');
171
            foreach ($result as $row) {
172
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
173
                foreach ($row as $v) {
174
                    $tables[] = (string) $v;
175
                    break;
176
                }
177
            }
178
179
            // Filter tables if requested
180
            if (!empty($includeTables) && is_array($includeTables)) {
181
                $tables = array_values(array_intersect($tables, $includeTables));
182
            }
183
            if (!empty($excludeTables) && is_array($excludeTables)) {
184
                $tables = array_values(array_diff($tables, $excludeTables));
185
            }
186
187
            foreach ($tables as $tableName) {
188
                // Safety: only allow typical MySQL table identifiers
189
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
190
                    continue;
191
                }
192
193
                // Write drop and creation
194
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
195
196
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
197
                if (!is_array($row2) || empty($row2['Create Table'])) {
198
                    // Skip table if structure cannot be fetched
199
                    fwrite($handle, "\n");
200
                    continue;
201
                }
202
203
                fwrite($handle, $row2['Create Table'] . ";\n\n");
204
205
                // Process table data in chunks to reduce memory usage
206
                $offset = 0;
207
                while (true) {
208
                    $rows = DB::query(
209
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
210
                        $chunkRows,
211
                        $offset
212
                    );
213
214
                    if (empty($rows)) {
215
                        break;
216
                    }
217
218
                    foreach ($rows as $record) {
219
                        $values = [];
220
                        foreach ($record as $value) {
221
                            if ($value === null) {
222
                                $values[] = 'NULL';
223
                                continue;
224
                            }
225
226
                            // Force scalar/string
227
                            if (is_bool($value)) {
228
                                $value = $value ? '1' : '0';
229
                            } elseif (is_numeric($value)) {
230
                                // keep numeric as string but quoted (safe & consistent)
231
                                $value = (string) $value;
232
                            } else {
233
                                $value = (string) $value;
234
                            }
235
236
                            // Escape and keep newlines
237
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
238
                            $values[] = '"' . $value . '"';
239
                        }
240
241
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
242
                        fwrite($handle, $insertQuery);
243
244
                        $insertCount++;
245
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
246
                            fflush($handle);
247
                        }
248
                    }
249
250
                    $offset += $chunkRows;
251
                    fflush($handle);
252
                }
253
254
                fwrite($handle, "\n\n");
255
                fflush($handle);
256
            }
257
        } catch (Throwable $e) {
258
            if (is_resource($handle)) {
259
                fclose($handle);
260
            }
261
262
            $errorMessage = 'Backup failed: ' . $e->getMessage();
263
264
            // Suppression sécurisée sans @
265
            if (file_exists($filepath)) {
266
                $deleted = unlink($filepath);
267
                if ($deleted === false) {
268
                    $errorMessage .= ' (Note: Temporary backup file could not be deleted from disk)';
269
                }
270
            }
271
272
            return [
273
                'success' => false,
274
                'filename' => $filename,
275
                'filepath' => $filepath,
276
                'encrypted' => false,
277
                'size_bytes' => 0,
278
                'message' => $errorMessage,
279
            ];
280
        }
281
282
        fclose($handle);
283
284
        // Encrypt the file if key provided
285
        $encrypted = false;
286
        if ($encryptionKey !== '') {
287
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
288
289
            if (!function_exists('prepareFileWithDefuse')) {
290
                if (file_exists($filepath)) {
291
                    unlink($filepath);
292
                }
293
                return [
294
                    'success' => false,
295
                    'filename' => $filename,
296
                    'filepath' => $filepath,
297
                    'encrypted' => false,
298
                    'size_bytes' => 0,
299
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
300
                ];
301
            }
302
303
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
304
305
            if ($ret !== true) {
306
                if (file_exists($filepath)) {
307
                    unlink($filepath);
308
                }
309
                if (file_exists($tmpPath)) {
310
                    unlink($tmpPath);
311
                }
312
                return [
313
                    'success' => false,
314
                    'filename' => $filename,
315
                    'filepath' => $filepath,
316
                    'encrypted' => false,
317
                    'size_bytes' => 0,
318
                    'message' => 'Encryption failed: ' . (is_string($ret) ? $ret : 'unknown error'),
0 ignored issues
show
The condition is_string($ret) is always true.
Loading history...
319
                ];
320
            }
321
322
            // Replace original with encrypted version
323
            if (file_exists($filepath)) {
324
                unlink($filepath);
325
            }
326
            
327
            // On vérifie le succès de rename() sans @
328
            if (is_file($tmpPath) && !rename($tmpPath, $filepath)) {
329
                if (file_exists($tmpPath)) {
330
                    unlink($tmpPath);
331
                }
332
                return [
333
                    'success' => false,
334
                    'filename' => $filename,
335
                    'filepath' => $filepath,
336
                    'encrypted' => false,
337
                    'size_bytes' => 0,
338
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
339
                ];
340
            }
341
342
            $encrypted = true;
343
        }
344
345
        // Gestion de filesize sans @
346
        $size = 0;
347
        if (is_file($filepath)) {
348
            $size = filesize($filepath);
349
            if ($size === false) {
350
                $size = 0;
351
            }
352
        }
353
354
        return [
355
            'success' => true,
356
            'filename' => $filename,
357
            'filepath' => $filepath,
358
            'encrypted' => $encrypted,
359
            'size_bytes' => (int) $size,
360
            'message' => '',
361
        ];
362
    }
363
}
364
365
366
// -----------------------------------------------------------------------------
367
// Helpers for restore logic (used by backups.queries.php)
368
// -----------------------------------------------------------------------------
369
370
if (function_exists('tpSafeUtf8String') === false) {
371
    /**
372
     * Ensure the returned string is valid UTF-8 and JSON-safe.
373
     *
374
     * Some crypto libraries can return messages containing non-UTF8 bytes.
375
     * Those would break json_encode() / prepareExchangedData().
376
     */
377
    function tpSafeUtf8String($value): string
378
    {
379
        if ($value === null) {
380
            return '';
381
        }
382
        if (is_bool($value)) {
383
            return $value ? '1' : '0';
384
        }
385
        if (is_scalar($value) === false) {
386
            $value = print_r($value, true);
387
        }
388
389
        $str = (string) $value;
390
391
        $isUtf8 = false;
392
        if (function_exists('mb_check_encoding')) {
393
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
394
        } else {
395
            $isUtf8 = (@preg_match('//u', $str) === 1);
396
        }
397
398
        if ($isUtf8 === false) {
399
            // ASCII safe fallback
400
            return '[hex]' . bin2hex($str);
401
        }
402
403
        // Strip ASCII control chars
404
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
405
        return $str;
406
    }
407
}
408
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
409
    /**
410
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
411
     *
412
     * @return array{success: bool, message: string}
413
     */
414
    function tpPrepareFileWithDefuseNormalized(
415
        string $mode,
416
        string $sourceFile,
417
        string $destFile,
418
        string $encryptionKey
419
    ): array {
420
        if (function_exists('prepareFileWithDefuse') === false) {
421
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
422
        }
423
424
        try {
425
            $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
426
427
            if ($ret === true) {
428
                return ['success' => true, 'message' => ''];
429
            }
430
431
            if (is_string($ret)) {
0 ignored issues
show
The condition is_string($ret) is always true.
Loading history...
432
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
433
            }
434
435
            return ['success' => false, 'message' => 'Unknown error'];
436
        } catch (\Throwable $e) {
437
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
438
        }
439
    }
440
}
441
442
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
443
    /**
444
     * Try to decrypt a file using Defuse with multiple candidate keys.
445
     *
446
     * @param array<int,string> $candidateKeys
447
     * @return array{success: bool, message: string, key_used?: string}
448
     */
449
    function tpDefuseDecryptWithCandidates(
450
        string $encryptedFile,
451
        string $decryptedFile,
452
        array $candidateKeys,
453
        array $SETTINGS = []
0 ignored issues
show
The parameter $SETTINGS is not used and could be removed. ( Ignorable by Annotation )

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

453
        /** @scrutinizer ignore-unused */ array $SETTINGS = []

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
454
    ): array {
455
        $lastMsg = '';
456
        foreach ($candidateKeys as $k) {
457
            $k = (string)$k;
458
            if ($k === '') {
459
                continue;
460
            }
461
462
            // Ensure we start from a clean slate.
463
            if (is_file($decryptedFile) && !unlink($decryptedFile)) {
464
                // Nothing to do, try next key
465
            }
466
467
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k);
468
            if (!empty($r['success'])) {
469
                return ['success' => true, 'message' => '', 'key_used' => $k];
470
            }
471
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
472
        }
473
474
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
475
    }
476
}
477