merge: resolve conflicts with dev, fix 7 Snyk security issues

- Resolve translation conflicts (keep both journey + OAuth scope keys)
- Resolve migrations.ts (dev OAuth migrations + journey migrations)
- Fix hono directory traversal, response splitting, input validation (CVE-2026-39407/08/09/10)
- Fix @hono/node-server directory traversal (CVE-2026-39406)
- Fix nodemailer CRLF injection (upgrade to 8.0.5)
This commit is contained in:
Maurice
2026-04-11 19:11:21 +02:00
121 changed files with 13475 additions and 2499 deletions
+122 -2
View File
@@ -19,7 +19,8 @@ function runMigrations(db: Database.Database): void {
}
}
const migrations: Array<() => void> = [
type Migration = (() => void) | { raw: () => void };
const migrations: Migration[] = [
() => db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'),
() => db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'),
() => db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'),
@@ -1316,13 +1317,132 @@ function runMigrations(db: Database.Database): void {
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)');
},
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id);
CREATE TABLE IF NOT EXISTS oauth_consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes TEXT NOT NULL DEFAULT '[]',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id, user_id)
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token_hash TEXT UNIQUE NOT NULL,
refresh_token_hash TEXT UNIQUE NOT NULL,
scopes TEXT NOT NULL DEFAULT '[]',
access_token_expires_at DATETIME NOT NULL,
refresh_token_expires_at DATETIME NOT NULL,
revoked_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash);
`);
},
// Migration: Refresh-token rotation chain tracking for replay detection
() => {
db.exec(`
ALTER TABLE oauth_tokens ADD COLUMN parent_token_id INTEGER REFERENCES oauth_tokens(id);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id);
`);
},
// Migration: Public client support for browser-initiated dynamic registration (DCR)
() => {
db.exec(`
ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0;
ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui';
`);
},
// Migration: Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients
// (must run outside a transaction because PRAGMA foreign_keys cannot change mid-transaction)
{
raw: () => {
db.exec('PRAGMA foreign_keys = OFF');
try {
db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients_new (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_public INTEGER NOT NULL DEFAULT 0,
created_via TEXT NOT NULL DEFAULT 'settings_ui'
)
`);
db.exec(`INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`);
db.exec(`DROP TABLE oauth_clients`);
db.exec(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`);
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`);
})();
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
},
},
// Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos
() => {
const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'hint')) {
db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`);
}
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3)
`);
db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`);
db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`);
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4)
`);
db.exec(`
UPDATE photo_provider_fields
SET hint = 'providerUrlHintSynology'
WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url'
`);
},
];
if (currentVersion < migrations.length) {
for (let i = currentVersion; i < migrations.length; i++) {
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
try {
db.transaction(() => migrations[i]())();
const migration = migrations[i];
if (typeof migration === 'function') {
db.transaction(migration)();
} else {
migration.raw();
}
} catch (err) {
console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err);
process.exit(1);
+1
View File
@@ -245,6 +245,7 @@ function createTables(db: Database.Database): void {
label TEXT NOT NULL,
input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT,
hint TEXT,
required INTEGER DEFAULT 0,
secret INTEGER DEFAULT 0,
settings_key TEXT,
+9 -7
View File
@@ -116,15 +116,17 @@ function seedAddons(db: Database.Database): void {
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
const providerFields = [
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
{ provider_id: 'immich', field_key: 'immich_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://immich.example.com', hint: null, required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
{ provider_id: 'immich', field_key: 'immich_api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'API Key', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://synology.example.com/photo', hint: 'providerUrlHintSynology', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'providerUsername', input_type: 'text', placeholder: 'Username', hint: null, required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'providerPassword', input_type: 'password', placeholder: 'Password', hint: null, required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
{ provider_id: 'synologyphotos', field_key: 'synology_otp', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: null, required: 0, secret: 0, settings_key: null, payload_key: 'synology_otp', sort_order: 3 },
{ provider_id: 'synologyphotos', field_key: 'synology_skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, settings_key: 'synology_skip_ssl', payload_key: 'synology_skip_ssl', sort_order: 4 },
];
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
for (const f of providerFields) {
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
}
console.log('Default addons seeded');
} catch (err: unknown) {