Issues (42)

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 (8 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-2025 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
     *
52
     * @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...
53
     *   success: bool,
54
     *   filename: string,
55
     *   filepath: string,
56
     *   encrypted: bool,
57
     *   size_bytes: int,
58
     *   message: string
59
     * }
60
     */
61
    function tpCreateDatabaseBackup(array $SETTINGS, string $encryptionKey = '', array $options = []): array
62
    {
63
        // Ensure required dependencies are loaded
64
        $mainFunctionsPath = __DIR__ . '/main.functions.php';
65
        if ((!function_exists('GenerateCryptKey') || !function_exists('prefixTable')) && is_file($mainFunctionsPath)) {
66
            require_once $mainFunctionsPath;
67
        }
68
        if (function_exists('loadClasses') && !class_exists('DB')) {
69
            loadClasses('DB');
70
        }
71
72
        // Enable maintenance mode for the whole backup operation, then restore previous value at the end.
73
        // This is best-effort: a failure to toggle maintenance must not break the backup itself.
74
        $__tpMaintenanceGuard = new class() {
0 ignored issues
show
The assignment to $__tpMaintenanceGuard is dead and can be removed.
Loading history...
75
            private $prev = null;
76
            private $changed = false;
77
78
            public function __construct()
79
            {
80
                try {
81
                    $row = DB::queryFirstRow(
82
                        'SELECT valeur FROM ' . prefixTable('misc') . ' WHERE intitule=%s AND type=%s',
83
                        'maintenance_mode',
84
                        'admin'
85
                    );
86
87
                    if (is_array($row) && array_key_exists('valeur', $row)) {
88
                        $this->prev = (string) $row['valeur'];
89
                    }
90
91
                    // Only toggle if it was not already enabled
92
                    if ($this->prev !== '1') {
93
                        DB::update(
94
                            prefixTable('misc'),
95
                            array(
96
                                'valeur' => '1',
97
                                'updated_at' => time(),
98
                            ),
99
                            'intitule = %s AND type= %s',
100
                            'maintenance_mode',
101
                            'admin'
102
                        );
103
                        $this->changed = true;
104
                    }
105
                } catch (Throwable $ignored) {
106
                    // ignore
107
                }
108
            }
109
110
            public function __destruct()
111
            {
112
                if ($this->changed !== true) {
113
                    return;
114
                }
115
116
                try {
117
                    DB::update(
118
                        prefixTable('misc'),
119
                        array(
120
                            'valeur' => (string) ($this->prev ?? '0'),
121
                            'updated_at' => time(),
122
                        ),
123
                        'intitule = %s AND type= %s',
124
                        'maintenance_mode',
125
                        'admin'
126
                    );
127
                } catch (Throwable $ignored) {
128
                    // ignore
129
                }
130
            }
131
        };
132
133
        $outputDir = $options['output_dir'] ?? ($SETTINGS['path_to_files_folder'] ?? '');
134
        $prefix = (string)($options['filename_prefix'] ?? '');
135
        $chunkRows = (int)($options['chunk_rows'] ?? 1000);
136
        $flushEvery = (int)($options['flush_every_inserts'] ?? 200);
137
        $includeTables = $options['include_tables'] ?? [];
138
        $excludeTables = $options['exclude_tables'] ?? [];
139
140
        if ($outputDir === '' || !is_dir($outputDir) || !is_writable($outputDir)) {
141
            return [
142
                'success' => false,
143
                'filename' => '',
144
                'filepath' => '',
145
                'encrypted' => false,
146
                'size_bytes' => 0,
147
                'message' => 'Backup folder is not writable or not found: ' . $outputDir,
148
            ];
149
        }
150
151
        // Generate filename
152
        $token = function_exists('GenerateCryptKey')
153
            ? GenerateCryptKey(20, false, true, true, false, true)
154
            : bin2hex(random_bytes(10));
155
156
        $filename = $prefix . time() . '-' . $token . '.sql';
157
        $filepath = rtrim($outputDir, '/') . '/' . $filename;
158
159
        $handle = @fopen($filepath, 'w+');
160
        if ($handle === false) {
161
            return [
162
                'success' => false,
163
                'filename' => $filename,
164
                'filepath' => $filepath,
165
                'encrypted' => false,
166
                'size_bytes' => 0,
167
                'message' => 'Could not create backup file: ' . $filepath,
168
            ];
169
        }
170
171
        $insertCount = 0;
172
173
        try {
174
            // Get all tables
175
            $tables = [];
176
            $result = DB::query('SHOW TABLES');
177
            foreach ($result as $row) {
178
                // SHOW TABLES returns key like 'Tables_in_<DB_NAME>'
179
                foreach ($row as $v) {
180
                    $tables[] = (string) $v;
181
                    break;
182
                }
183
            }
184
185
            // Filter tables if requested
186
            if (!empty($includeTables) && is_array($includeTables)) {
187
                $tables = array_values(array_intersect($tables, $includeTables));
188
            }
189
            if (!empty($excludeTables) && is_array($excludeTables)) {
190
                $tables = array_values(array_diff($tables, $excludeTables));
191
            }
192
193
            foreach ($tables as $tableName) {
194
                // Safety: only allow typical MySQL table identifiers
195
                if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName)) {
196
                    continue;
197
                }
198
199
                // Write drop and creation
200
                fwrite($handle, 'DROP TABLE IF EXISTS `' . $tableName . "`;\n");
201
202
                $row2 = DB::queryFirstRow('SHOW CREATE TABLE `' . $tableName . '`');
203
                if (!is_array($row2) || empty($row2['Create Table'])) {
204
                    // Skip table if structure cannot be fetched
205
                    fwrite($handle, "\n");
206
                    continue;
207
                }
208
209
                fwrite($handle, $row2['Create Table'] . ";\n\n");
210
211
                // Process table data in chunks to reduce memory usage
212
                $offset = 0;
213
                while (true) {
214
                    $rows = DB::query(
215
                        'SELECT * FROM `' . $tableName . '` LIMIT %i OFFSET %i',
216
                        $chunkRows,
217
                        $offset
218
                    );
219
220
                    if (empty($rows)) {
221
                        break;
222
                    }
223
224
                    foreach ($rows as $record) {
225
                        $values = [];
226
                        foreach ($record as $value) {
227
                            if ($value === null) {
228
                                $values[] = 'NULL';
229
                                continue;
230
                            }
231
232
                            // Force scalar/string
233
                            if (is_bool($value)) {
234
                                $value = $value ? '1' : '0';
235
                            } elseif (is_numeric($value)) {
236
                                // keep numeric as string but quoted (safe & consistent)
237
                                $value = (string) $value;
238
                            } else {
239
                                $value = (string) $value;
240
                            }
241
242
                            // Escape and keep newlines
243
                            $value = addslashes(preg_replace("/\n/", '\\n', $value));
244
                            $values[] = '"' . $value . '"';
245
                        }
246
247
                        $insertQuery = 'INSERT INTO `' . $tableName . '` VALUES(' . implode(',', $values) . ");\n";
248
                        fwrite($handle, $insertQuery);
249
250
                        $insertCount++;
251
                        if ($flushEvery > 0 && ($insertCount % $flushEvery) === 0) {
252
                            fflush($handle);
253
                        }
254
                    }
255
256
                    $offset += $chunkRows;
257
                    fflush($handle);
258
                }
259
260
                fwrite($handle, "\n\n");
261
                fflush($handle);
262
            }
263
        } catch (Throwable $e) {
264
            fclose($handle);
265
            @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

265
            /** @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...
266
267
            return [
268
                'success' => false,
269
                'filename' => $filename,
270
                'filepath' => $filepath,
271
                'encrypted' => false,
272
                'size_bytes' => 0,
273
                'message' => 'Backup failed: ' . $e->getMessage(),
274
            ];
275
        }
276
277
        fclose($handle);
278
279
        // Encrypt the file if key provided
280
        $encrypted = false;
281
        if ($encryptionKey !== '') {
282
            $tmpPath = rtrim($outputDir, '/') . '/defuse_temp_' . $filename;
283
284
            if (!function_exists('prepareFileWithDefuse')) {
285
                @unlink($filepath);
286
                return [
287
                    'success' => false,
288
                    'filename' => $filename,
289
                    'filepath' => $filepath,
290
                    'encrypted' => false,
291
                    'size_bytes' => 0,
292
                    'message' => 'Missing prepareFileWithDefuse() dependency (main.functions.php not loaded?)',
293
                ];
294
            }
295
296
            $ret = prepareFileWithDefuse('encrypt', $filepath, $tmpPath, $encryptionKey);
297
298
            // prepareFileWithDefuse usually returns true on success, otherwise message/false
299
            if ($ret !== true) {
300
                @unlink($filepath);
301
                @unlink($tmpPath);
302
                return [
303
                    'success' => false,
304
                    'filename' => $filename,
305
                    'filepath' => $filepath,
306
                    'encrypted' => false,
307
                    'size_bytes' => 0,
308
                    'message' => 'Encryption failed: ' . (is_string($ret) ? $ret : 'unknown error'),
0 ignored issues
show
The condition is_string($ret) is always true.
Loading history...
309
                ];
310
            }
311
312
            // Replace original with encrypted version
313
            @unlink($filepath);
314
            if (!@rename($tmpPath, $filepath)) {
315
                @unlink($tmpPath);
316
                return [
317
                    'success' => false,
318
                    'filename' => $filename,
319
                    'filepath' => $filepath,
320
                    'encrypted' => false,
321
                    'size_bytes' => 0,
322
                    'message' => 'Encryption succeeded but could not finalize file (rename failed)',
323
                ];
324
            }
325
326
            $encrypted = true;
327
        }
328
329
        $size = (int) (@filesize($filepath) ?: 0);
330
331
        return [
332
            'success' => true,
333
            'filename' => $filename,
334
            'filepath' => $filepath,
335
            'encrypted' => $encrypted,
336
            'size_bytes' => $size,
337
            'message' => '',
338
        ];
339
    }
340
}
341
342
343
// -----------------------------------------------------------------------------
344
// Helpers for restore logic (used by backups.queries.php)
345
// -----------------------------------------------------------------------------
346
347
if (function_exists('tpSafeUtf8String') === false) {
348
    /**
349
     * Ensure the returned string is valid UTF-8 and JSON-safe.
350
     *
351
     * Some crypto libraries can return messages containing non-UTF8 bytes.
352
     * Those would break json_encode() / prepareExchangedData().
353
     */
354
    function tpSafeUtf8String($value): string
355
    {
356
        if ($value === null) {
357
            return '';
358
        }
359
        if (is_bool($value)) {
360
            return $value ? '1' : '0';
361
        }
362
        if (is_scalar($value) === false) {
363
            $value = print_r($value, true);
364
        }
365
366
        $str = (string) $value;
367
368
        $isUtf8 = false;
369
        if (function_exists('mb_check_encoding')) {
370
            $isUtf8 = mb_check_encoding($str, 'UTF-8');
371
        } else {
372
            $isUtf8 = (@preg_match('//u', $str) === 1);
373
        }
374
375
        if ($isUtf8 === false) {
376
            // ASCII safe fallback
377
            return '[hex]' . bin2hex($str);
378
        }
379
380
        // Strip ASCII control chars
381
        $str = preg_replace("/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/", '', $str) ?? $str;
382
        return $str;
383
    }
384
}
385
if (function_exists('tpPrepareFileWithDefuseNormalized') === false) {
386
    /**
387
     * Wrapper around prepareFileWithDefuse() that normalizes return values across TeamPass versions.
388
     *
389
     * @return array{success: bool, message: string}
390
     */
391
    function tpPrepareFileWithDefuseNormalized(
392
        string $mode,
393
        string $sourceFile,
394
        string $destFile,
395
        string $encryptionKey,
396
        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

396
        /** @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...
397
    ): array {
398
        if (function_exists('prepareFileWithDefuse') === false) {
399
            return ['success' => false, 'message' => 'prepareFileWithDefuse() is not available'];
400
        }
401
402
        try {
403
            $ret = prepareFileWithDefuse($mode, $sourceFile, $destFile, $encryptionKey);
404
405
            if ($ret === true) {
406
                return ['success' => true, 'message' => ''];
407
            }
408
409
            if (is_array($ret)) {
0 ignored issues
show
The condition is_array($ret) is always false.
Loading history...
410
                $hasError = !empty($ret['error']);
411
                $msg = tpSafeUtf8String((string)($ret['message'] ?? $ret['details'] ?? $ret['error'] ?? ''));
412
                return ['success' => !$hasError, 'message' => $msg];
413
            }
414
415
            if (is_string($ret)) {
0 ignored issues
show
The condition is_string($ret) is always true.
Loading history...
416
                return ['success' => false, 'message' => tpSafeUtf8String($ret)];
417
            }
418
419
            return ['success' => false, 'message' => 'Unknown error'];
420
        } catch (\Throwable $e) {
421
            return ['success' => false, 'message' => tpSafeUtf8String($e->getMessage())];
422
        }
423
    }
424
}
425
426
if (function_exists('tpDefuseDecryptWithCandidates') === false) {
427
    /**
428
     * Try to decrypt a file using Defuse with multiple candidate keys.
429
     *
430
     * @param array<int,string> $candidateKeys
431
     * @return array{success: bool, message: string, key_used?: string}
432
     */
433
    function tpDefuseDecryptWithCandidates(
434
        string $encryptedFile,
435
        string $decryptedFile,
436
        array $candidateKeys,
437
        array $SETTINGS = []
438
    ): array {
439
        $lastMsg = '';
440
        foreach ($candidateKeys as $k) {
441
            $k = (string)$k;
442
            if ($k === '') {
443
                continue;
444
            }
445
446
            // Ensure we start from a clean slate.
447
            if (is_file($decryptedFile)) {
448
                @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

448
                /** @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...
449
            }
450
451
            $r = tpPrepareFileWithDefuseNormalized('decrypt', $encryptedFile, $decryptedFile, $k, $SETTINGS);
452
            if (!empty($r['success'])) {
453
                return ['success' => true, 'message' => '', 'key_used' => $k];
454
            }
455
            $lastMsg = tpSafeUtf8String((string)($r['message'] ?? ''));
456
        }
457
458
        return ['success' => false, 'message' => ($lastMsg !== '' ? $lastMsg : 'Unable to decrypt')];
459
    }
460
}
461