Skip to content

Commit 7f1e6dc

Browse files
committed
fix: harden DB/Redis setup with escaping, auth, login probes, and SQLite schema
- Add shDq() helper and apply it to all values embedded in shell double-quoted strings, preventing $ and backtick injection in generated setup commands - Fix Redis sed delimiter bug: replace s/.../.../ with delete-then-append so passwords containing / no longer break the sed pattern - MongoDB: enable security.authorization in mongod.conf when a password is provided; skip user creation (was silently failing with || true) when no password is given, as SCRAM auth requires pwd - Add post-setup login probes for Postgres, MySQL, MongoDB, and Redis when a password is configured, so credential typos surface at setup time rather than at runtime - Replace DatabaseConfig flat interface with a discriminated union (SqliteDatabaseConfig | NetworkDatabaseConfig): SQLite only requires name, network DBs get min(1) validation on host/name/user, and port: 0 workaround in init.ts is removed - Replace unreachable return 'true' SQLite fallthrough with throw - Export buildDbSetupCommand and buildRedisSetupCommand and add 18 unit tests covering each DB type, password presence, login probe inclusion, the / regression, and $ escaping
1 parent 3bfc93f commit 7f1e6dc

7 files changed

Lines changed: 201 additions & 45 deletions

File tree

src/cli/commands/backup.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,18 @@ export async function cmdBackupSetup(
8282
process.exit(1);
8383
}
8484

85+
const db = config.database;
86+
const netDb = db && db.type !== 'sqlite' ? db : undefined;
8587
const script = buildBackupScript({
8688
remotePath: config.remotePath,
8789
s3Bucket,
8890
s3Prefix,
8991
s3Endpoint,
90-
dbType: config.database?.type,
91-
dbName: config.database?.name,
92-
dbUser: config.database?.user,
93-
dbHost: config.database?.host,
94-
dbPort: config.database?.port,
92+
dbType: db?.type,
93+
dbName: db?.name,
94+
dbUser: netDb?.user,
95+
dbHost: netDb?.host,
96+
dbPort: netDb?.port,
9597
});
9698

9799
const b64 = Buffer.from(script).toString('base64');

src/cli/commands/config.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,12 @@ export async function cmdConfigShow(cwd: string, options: { config?: string }):
6161
}
6262

6363
if (config.database) {
64-
ui.section('Database', [
65-
['type', config.database.type],
66-
['host', config.database.host],
67-
['port', String(config.database.port)],
68-
['name', config.database.name],
69-
['user', config.database.user],
70-
]);
64+
const db = config.database;
65+
const rows: [string, string][] = [['type', db.type], ['name', db.name]];
66+
if (db.type !== 'sqlite') {
67+
rows.push(['host', db.host], ['port', String(db.port)], ['user', db.user]);
68+
}
69+
ui.section('Database', rows);
7170
}
7271
},
7372
{ configPath: options.config },

src/cli/commands/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ function generateConfig(opts: ConfigOptions): string {
418418

419419
if (opts.dbType) {
420420
if (opts.dbType === 'sqlite') {
421-
lines.push(` .database({ type: 'sqlite', host: 'localhost', port: 0, name: '${opts.dbName ?? './data.db'}', user: '' })`);
421+
lines.push(` .database({ type: 'sqlite', name: '${opts.dbName ?? './data.db'}' })`);
422422
} else {
423423
const dbOpts = [
424424
`type: '${opts.dbType}'`,

src/cli/commands/setup.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -111,60 +111,86 @@ function sh(s: string): string {
111111
return s.replace(/'/g, "'\"'\"'");
112112
}
113113

114-
function buildDbSetupCommand(db: DatabaseConfig): string {
114+
function shDq(s: string): string {
115+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
116+
}
117+
118+
export function buildDbSetupCommand(db: DatabaseConfig): string {
119+
if (db.type === 'sqlite') {
120+
throw new Error(`buildDbSetupCommand called for sqlite — caller should guard against this`);
121+
}
122+
115123
// preamble uses ';' so the conditional SUDO/PG_RUN assignments don't break
116124
// the '&&' chain when running as root (EUID=0 makes [ -ne 0 ] exit 1)
117125
const preamble = 'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; PG_RUN="runuser -u postgres --"; [ "$EUID" -ne 0 ] && PG_RUN="sudo -u postgres"';
118126

119127
if (db.type === 'postgres') {
128+
const userDq = shDq(db.user);
129+
const nameDq = shDq(db.name);
120130
const createUser = db.password
121-
? `$PG_RUN psql -c "CREATE USER \\"${sh(db.user)}\\" WITH PASSWORD '${sh(db.password)}';" 2>/dev/null || true`
122-
: `$PG_RUN psql -c "CREATE USER \\"${sh(db.user)}\\";" 2>/dev/null || true`;
131+
? `$PG_RUN psql -c "CREATE USER \\"${userDq}\\" WITH PASSWORD '${shDq(db.password)}';" 2>/dev/null || true`
132+
: `$PG_RUN psql -c "CREATE USER \\"${userDq}\\";" 2>/dev/null || true`;
123133
const commands = [
124134
'$SUDO apt-get install -y postgresql postgresql-contrib',
125135
'$SUDO systemctl enable postgresql',
126136
'$SUDO systemctl start postgresql',
127137
createUser,
128-
`$PG_RUN psql -tc "SELECT 1 FROM pg_database WHERE datname='${sh(db.name)}'" | grep -q 1 || $PG_RUN createdb -O "${sh(db.user)}" "${sh(db.name)}"`,
138+
`$PG_RUN psql -tc "SELECT 1 FROM pg_database WHERE datname='${nameDq}'" | grep -q 1 || $PG_RUN createdb -O "${userDq}" "${nameDq}"`,
129139
];
140+
if (db.password) {
141+
commands.push(`PGPASSWORD='${sh(db.password)}' psql -h localhost -U '${sh(db.user)}' -d '${sh(db.name)}' -c "SELECT 1" > /dev/null`);
142+
}
130143
return `${preamble}; ${commands.join(' && ')}`;
131144
}
132145

133146
if (db.type === 'mysql') {
134-
const pwClause = db.password ? `IDENTIFIED BY '${sh(db.password)}'` : '';
147+
const userDq = shDq(db.user);
148+
const nameDq = shDq(db.name);
149+
const pwClause = db.password ? `IDENTIFIED BY '${shDq(db.password)}'` : '';
135150
const commands = [
136151
'$SUDO apt-get install -y mysql-server',
137152
'$SUDO systemctl enable mysql',
138153
'$SUDO systemctl start mysql',
139-
`$SUDO mysql -e "CREATE USER IF NOT EXISTS '${sh(db.user)}'@'localhost' ${pwClause};"`,
140-
`$SUDO mysql -e "CREATE DATABASE IF NOT EXISTS \\\`${sh(db.name)}\\\`;"`,
141-
`$SUDO mysql -e "GRANT ALL PRIVILEGES ON \\\`${sh(db.name)}\\\`.* TO '${sh(db.user)}'@'localhost';"`,
154+
`$SUDO mysql -e "CREATE USER IF NOT EXISTS '${userDq}'@'localhost' ${pwClause};"`,
155+
`$SUDO mysql -e "CREATE DATABASE IF NOT EXISTS \\\`${nameDq}\\\`;"`,
156+
`$SUDO mysql -e "GRANT ALL PRIVILEGES ON \\\`${nameDq}\\\`.* TO '${userDq}'@'localhost';"`,
142157
`$SUDO mysql -e "FLUSH PRIVILEGES;"`,
143158
];
159+
if (db.password) {
160+
commands.push(`MYSQL_PWD='${sh(db.password)}' mysql -h 127.0.0.1 -u '${sh(db.user)}' '${sh(db.name)}' -e "SELECT 1" > /dev/null`);
161+
}
144162
return `${preamble}; ${commands.join(' && ')}`;
145163
}
146164

147165
if (db.type === 'mongodb') {
148-
const createUser = db.password
149-
? `mongosh "${sh(db.name)}" --eval "db.createUser({user:'${sh(db.user)}',pwd:'${sh(db.password)}',roles:[{role:'readWrite',db:'${sh(db.name)}'}]})" 2>/dev/null || true`
150-
: `mongosh "${sh(db.name)}" --eval "db.createUser({user:'${sh(db.user)}',roles:[{role:'readWrite',db:'${sh(db.name)}'}]})" 2>/dev/null || true`;
151-
const commands = [
166+
const installCmd =
152167
'if ! command -v mongod &>/dev/null; then ' +
153168
'curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | $SUDO gpg --dearmor -o /usr/share/keyrings/mongodb-server-7.0.gpg; ' +
154169
'echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | $SUDO tee /etc/apt/sources.list.d/mongodb-org-7.0.list; ' +
155170
'$SUDO apt-get update -qq && $SUDO apt-get install -y mongodb-org; ' +
156-
'fi',
171+
'fi';
172+
const commands = [
173+
installCmd,
157174
'$SUDO systemctl enable mongod',
158175
'$SUDO systemctl start mongod',
159-
createUser,
160176
];
177+
if (db.password) {
178+
const nameDq = shDq(db.name);
179+
const userDq = shDq(db.user);
180+
const pwdDq = shDq(db.password);
181+
commands.push(
182+
`if ! grep -q "authorization: enabled" /etc/mongod.conf 2>/dev/null; then echo -e "\\nsecurity:\\n authorization: enabled" | $SUDO tee -a /etc/mongod.conf > /dev/null && $SUDO systemctl restart mongod; fi`,
183+
`mongosh "${nameDq}" --eval "db.createUser({user:'${userDq}',pwd:'${pwdDq}',roles:[{role:'readWrite',db:'${nameDq}'}]})" 2>/dev/null || true`,
184+
`mongosh --host localhost '${sh(db.name)}' -u '${sh(db.user)}' -p '${sh(db.password)}' --authenticationDatabase '${sh(db.name)}' --eval "db.runCommand({ping:1})" --quiet > /dev/null`,
185+
);
186+
}
161187
return `${preamble}; ${commands.join(' && ')}`;
162188
}
163189

164-
return 'true';
190+
throw new Error(`Unsupported database type: ${(db as DatabaseConfig).type}`);
165191
}
166192

167-
function buildRedisSetupCommand(redis: RedisConfig): string {
193+
export function buildRedisSetupCommand(redis: RedisConfig): string {
168194
const preamble = 'SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"';
169195
const parts = [
170196
'$SUDO apt-get install -y redis-server',
@@ -174,9 +200,10 @@ function buildRedisSetupCommand(redis: RedisConfig): string {
174200

175201
if (redis.password) {
176202
parts.push(
177-
`$SUDO sed -i "s/^# requirepass .*/requirepass ${sh(redis.password)}/" /etc/redis/redis.conf`,
178-
`grep -q "^requirepass" /etc/redis/redis.conf || echo "requirepass ${sh(redis.password)}" | $SUDO tee -a /etc/redis/redis.conf > /dev/null`,
203+
`$SUDO sed -i '/^#* *requirepass/d' /etc/redis/redis.conf`,
204+
`echo "requirepass ${shDq(redis.password)}" | $SUDO tee -a /etc/redis/redis.conf > /dev/null`,
179205
'$SUDO systemctl restart redis-server',
206+
`redis-cli -h localhost -p ${redis.port} -a '${sh(redis.password)}' PING > /dev/null 2>&1`,
180207
);
181208
}
182209

src/config/schema.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,20 @@ export const HealthCheckConfigSchema = z.object({
2828
startupDelay: z.number().int().min(0).default(3),
2929
}).default({});
3030

31-
export const DatabaseConfigSchema = z.object({
32-
type: z.enum(['postgres', 'mysql', 'sqlite', 'mongodb']),
33-
host: z.string(),
31+
const networkDbFields = {
32+
host: z.string().min(1, 'Database host is required'),
3433
port: z.number().int().min(1).max(65535),
35-
name: z.string(),
36-
user: z.string(),
34+
name: z.string().min(1, 'Database name is required'),
35+
user: z.string().min(1, 'Database user is required'),
3736
password: z.string().optional(),
38-
}).optional();
37+
};
38+
39+
export const DatabaseConfigSchema = z.discriminatedUnion('type', [
40+
z.object({ type: z.literal('sqlite'), name: z.string().min(1, 'SQLite file path is required') }),
41+
z.object({ type: z.literal('postgres'), ...networkDbFields }),
42+
z.object({ type: z.literal('mysql'), ...networkDbFields }),
43+
z.object({ type: z.literal('mongodb'), ...networkDbFields }),
44+
]).optional();
3945

4046
export const RedisConfigSchema = z.object({
4147
host: z.string().default('localhost'),

src/shared/types.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ export type PkgManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
44

55
export type DatabaseType = 'postgres' | 'mysql' | 'sqlite' | 'mongodb';
66

7+
export interface SqliteDatabaseConfig {
8+
type: 'sqlite';
9+
name: string;
10+
}
11+
12+
export interface NetworkDatabaseConfig {
13+
type: 'postgres' | 'mysql' | 'mongodb';
14+
host: string;
15+
port: number;
16+
name: string;
17+
user: string;
18+
password?: string;
19+
}
20+
721
export interface SshConfig {
822
host: string;
923
user: string;
@@ -31,14 +45,7 @@ export interface HealthCheckConfig {
3145
startupDelay: number;
3246
}
3347

34-
export interface DatabaseConfig {
35-
type: DatabaseType;
36-
host: string;
37-
port: number;
38-
name: string;
39-
user: string;
40-
password?: string;
41-
}
48+
export type DatabaseConfig = SqliteDatabaseConfig | NetworkDatabaseConfig;
4249

4350
export interface RedisConfig {
4451
host: string;

tests/unit/setup.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { buildDbSetupCommand, buildRedisSetupCommand } from '../../src/cli/commands/setup.js';
3+
4+
describe('buildDbSetupCommand', () => {
5+
it('postgres: installs, creates user and db', () => {
6+
const cmd = buildDbSetupCommand({ type: 'postgres', host: 'localhost', port: 5432, name: 'mydb', user: 'myuser' });
7+
expect(cmd).toContain('apt-get install -y postgresql postgresql-contrib');
8+
expect(cmd).toContain('systemctl enable postgresql');
9+
expect(cmd).toContain('CREATE USER \\"myuser\\"');
10+
expect(cmd).toContain("datname='mydb'");
11+
expect(cmd).toContain('createdb -O "myuser" "mydb"');
12+
});
13+
14+
it('postgres: includes password in createUser when provided', () => {
15+
const cmd = buildDbSetupCommand({ type: 'postgres', host: 'localhost', port: 5432, name: 'mydb', user: 'myuser', password: 'secret' });
16+
expect(cmd).toContain("WITH PASSWORD 'secret'");
17+
});
18+
19+
it('postgres: adds login probe when password is provided', () => {
20+
const cmd = buildDbSetupCommand({ type: 'postgres', host: 'localhost', port: 5432, name: 'mydb', user: 'myuser', password: 'secret' });
21+
expect(cmd).toContain("PGPASSWORD='secret' psql -h localhost -U 'myuser' -d 'mydb'");
22+
});
23+
24+
it('postgres: no login probe without password', () => {
25+
const cmd = buildDbSetupCommand({ type: 'postgres', host: 'localhost', port: 5432, name: 'mydb', user: 'myuser' });
26+
expect(cmd).not.toContain('PGPASSWORD');
27+
});
28+
29+
it('postgres: escapes $ in user/name with shDq to prevent shell expansion', () => {
30+
const cmd = buildDbSetupCommand({ type: 'postgres', host: 'localhost', port: 5432, name: '$mydb', user: '$myuser', password: 'p' });
31+
expect(cmd).toContain('\\$myuser');
32+
expect(cmd).toContain('\\$mydb');
33+
});
34+
35+
it('mysql: installs, creates user and db', () => {
36+
const cmd = buildDbSetupCommand({ type: 'mysql', host: 'localhost', port: 3306, name: 'mydb', user: 'myuser' });
37+
expect(cmd).toContain('apt-get install -y mysql-server');
38+
expect(cmd).toContain("CREATE USER IF NOT EXISTS 'myuser'@'localhost'");
39+
expect(cmd).toContain('CREATE DATABASE IF NOT EXISTS');
40+
expect(cmd).toContain("GRANT ALL PRIVILEGES ON");
41+
expect(cmd).toContain('FLUSH PRIVILEGES');
42+
});
43+
44+
it('mysql: adds login probe when password is provided', () => {
45+
const cmd = buildDbSetupCommand({ type: 'mysql', host: 'localhost', port: 3306, name: 'mydb', user: 'myuser', password: 'secret' });
46+
expect(cmd).toContain("MYSQL_PWD='secret' mysql -h 127.0.0.1 -u 'myuser' 'mydb'");
47+
});
48+
49+
it('mysql: no login probe without password', () => {
50+
const cmd = buildDbSetupCommand({ type: 'mysql', host: 'localhost', port: 3306, name: 'mydb', user: 'myuser' });
51+
expect(cmd).not.toContain('MYSQL_PWD');
52+
});
53+
54+
it('mongodb: installs and starts mongod', () => {
55+
const cmd = buildDbSetupCommand({ type: 'mongodb', host: 'localhost', port: 27017, name: 'mydb', user: 'myuser' });
56+
expect(cmd).toContain('command -v mongod');
57+
expect(cmd).toContain('mongodb-org');
58+
expect(cmd).toContain('systemctl enable mongod');
59+
expect(cmd).toContain('systemctl start mongod');
60+
});
61+
62+
it('mongodb: enables auth and creates user when password provided', () => {
63+
const cmd = buildDbSetupCommand({ type: 'mongodb', host: 'localhost', port: 27017, name: 'mydb', user: 'myuser', password: 'secret' });
64+
expect(cmd).toContain('authorization: enabled');
65+
expect(cmd).toContain("db.createUser({user:'myuser',pwd:'secret'");
66+
});
67+
68+
it('mongodb: adds login probe when password provided', () => {
69+
const cmd = buildDbSetupCommand({ type: 'mongodb', host: 'localhost', port: 27017, name: 'mydb', user: 'myuser', password: 'secret' });
70+
expect(cmd).toContain("mongosh --host localhost 'mydb' -u 'myuser' -p 'secret'");
71+
});
72+
73+
it('mongodb: no auth or user creation without password', () => {
74+
const cmd = buildDbSetupCommand({ type: 'mongodb', host: 'localhost', port: 27017, name: 'mydb', user: 'myuser' });
75+
expect(cmd).not.toContain('authorization');
76+
expect(cmd).not.toContain('createUser');
77+
});
78+
79+
it('throws for sqlite', () => {
80+
expect(() => buildDbSetupCommand({ type: 'sqlite', name: './data.db' })).toThrow();
81+
});
82+
});
83+
84+
describe('buildRedisSetupCommand', () => {
85+
it('installs and starts redis', () => {
86+
const cmd = buildRedisSetupCommand({ host: 'localhost', port: 6379 });
87+
expect(cmd).toContain('apt-get install -y redis-server');
88+
expect(cmd).toContain('systemctl enable redis-server');
89+
expect(cmd).toContain('systemctl start redis-server');
90+
expect(cmd).not.toContain('requirepass');
91+
});
92+
93+
it('sets requirepass and restarts when password provided', () => {
94+
const cmd = buildRedisSetupCommand({ host: 'localhost', port: 6379, password: 'secret' });
95+
expect(cmd).toContain('requirepass secret');
96+
expect(cmd).toContain('systemctl restart redis-server');
97+
});
98+
99+
it('adds login probe when password provided', () => {
100+
const cmd = buildRedisSetupCommand({ host: 'localhost', port: 6379, password: 'secret' });
101+
expect(cmd).toContain("redis-cli -h localhost -p 6379 -a 'secret' PING");
102+
});
103+
104+
it('does not use / as sed delimiter — password with slash must not break sed', () => {
105+
const cmd = buildRedisSetupCommand({ host: 'localhost', port: 6379, password: 'pass/word' });
106+
// Old code used s/.../.../ which breaks on /; new code deletes and appends instead
107+
expect(cmd).not.toMatch(/sed -i "s\//);
108+
expect(cmd).toContain('requirepass pass/word');
109+
});
110+
111+
it('escapes $ in password to prevent shell expansion', () => {
112+
const cmd = buildRedisSetupCommand({ host: 'localhost', port: 6379, password: '$ecret' });
113+
expect(cmd).toContain('\\$ecret');
114+
});
115+
});

0 commit comments

Comments
 (0)