mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
feat(config): TLS config options for database connection
Docker / build-and-push (backend) (push) Has been cancelled
Docker / build-and-push (frontend) (push) Has been cancelled
Deploy HD2 docs to Netlify / Deploys to netlify (push) Has been cancelled
E2E Tests / backend-sqlite (push) Has been cancelled
E2E Tests / backend-mariadb (push) Has been cancelled
E2E Tests / backend-postgres (push) Has been cancelled
Lint and check format / Lint files and check formatting (push) Has been cancelled
Static Analysis / Njsscan code scanning (push) Has been cancelled
Static Analysis / CodeQL analysis (javascript) (push) Has been cancelled
Run tests & build / Test and build with NodeJS 24 (push) Has been cancelled
REUSE Compliance Check / reuse (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Docker / build-and-push (backend) (push) Has been cancelled
Docker / build-and-push (frontend) (push) Has been cancelled
Deploy HD2 docs to Netlify / Deploys to netlify (push) Has been cancelled
E2E Tests / backend-sqlite (push) Has been cancelled
E2E Tests / backend-mariadb (push) Has been cancelled
E2E Tests / backend-postgres (push) Has been cancelled
Lint and check format / Lint files and check formatting (push) Has been cancelled
Static Analysis / Njsscan code scanning (push) Has been cancelled
Static Analysis / CodeQL analysis (javascript) (push) Has been cancelled
Run tests & build / Test and build with NodeJS 24 (push) Has been cancelled
REUSE Compliance Check / reuse (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
This was originally contributed by @Avi98 back when the config still used Joi and TypeORM instead of zod and knex. This commit adapts the same changes previously done but ports them over to zod and knex. Furthermore, the tests are updated to ensure all aspects of the config are tested. Co-authored-by: Avinash <avinash.kumar.cs92@gmail.com> Co-authored-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
committed by
Philip Molares
parent
5b026c052a
commit
2b4f00d28f
@@ -5,18 +5,11 @@
|
||||
*/
|
||||
import mockedEnv from 'mocked-env';
|
||||
|
||||
import * as utilsModule from './utils';
|
||||
|
||||
import authConfig from './auth.config';
|
||||
import { Theme } from './theme.enum';
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn((fileName) => fileName === './test.pem'),
|
||||
readFileSync: jest.fn((fileName, encoding) => {
|
||||
if (fileName === './test.pem' && encoding === 'utf8') {
|
||||
return 'test-cert\n';
|
||||
}
|
||||
throw new Error('File not found');
|
||||
}),
|
||||
}));
|
||||
import { TEST_CERT_FILE_CONTENT, TEST_CERT_FILE_PATH } from './shared-test-data';
|
||||
|
||||
describe('authConfig', () => {
|
||||
const secret = 'this-is-a-long-but-insecure-secret';
|
||||
@@ -274,6 +267,14 @@ describe('authConfig', () => {
|
||||
});
|
||||
|
||||
describe('ldap', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(utilsModule, 'readOptionalFileContents')
|
||||
.mockImplementation((filePath: string | undefined) =>
|
||||
filePath === TEST_CERT_FILE_PATH ? TEST_CERT_FILE_CONTENT : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const ldapNames = ['futurama'];
|
||||
const providerName = 'Futurama LDAP';
|
||||
const url = 'ldap://localhost:389';
|
||||
@@ -286,8 +287,8 @@ describe('authConfig', () => {
|
||||
const profilePictureField = 'non_default_profile_picture';
|
||||
const bindDn = 'cn=admin,dc=planetexpress,dc=com';
|
||||
const bindCredentials = 'GoodNewsEveryone';
|
||||
const tlsCa = ['./test.pem'];
|
||||
const tlsCaContent = ['test-cert\n'];
|
||||
const tlsCa = [TEST_CERT_FILE_PATH];
|
||||
const tlsCaContent = [TEST_CERT_FILE_CONTENT];
|
||||
const completeLdapConfig = {
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_AUTH_LDAP_SERVERS: ldapNames.join(','),
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import fs from 'fs';
|
||||
import z from 'zod';
|
||||
|
||||
import { Theme } from './theme.enum';
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
parseOptionalBoolean,
|
||||
parseOptionalNumber,
|
||||
printConfigErrorAndExit,
|
||||
readOptionalFileContents,
|
||||
toArrayConfig,
|
||||
} from './utils';
|
||||
import { buildErrorMessage, extractDescriptionFromZodIssue } from './zod-error-message';
|
||||
@@ -147,11 +147,7 @@ export default registerAs('authConfig', () => {
|
||||
const caFiles = toArrayConfig(process.env[`HD_AUTH_LDAP_${name}_TLS_CERT_PATHS`], ',');
|
||||
let tlsCaCerts = undefined;
|
||||
if (caFiles) {
|
||||
tlsCaCerts = caFiles.map((fileName) => {
|
||||
if (fs.existsSync(fileName)) {
|
||||
return fs.readFileSync(fileName, 'utf8');
|
||||
}
|
||||
});
|
||||
tlsCaCerts = caFiles.map((fileName) => readOptionalFileContents(fileName));
|
||||
}
|
||||
return {
|
||||
identifier: name.toLowerCase(),
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as utilsModule from './utils';
|
||||
import mockedEnv from 'mocked-env';
|
||||
|
||||
import databaseConfig, {
|
||||
getKnexConfig,
|
||||
MariadbDatabaseConfig,
|
||||
PostgresDatabaseConfig,
|
||||
SqliteDatabaseConfig,
|
||||
} from './database.config';
|
||||
import { TEST_CERT_FILE_CONTENT } from './shared-test-data';
|
||||
|
||||
describe('databaseConfig', () => {
|
||||
const databaseTypeSqlite = 'sqlite';
|
||||
@@ -69,6 +72,28 @@ describe('databaseConfig', () => {
|
||||
restore();
|
||||
});
|
||||
|
||||
it('MariaDB config with TLS defaults when not enabled', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_DATABASE_TYPE: databaseTypeMariadb,
|
||||
HD_DATABASE_NAME: databaseName,
|
||||
HD_DATABASE_USERNAME: databaseUser,
|
||||
HD_DATABASE_PASSWORD: databasePass,
|
||||
HD_DATABASE_HOST: databaseHost,
|
||||
HD_DATABASE_PORT: String(databasePort),
|
||||
/* oxlint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = databaseConfig() as MariadbDatabaseConfig;
|
||||
expect(config.tls.enabled).toBe(false);
|
||||
expect(config.tls.rejectUnauthorized).toBe(true);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('Postgres config', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
@@ -94,6 +119,133 @@ describe('databaseConfig', () => {
|
||||
expect(config.port).toEqual(databasePort);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('Postgres config with TLS enabled', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_DATABASE_TYPE: databaseTypePostgres,
|
||||
HD_DATABASE_NAME: databaseName,
|
||||
HD_DATABASE_USERNAME: databaseUser,
|
||||
HD_DATABASE_PASSWORD: databasePass,
|
||||
HD_DATABASE_HOST: databaseHost,
|
||||
HD_DATABASE_PORT: String(databasePort),
|
||||
HD_DATABASE_TLS_ENABLED: 'true',
|
||||
HD_DATABASE_TLS_REJECT_UNAUTHORIZED: 'false',
|
||||
HD_DATABASE_TLS_CIPHERS: 'TLS_AES_256_GCM_SHA384',
|
||||
HD_DATABASE_TLS_MIN_VERSION: 'TLSv1.2',
|
||||
HD_DATABASE_TLS_MAX_VERSION: 'TLSv1.3',
|
||||
HD_DATABASE_TLS_PASSPHRASE: 'test-passphrase',
|
||||
/* oxlint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = databaseConfig() as PostgresDatabaseConfig;
|
||||
expect(config.tls.enabled).toBe(true);
|
||||
expect(config.tls.rejectUnauthorized).toBe(false);
|
||||
expect(config.tls.ciphers).toEqual('TLS_AES_256_GCM_SHA384');
|
||||
expect(config.tls.minVersion).toEqual('TLSv1.2');
|
||||
expect(config.tls.maxVersion).toEqual('TLSv1.3');
|
||||
expect(config.tls.passphrase).toEqual('test-passphrase');
|
||||
restore();
|
||||
});
|
||||
|
||||
it('Postgres config with TLS certificate paths', () => {
|
||||
jest.spyOn(utilsModule, 'readOptionalFileContents').mockReturnValue(TEST_CERT_FILE_CONTENT);
|
||||
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_DATABASE_TYPE: databaseTypePostgres,
|
||||
HD_DATABASE_NAME: databaseName,
|
||||
HD_DATABASE_USERNAME: databaseUser,
|
||||
HD_DATABASE_PASSWORD: databasePass,
|
||||
HD_DATABASE_HOST: databaseHost,
|
||||
HD_DATABASE_TLS_ENABLED: 'true',
|
||||
HD_DATABASE_TLS_CA_PATH: '/path/to/ca.pem',
|
||||
HD_DATABASE_TLS_CERT_PATH: '/path/to/cert.pem',
|
||||
HD_DATABASE_TLS_KEY_PATH: '/path/to/key.pem',
|
||||
/* oxlint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = databaseConfig() as PostgresDatabaseConfig;
|
||||
expect(config.tls.enabled).toBe(true);
|
||||
expect(config.tls.caPath).toEqual('/path/to/ca.pem');
|
||||
expect(config.tls.certPath).toEqual('/path/to/cert.pem');
|
||||
expect(config.tls.keyPath).toEqual('/path/to/key.pem');
|
||||
|
||||
const knexConfig = getKnexConfig(config);
|
||||
const connection = knexConfig.connection as Record<string, unknown>;
|
||||
const ssl = connection.ssl as Record<string, unknown>;
|
||||
expect(ssl.ca).toEqual(TEST_CERT_FILE_CONTENT);
|
||||
expect(ssl.cert).toEqual(TEST_CERT_FILE_CONTENT);
|
||||
expect(ssl.key).toEqual(TEST_CERT_FILE_CONTENT);
|
||||
|
||||
restore();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('MariaDB config with TLS enabled', () => {
|
||||
jest.spyOn(utilsModule, 'readOptionalFileContents').mockReturnValue(TEST_CERT_FILE_CONTENT);
|
||||
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_DATABASE_TYPE: databaseTypeMariadb,
|
||||
HD_DATABASE_NAME: databaseName,
|
||||
HD_DATABASE_USERNAME: databaseUser,
|
||||
HD_DATABASE_PASSWORD: databasePass,
|
||||
HD_DATABASE_HOST: databaseHost,
|
||||
HD_DATABASE_TLS_ENABLED: 'true',
|
||||
HD_DATABASE_TLS_CA_PATH: '/path/to/ca.pem',
|
||||
HD_DATABASE_TLS_CIPHERS: 'TLS_AES_256_GCM_SHA384',
|
||||
HD_DATABASE_TLS_REJECT_UNAUTHORIZED: 'true',
|
||||
/* oxlint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = databaseConfig() as MariadbDatabaseConfig;
|
||||
expect(config.tls.enabled).toBe(true);
|
||||
|
||||
const knexConfig = getKnexConfig(config);
|
||||
const connection = knexConfig.connection as Record<string, unknown>;
|
||||
const ssl = connection.ssl as Record<string, unknown>;
|
||||
expect(ssl.ca).toEqual(TEST_CERT_FILE_CONTENT);
|
||||
expect(ssl.cipher).toEqual('TLS_AES_256_GCM_SHA384');
|
||||
expect(ssl.rejectUnauthorized).toBe(true);
|
||||
|
||||
restore();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Postgres config without TLS produces no ssl in knex config', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_DATABASE_TYPE: databaseTypePostgres,
|
||||
HD_DATABASE_NAME: databaseName,
|
||||
HD_DATABASE_USERNAME: databaseUser,
|
||||
HD_DATABASE_PASSWORD: databasePass,
|
||||
HD_DATABASE_HOST: databaseHost,
|
||||
/* oxlint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = databaseConfig() as PostgresDatabaseConfig;
|
||||
const knexConfig = getKnexConfig(config);
|
||||
const connection = knexConfig.connection as Record<string, unknown>;
|
||||
expect(connection.ssl).toBeUndefined();
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('throws error', () => {
|
||||
@@ -162,5 +314,54 @@ describe('databaseConfig', () => {
|
||||
expect(spyProcessExit).toHaveBeenCalledWith(1);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when TLS min version is greater than max version', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_DATABASE_TYPE: databaseTypePostgres,
|
||||
HD_DATABASE_NAME: databaseName,
|
||||
HD_DATABASE_USERNAME: databaseUser,
|
||||
HD_DATABASE_PASSWORD: databasePass,
|
||||
HD_DATABASE_HOST: databaseHost,
|
||||
HD_DATABASE_TLS_ENABLED: 'true',
|
||||
HD_DATABASE_TLS_MIN_VERSION: 'TLSv1.3',
|
||||
HD_DATABASE_TLS_MAX_VERSION: 'TLSv1.2',
|
||||
/* oxlint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
databaseConfig();
|
||||
expect(spyConsoleError.mock.calls[0][0]).toContain(
|
||||
'TLS min version must be less than or equal to TLS max version',
|
||||
);
|
||||
expect(spyProcessExit).toHaveBeenCalledWith(1);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when TLS version is invalid', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* oxlint-disable @typescript-eslint/naming-convention */
|
||||
HD_DATABASE_TYPE: databaseTypePostgres,
|
||||
HD_DATABASE_NAME: databaseName,
|
||||
HD_DATABASE_USERNAME: databaseUser,
|
||||
HD_DATABASE_PASSWORD: databasePass,
|
||||
HD_DATABASE_HOST: databaseHost,
|
||||
HD_DATABASE_TLS_ENABLED: 'true',
|
||||
HD_DATABASE_TLS_MIN_VERSION: 'TLSv1.0',
|
||||
/* oxlint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
databaseConfig();
|
||||
expect(spyConsoleError.mock.calls[0][0]).toContain('HD_DATABASE_TLS_MIN_VERSION');
|
||||
expect(spyProcessExit).toHaveBeenCalledWith(1);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import { Knex } from 'knex';
|
||||
import { types as pgTypes } from 'pg';
|
||||
import { ConnectionOptions } from 'tls';
|
||||
import z from 'zod';
|
||||
|
||||
import { DatabaseType } from './database-type.enum';
|
||||
import { parseOptionalNumber, printConfigErrorAndExit } from './utils';
|
||||
import {
|
||||
parseOptionalBoolean,
|
||||
parseOptionalNumber,
|
||||
printConfigErrorAndExit,
|
||||
readOptionalFileContents,
|
||||
} from './utils';
|
||||
import { buildErrorMessage, extractDescriptionFromZodIssue } from './zod-error-message';
|
||||
import { checkDatabaseHealthWithRawConnection } from '../database/utils/healthcheck';
|
||||
|
||||
@@ -24,6 +30,31 @@ interface KnexConfigWithPoolConfig extends Knex.Config {
|
||||
pool?: KnexPoolConfigWithValidate;
|
||||
}
|
||||
|
||||
const tlsVersionEnum = z.enum(['TLSv1.2', 'TLSv1.3']);
|
||||
|
||||
const dbTlsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().default(false).describe('HD_DATABASE_TLS_ENABLED'),
|
||||
caPath: z.string().optional().describe('HD_DATABASE_TLS_CA_PATH'),
|
||||
certPath: z.string().optional().describe('HD_DATABASE_TLS_CERT_PATH'),
|
||||
keyPath: z.string().optional().describe('HD_DATABASE_TLS_KEY_PATH'),
|
||||
rejectUnauthorized: z.boolean().default(true).describe('HD_DATABASE_TLS_REJECT_UNAUTHORIZED'),
|
||||
ciphers: z.string().optional().describe('HD_DATABASE_TLS_CIPHERS'),
|
||||
minVersion: tlsVersionEnum.optional().describe('HD_DATABASE_TLS_MIN_VERSION'),
|
||||
maxVersion: tlsVersionEnum.optional().describe('HD_DATABASE_TLS_MAX_VERSION'),
|
||||
passphrase: z.string().optional().describe('HD_DATABASE_TLS_PASSPHRASE'),
|
||||
})
|
||||
.superRefine((config, ctx) => {
|
||||
if (config.minVersion && config.maxVersion && config.minVersion > config.maxVersion) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'TLS min version must be less than or equal to TLS max version',
|
||||
path: ['minVersion'],
|
||||
fatal: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sqliteDbSchema = z.object({
|
||||
type: z.literal(DatabaseType.SQLITE).describe('HD_DATABASE_TYPE'),
|
||||
name: z.string().describe('HD_DATABASE_NAME'),
|
||||
@@ -36,6 +67,7 @@ const postgresDbSchema = z.object({
|
||||
password: z.string().describe('HD_DATABASE_PASSWORD'),
|
||||
host: z.string().describe('HD_DATABASE_HOST'),
|
||||
port: z.number().positive().max(65535).default(5432).describe('HD_DATABASE_PORT'),
|
||||
tls: dbTlsSchema.default({}),
|
||||
});
|
||||
|
||||
const mariaDbSchema = z.object({
|
||||
@@ -45,10 +77,12 @@ const mariaDbSchema = z.object({
|
||||
password: z.string().describe('HD_DATABASE_PASSWORD'),
|
||||
host: z.string().describe('HD_DATABASE_HOST'),
|
||||
port: z.number().positive().max(65535).default(3306).describe('HD_DATABASE_PORT'),
|
||||
tls: dbTlsSchema.default({}),
|
||||
});
|
||||
|
||||
const dbSchema = z.discriminatedUnion('type', [sqliteDbSchema, mariaDbSchema, postgresDbSchema]);
|
||||
|
||||
export type DatabaseTlsConfig = z.infer<typeof dbTlsSchema>;
|
||||
export type SqliteDatabaseConfig = z.infer<typeof sqliteDbSchema>;
|
||||
export type PostgresDatabaseConfig = z.infer<typeof postgresDbSchema>;
|
||||
export type MariadbDatabaseConfig = z.infer<typeof mariaDbSchema>;
|
||||
@@ -62,6 +96,17 @@ export default registerAs('databaseConfig', () => {
|
||||
name: process.env.HD_DATABASE_NAME,
|
||||
host: process.env.HD_DATABASE_HOST,
|
||||
port: parseOptionalNumber(process.env.HD_DATABASE_PORT),
|
||||
tls: {
|
||||
enabled: parseOptionalBoolean(process.env.HD_DATABASE_TLS_ENABLED),
|
||||
caPath: process.env.HD_DATABASE_TLS_CA_PATH,
|
||||
certPath: process.env.HD_DATABASE_TLS_CERT_PATH,
|
||||
keyPath: process.env.HD_DATABASE_TLS_KEY_PATH,
|
||||
rejectUnauthorized: parseOptionalBoolean(process.env.HD_DATABASE_TLS_REJECT_UNAUTHORIZED),
|
||||
ciphers: process.env.HD_DATABASE_TLS_CIPHERS,
|
||||
minVersion: process.env.HD_DATABASE_TLS_MIN_VERSION,
|
||||
maxVersion: process.env.HD_DATABASE_TLS_MAX_VERSION,
|
||||
passphrase: process.env.HD_DATABASE_TLS_PASSPHRASE,
|
||||
},
|
||||
});
|
||||
if (databaseConfig.error) {
|
||||
const errorMessages = databaseConfig.error.errors.map((issue) =>
|
||||
@@ -73,6 +118,51 @@ export default registerAs('databaseConfig', () => {
|
||||
return databaseConfig.data;
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the TLS connection options for PostgreSQL from the TLS config.
|
||||
*
|
||||
* @param tlsConfig The TLS configuration
|
||||
* @returns The TLS connection options or undefined if TLS is not enabled
|
||||
*/
|
||||
function buildPostgresTlsOptions(tlsConfig: DatabaseTlsConfig): ConnectionOptions | undefined {
|
||||
if (!tlsConfig.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ca: readOptionalFileContents(tlsConfig.caPath),
|
||||
cert: readOptionalFileContents(tlsConfig.certPath),
|
||||
key: readOptionalFileContents(tlsConfig.keyPath),
|
||||
rejectUnauthorized: tlsConfig.rejectUnauthorized,
|
||||
ciphers: tlsConfig.ciphers,
|
||||
minVersion: tlsConfig.minVersion,
|
||||
maxVersion: tlsConfig.maxVersion,
|
||||
passphrase: tlsConfig.passphrase,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the TLS connection options for MariaDB from the TLS config.
|
||||
*
|
||||
* @param tlsConfig The TLS configuration
|
||||
* @returns The MariaDB TLS configuration object or undefined if TLS is not enabled
|
||||
*/
|
||||
function buildMariaDbTlsOptions(
|
||||
tlsConfig: DatabaseTlsConfig,
|
||||
):
|
||||
| { ca?: string; cert?: string; key?: string; rejectUnauthorized: boolean; cipher?: string }
|
||||
| undefined {
|
||||
if (!tlsConfig.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ca: readOptionalFileContents(tlsConfig.caPath),
|
||||
cert: readOptionalFileContents(tlsConfig.certPath),
|
||||
key: readOptionalFileContents(tlsConfig.keyPath),
|
||||
rejectUnauthorized: tlsConfig.rejectUnauthorized,
|
||||
cipher: tlsConfig.ciphers,
|
||||
};
|
||||
}
|
||||
|
||||
export function getKnexConfig(databaseConfig: DatabaseConfig): Knex.Config {
|
||||
switch (databaseConfig.type) {
|
||||
case DatabaseType.SQLITE:
|
||||
@@ -98,6 +188,7 @@ export function getKnexConfig(databaseConfig: DatabaseConfig): Knex.Config {
|
||||
password: databaseConfig.password,
|
||||
// oxlint-disable-next-line @typescript-eslint/naming-convention
|
||||
application_name: 'HedgeDoc',
|
||||
ssl: buildPostgresTlsOptions(databaseConfig.tls),
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
@@ -118,6 +209,7 @@ export function getKnexConfig(databaseConfig: DatabaseConfig): Knex.Config {
|
||||
password: databaseConfig.password,
|
||||
dateStrings: true,
|
||||
charset: 'utf8mb4',
|
||||
ssl: buildMariaDbTlsOptions(databaseConfig.tls),
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
|
||||
@@ -17,6 +17,10 @@ export function createDefaultMockDatabaseConfig(): DatabaseConfig {
|
||||
host: 'localhost',
|
||||
port: 0,
|
||||
username: 'hedgedoc',
|
||||
tls: {
|
||||
enabled: false,
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const TEST_CERT_FILE_PATH = './test.pem';
|
||||
export const TEST_CERT_FILE_CONTENT = 'test-cert\n';
|
||||
@@ -10,8 +10,20 @@ import {
|
||||
needToLog,
|
||||
parseOptionalBoolean,
|
||||
parseOptionalNumber,
|
||||
readOptionalFileContents,
|
||||
toArrayConfig,
|
||||
} from './utils';
|
||||
import { TEST_CERT_FILE_CONTENT, TEST_CERT_FILE_PATH } from './shared-test-data';
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn((fileName) => fileName === TEST_CERT_FILE_PATH),
|
||||
readFileSync: jest.fn((fileName, encoding) => {
|
||||
if (fileName === TEST_CERT_FILE_PATH && encoding === 'utf8') {
|
||||
return TEST_CERT_FILE_CONTENT;
|
||||
}
|
||||
throw new Error('File not found');
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('config utils', () => {
|
||||
describe('findDuplicatesInArray', () => {
|
||||
@@ -128,4 +140,15 @@ describe('config utils', () => {
|
||||
expect(parseOptionalBoolean('HedgeDoc')).toEqual(false);
|
||||
});
|
||||
});
|
||||
describe('readOptionalFileContents', () => {
|
||||
it('returns undefined on undefined file path', () => {
|
||||
expect(readOptionalFileContents(undefined)).toBeUndefined();
|
||||
});
|
||||
it('returns undefined when file does not exist', () => {
|
||||
expect(readOptionalFileContents('./non-existing-file.pem')).toBeUndefined();
|
||||
});
|
||||
it('returns the contents of the file when it exists', () => {
|
||||
expect(readOptionalFileContents(TEST_CERT_FILE_PATH)).toEqual(TEST_CERT_FILE_CONTENT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
import { Loglevel } from './loglevel.enum';
|
||||
|
||||
/**
|
||||
@@ -116,6 +118,23 @@ export function parseOptionalBoolean(value?: string): boolean | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the UTF-8 contents of a file if the path is provided and the file exists.
|
||||
* Returns undefined if no path is given or the file does not exist.
|
||||
*
|
||||
* @param filePath The path to the file to read
|
||||
* @returns The contents of the file as a string, or undefined if not found
|
||||
*/
|
||||
export function readOptionalFileContents(filePath?: string): string | undefined {
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
if (fs.existsSync(filePath)) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the given error message to the console (STDERR usually) and then exits the HedgeDoc process.
|
||||
* This is required, since just throwing a config error does not exit the app but instead just gives the
|
||||
|
||||
@@ -19,3 +19,32 @@ We officially support and test these databases:
|
||||
| `HD_DATABASE_USERNAME` | - | `hedgedoc` | The user that logs in the database. *Only if you're **not** using `sqlite`.* |
|
||||
| `HD_DATABASE_PASSWORD` | - | `password` | The password to log into the database. *Only if you're **not** using `sqlite`.* |
|
||||
<!-- markdownlint-enable proper-names -->
|
||||
|
||||
## TLS
|
||||
|
||||
To secure the connection between HedgeDoc and your database server, you can enable TLS.
|
||||
This is only available for PostgreSQL and MariaDB, not for SQLite.
|
||||
|
||||
<!-- markdownlint-disable proper-names -->
|
||||
| environment variable | default | example | description |
|
||||
|---------------------------------------|---------|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `HD_DATABASE_TLS_ENABLED` | `false` | `true` | Set to `true` to enable TLS for the database connection. |
|
||||
| `HD_DATABASE_TLS_CA_PATH` | - | `/path/to/ca.pem` | The file path of the CA certificate used as trust anchor for verifying the database server's certificate. |
|
||||
| `HD_DATABASE_TLS_CERT_PATH` | - | `/path/to/cert.pem` | The file path of the client certificate for mutual TLS authentication. |
|
||||
| `HD_DATABASE_TLS_KEY_PATH` | - | `/path/to/key.pem` | The file path of the client private key for mutual TLS authentication. |
|
||||
| `HD_DATABASE_TLS_REJECT_UNAUTHORIZED` | `true` | `false` | Whether to verify the database server's certificate against the CA. Set to `false` to allow self-signed certificates (not recommended). |
|
||||
| `HD_DATABASE_TLS_CIPHERS` | - | `TLS_AES_256_GCM_SHA384` | The TLS cipher suites to use instead of the Node.js defaults. |
|
||||
| `HD_DATABASE_TLS_MIN_VERSION` | - | `TLSv1.2` | The minimum TLS version to allow. Must be `TLSv1.2` or `TLSv1.3`. |
|
||||
| `HD_DATABASE_TLS_MAX_VERSION` | - | `TLSv1.3` | The maximum TLS version to allow. Must be `TLSv1.2` or `TLSv1.3`. |
|
||||
| `HD_DATABASE_TLS_PASSPHRASE` | - | `my-passphrase` | The passphrase for the client private key. *Only used for PostgreSQL.* |
|
||||
<!-- markdownlint-enable proper-names -->
|
||||
|
||||
!!! note
|
||||
The certificate file paths point to PEM-encoded files on the filesystem. Their contents
|
||||
are read at application startup. Make sure the files are accessible to the HedgeDoc process.
|
||||
|
||||
!!! note
|
||||
`HD_DATABASE_TLS_PASSPHRASE`, `HD_DATABASE_TLS_MIN_VERSION`, and `HD_DATABASE_TLS_MAX_VERSION`
|
||||
are only supported for PostgreSQL connections. MariaDB connections only support
|
||||
`HD_DATABASE_TLS_CA_PATH`, `HD_DATABASE_TLS_CERT_PATH`, `HD_DATABASE_TLS_KEY_PATH`,
|
||||
`HD_DATABASE_TLS_REJECT_UNAUTHORIZED`, and `HD_DATABASE_TLS_CIPHERS`.
|
||||
|
||||
Reference in New Issue
Block a user