Skip to content

Commit cd19836

Browse files
Alexey Abelalexeyabel
authored andcommitted
Implement reading db password from a file
This is useful for systems where configuration files might be(come) public or world readable and thus secrets must be kept separately in files. Examples are NixOS or Docker (secrets).
1 parent 8be3bd1 commit cd19836

File tree

3 files changed

+151
-19
lines changed

3 files changed

+151
-19
lines changed

README.md

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,32 @@ This app has no user interface. All configuration is done via Nextcloud's system
4949

5050

5151
There are three types of configuration parameters:
52+
5253
### 1. Database
53-
that *User Backend SQL Raw* will connect to
54-
- *db_type* is optional and defaults to `postgresql`. The only other valid non-empty value is `mariadb`, which can be used for MySQL, too.
55-
- *db_host* is optional and defaults to `localhost`
56-
- *db_port* is optional and defaults to `5432`
57-
- *db_name*, *db_user* and *db_password* are mandatory
58-
- *mariadb_charset* sets the charset for mariadb connections, is optional and defaults to `utf8mb4`
54+
55+
that *User Backend SQL Raw* will connect to.
56+
57+
| key | value | default value |
58+
| ------------------ | --------------------------------------- | ------------- |
59+
| `db_type` | `postgresql` or `mariadb` | `postgresql` |
60+
| `db_host` | your db host | `localhost` |
61+
| `db_port` | your db port | `5432` |
62+
| `db_name` | your db name | |
63+
| `db_user` | your db user | |
64+
| `db_password` | your db password | |
65+
| `db_password_file` | path to file containing the db password | |
66+
| `mariadb_charset` | the charset for mariadb connections | `utf8mb4` |
67+
68+
* Values without a default value are mandatory, except that
69+
* only one of `db_password` or `db_passowrd_file` must be set.
70+
* Only the first line of the file specified by `db_passowrd_file` is read.
71+
* Not more than 100 characters of the first line are read.
72+
* Whitespace-like characters are [stripped](https://www.php.net/manual/en/function.trim.php) from the beginning and end of the read password.
5973

6074
### 2. SQL Queries
61-
that will be used to read/write data
75+
76+
that will be used to read/write data.
77+
6278
- queries use named parameters. You have to use the exact names as shown in the examples. For
6379
example, to retrieve the hash for a user, the query named `get_password_hash_for_user` will be
6480
used. Write your custom SQL query and simply put `:username` where you are referring to
@@ -67,8 +83,7 @@ that will be used to read/write data
6783
leave the query `get_home` commented. This app will recognize
6884
this and [communicate](https://docs.nextcloud.com/server/13/developer_manual/api/OCP/UserInterface.html#OCP\UserInterface::implementsActions) to Nextcloud that this feature is not available.
6985
- `user_exists` and `get_users` are required, the rest is optional.
70-
- For user authentication (i.e. login) you need at least `get_password_hash_for_user`,
71-
`user_exists` and `get_users`.
86+
- For user authentication (i.e. login) you need at least `get_password_hash_for_user`, `user_exists` and `get_users`.
7287

7388
- For all queries that read data, only the first column is interpreted.
7489
- Two queries require a little bit of attention:
@@ -84,7 +99,9 @@ that will be used to read/write data
8499
[prepare()](http://php.net/manual/en/pdo.prepare.php) method of a PDO object.
85100

86101
### 3. Hash Algorithm For New Passwords
87-
used for the creation of new passwords
102+
103+
used for the creation of new passwords.
104+
88105
- is optional and, if you leave it empty, defaults to `bcrypt` ($2y$).
89106
- Other supported hash algorithms are MD5-CRYPT, SHA-256-CRYPT, SHA-512-CRYPT, Argon2i and Argon2id.
90107
The config values are `md5`, `sha256`, `sha512`, `argon2i`, `argon2id` respectively, e.g.
@@ -100,13 +117,15 @@ The config values are `md5`, `sha256`, `sha512`, `argon2i`, `argon2id` respectiv
100117

101118

102119
## Security
120+
103121
- Password length is limited to 100 characters to prevent denial of service attacks against the
104122
web server. Without a limit, malicious users could feed your Nextcloud instance with passwords that have a length of tens of thousands of characters, which could cause a very
105123
high load due to expensive password hashing operations.
106124
- The username during user creation (`create_user`) and the display name (`set_display_name`) are
107125
not limited in length. You should limit this on the db layer.
108126

109127
## Troubleshooting
128+
110129
- **TL;DR**: check the log file
111130
- This app has no UI, therefore all error output (exceptions and explicit logs) is written to [Nextcloud's log](https://docs.nextcloud.com/server/20/admin_manual/configuration_server/logging_configuration.html),
112131
by default */var/www/nextcloud/data/nextcloud.log* or */var/log/syslog*. Log level 3 is sufficient for all non-debug output.

lib/Config.php

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Psr\Log\LoggerInterface;
2525
use \OCP\IConfig;
2626

27+
2728
class Config {
2829

2930
const DEFAULT_DB_TYPE = 'postgresql';
@@ -42,6 +43,7 @@ class Config {
4243
const CONFIG_KEY_DB_NAME = 'db_name';
4344
const CONFIG_KEY_DB_USER = 'db_user';
4445
const CONFIG_KEY_DB_PASSWORD = 'db_password';
46+
const CONFIG_KEY_DB_PASSWORD_FILE = 'db_password_file';
4547
const CONFIG_KEY_MARIADB_CHARSET = 'mariadb_charset';
4648
const CONFIG_KEY_HASH_ALGORITHM_FOR_NEW_PASSWORDS = 'hash_algorithm_for_new_passwords';
4749

@@ -61,9 +63,16 @@ class Config {
6163
private $logger;
6264
private $appConfiguration;
6365

66+
/*
67+
* Design decision: Judging from the Nextcloud debug logs the Config class is
68+
* constructed at least as often as queries to the DB are made. Therefore,
69+
* reading all config options once and then returning them from the runtime
70+
* object would not yield any performance advantage. On the contrary, most
71+
* options would be read but never used. So, lazy loading all options seems
72+
* to be the better way.
73+
*/
6474
public function __construct(LoggerInterface $logger, IConfig $nextCloudConfiguration) {
6575
$this->logger = $logger;
66-
6776
$this->appConfiguration = $nextCloudConfiguration->getSystemValue(self::CONFIG_KEY);
6877
if (empty($this->appConfiguration)) {
6978
throw new \UnexpectedValueException('The Nextcloud '
@@ -128,9 +137,53 @@ public function getDbUser() {
128137

129138
/**
130139
* @return string password of db user
140+
* @throws \UnexpectedValueException
131141
*/
132142
public function getDbPassword() {
133-
return $this->getConfigValueOrThrowException(self::CONFIG_KEY_DB_PASSWORD);
143+
144+
$password = $this->getConfigValueOrFalse(self::CONFIG_KEY_DB_PASSWORD);
145+
$passwordFilePath = $this->getConfigValueOrFalse(self::CONFIG_KEY_DB_PASSWORD_FILE);
146+
147+
$passwordIsSet = $password !== FALSE;
148+
$passwordFileIsSet = $passwordFilePath !== FALSE;
149+
150+
if ($passwordIsSet === $passwordFileIsSet) { // expression is a "not XOR"
151+
throw new \UnexpectedValueException('Exactly one of ' . self::CONFIG_KEY_DB_PASSWORD . ' or ' . self::CONFIG_KEY_DB_PASSWORD_FILE . ' must be set (not be empty) in the config.');
152+
}
153+
154+
if ($passwordIsSet) {
155+
$this->logger->debug("Will use db password specified directly in config.php.");
156+
return $password;
157+
}
158+
159+
if ($passwordFileIsSet) {
160+
$this->logger->debug("Will use db password stored in file " . $passwordFilePath). ".";
161+
$error_message_prefix = "Specified db password file with path {$passwordFilePath}";
162+
163+
if (!file_exists($passwordFilePath)) {
164+
throw new \UnexpectedValueException("{$error_message_prefix} does not exist or is not accessible.");
165+
}
166+
if (is_link($passwordFilePath)) {
167+
throw new \UnexpectedValueException("{$error_message_prefix} is a symbolic link, which might be a security problem and is therefore not allowed.");
168+
}
169+
if (is_dir($passwordFilePath)) {
170+
throw new \UnexpectedValueException("{$error_message_prefix} is a directory but I need a file to read the password.");
171+
}
172+
$file = fopen($passwordFilePath, "r");
173+
if ($file === FALSE) {
174+
throw new \UnexpectedValueException("{$error_message_prefix} can not be opened. Maybe insufficient permissions?");
175+
}
176+
// + 1 because fgets() reads one less byte than specified and we want to keep the promise of reading 100 bytes
177+
$first_line = fgets($file, self::MAXIMUM_ALLOWED_PASSWORD_LENGTH + 1);
178+
if ($first_line === FALSE) {
179+
fclose($file);
180+
throw new \UnexpectedValueException("{$error_message_prefix} was opened but the first line could not be read.");
181+
}
182+
fclose($file);
183+
$this->logger->debug("Successfully read db password from file " . $passwordFilePath). ".";
184+
return trim($first_line);
185+
}
186+
134187
}
135188

136189
/**
@@ -257,24 +310,39 @@ private function getConfigValueOrDefaultValue($configKey, $defaultValue) {
257310
}
258311

259312
/**
260-
* Tries to read a config value (query) and if it is set returns its value,
313+
* Tries to read a config value and if it is set returns its value,
261314
* otherwise returns FALSE. This is used for optional configuration keys
262315
* where default values are not known, i.e. SQL queries.
263316
* @param $configKey string key name of configuration parameter
264317
* @return string|bool value of configuration parameter or false if it is
265318
* not set
266319
*/
267-
private function getQueryStringOrFalse ($configKey) {
268-
$queryArray = $this->getConfigValueOrThrowException(self::CONFIG_KEY_QUERIES);
269-
270-
if (empty($queryArray[$configKey])) {
320+
private function getConfigValueOrFalse ($configKey) {
321+
if (empty($this->appConfiguration[$configKey])) {
271322
return FALSE;
272323
}
273324
else {
274-
return $queryArray[$configKey];
325+
return $this->appConfiguration[$configKey];
275326
}
276327
}
277328

329+
private function getValueOrFalse ($value) {
330+
return empty($value) ? FALSE : $value;
331+
}
332+
333+
334+
/**
335+
* Tries to read a query value and if it is set returns its value,
336+
* otherwise returns FALSE.
337+
* @param $configKey string key name of configuration parameter
338+
* @return string|bool value of configuration parameter or false if it is
339+
* not set
340+
*/
341+
private function getQueryStringOrFalse ($configKey) {
342+
$queryArray = $this->getConfigValueOrThrowException(self::CONFIG_KEY_QUERIES);
343+
return $this->getValueOrFalse($queryArray[$configKey] ?? FALSE);
344+
}
345+
278346
/**
279347
* @param $dbType string db descriptor to check
280348
* @return bool whether the db is supported

tests/Unit/ConfigTest.php

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ public function testThrowsExceptionIfMandatorySettingIsEmpty() {
7272
$config->getDbName();
7373
}
7474

75+
public function testThrowsExceptionIfDbPasswordAndDbPasswordFileAreBothSet() {
76+
$this->nextcloudConfigStub->method('getSystemValue')
77+
->willReturn(array(
78+
'db_password' => 'such_secret',
79+
// Specify a file that is always there, so that test does not fail due to missing db password file.
80+
'db_password_file' => '/dev/zero'
81+
));
82+
83+
$this->expectException(\UnexpectedValueException::class);
84+
$config = new Config($this->logStub, $this->nextcloudConfigStub);
85+
$config->getDbPassword();
86+
}
87+
88+
89+
7590
// Tests that check if default values are uses correctly
7691

7792
public function testDefaultDbTypeIsUsedWhenThatParameterIsNotSet() {
@@ -162,7 +177,7 @@ public function testEmptyQueryParameterReturnsFalse() {
162177

163178
$config = new Config($this->logStub, $this->nextcloudConfigStub);
164179

165-
$actualReturnValue = $config->getQueryUserExists();
180+
$actualReturnValue = $config->getQueryGetUsers();
166181
self::assertSame(FALSE, $actualReturnValue);
167182
}
168183

@@ -220,6 +235,36 @@ public function testDbPortIsReturnedWhenThisParameterIsSet() {
220235
self::assertEquals($expectedPort, $actualPort);
221236
}
222237

238+
public function testDBPasswordFromPasswordFileIsTrimmedAndReturned() {
239+
240+
$expectedPassword = 'very-secret 909!&äßZ';
241+
242+
$db_password_file = tempnam("/tmp", "user_backend_sql_raw-db_password_file");
243+
if($db_password_file === FALSE) {
244+
self::fail("Temporary db password file could not be created.");
245+
}
246+
247+
$file = fopen($db_password_file, "w");
248+
if($file === FALSE) {
249+
self::fail("Temporary db password file could not be opened for writing.");
250+
}
251+
252+
// add whitespace at the end which will be trimmed
253+
fwrite($file, "{$expectedPassword} ");
254+
fclose($file);
255+
256+
$this->nextcloudConfigStub->method('getSystemValue')
257+
->willReturn(array(
258+
'db_password_file' => $db_password_file
259+
));
260+
261+
$config = new Config($this->logStub, $this->nextcloudConfigStub);
262+
263+
$actualPassword = $config->getDbPassword();
264+
unlink($db_password_file);
265+
self::assertEquals($expectedPassword, $actualPassword);
266+
}
267+
223268
public function testMariaDbCharsetIsReturnedWhenThisParameterIsSet() {
224269
$this->nextcloudConfigStub->method('getSystemValue')
225270
->willReturn(array(

0 commit comments

Comments
 (0)