Skip to content

Commit ed0447d

Browse files
authored
Merge commit from fork
fix(versions): scope workflow_versions per instance (GHSA-j6r7-6fhx-77wx)
2 parents 0f3d3f5 + ced57ab commit ed0447d

19 files changed

Lines changed: 652 additions & 157 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.56.1] - 2026-06-02
11+
12+
### Fixed
13+
14+
- **Workflow version backups are now scoped per n8n instance.** Each `workflow_versions` record is tagged with a derived, non-spoofable instance key (a hash of the instance's API URL and key), and every read, list, get, delete, rollback, and prune is filtered by it. In multi-tenant HTTP deployments this isolates version history per instance, so one tenant can no longer read or delete another tenant's backups; single-instance and stdio deployments are unaffected (one logical scope). A startup migration adds the `instance_id` column to existing databases (pre-existing, un-scoped backups are cleared during the migration) and an age-based retention sweep — configurable via `WORKFLOW_VERSION_RETENTION_DAYS` (default 30) — bounds on-disk growth alongside the existing per-workflow keep-10 pruning.
15+
16+
### Changed
17+
18+
- **Removed the global `truncate` mode from `n8n_workflow_versions`.** Per-workflow `delete`/`prune` plus the automatic retention sweep replace it.
19+
20+
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
21+
1022
## [2.56.0] - 2026-05-23
1123

1224
### Added

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-mcp",
3-
"version": "2.56.0",
3+
"version": "2.56.1",
44
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Migration: add tenant scoping (instance_id) to workflow_versions.
3+
*
4+
* Fixes GHSA-j6r7-6fhx-77wx: the workflow_versions table had no tenant
5+
* column, so in multi-tenant deployments any tenant could read/delete other
6+
* tenants' version backups by enumerating sequential version ids.
7+
*
8+
* File-based databases do not re-run schema.sql at startup, so this runs at
9+
* NodeRepository init to upgrade existing databases in place. It is idempotent
10+
* (guarded by PRAGMA table_info) and a no-op once the column exists.
11+
*
12+
* Pre-fix rows have no known tenant and were cross-tenant-readable while
13+
* vulnerable, so they are purged: when the column is missing the table is
14+
* dropped and recreated rather than backfilled. Dropping also lets us fix the
15+
* UNIQUE constraint (now scoped by instance_id), which SQLite cannot ALTER.
16+
*
17+
* Only the workflow_versions table is touched; the nodes table (including
18+
* community nodes) is never affected.
19+
*/
20+
21+
import { DatabaseAdapter } from '../database-adapter';
22+
import { logger } from '../../utils/logger';
23+
24+
// Canonical DDL — keep in sync with src/database/schema.sql.
25+
const CREATE_TABLE = `
26+
CREATE TABLE IF NOT EXISTS workflow_versions (
27+
id INTEGER PRIMARY KEY AUTOINCREMENT,
28+
instance_id TEXT NOT NULL DEFAULT '',
29+
workflow_id TEXT NOT NULL,
30+
version_number INTEGER NOT NULL,
31+
workflow_name TEXT NOT NULL,
32+
workflow_snapshot TEXT NOT NULL,
33+
trigger TEXT NOT NULL CHECK(trigger IN (
34+
'partial_update',
35+
'full_update',
36+
'autofix'
37+
)),
38+
operations TEXT,
39+
fix_types TEXT,
40+
metadata TEXT,
41+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
42+
UNIQUE(instance_id, workflow_id, version_number)
43+
);
44+
`;
45+
46+
const CREATE_INDEXES = `
47+
CREATE INDEX IF NOT EXISTS idx_workflow_versions_instance ON workflow_versions(instance_id, workflow_id);
48+
CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow_id ON workflow_versions(workflow_id);
49+
CREATE INDEX IF NOT EXISTS idx_workflow_versions_created_at ON workflow_versions(created_at);
50+
CREATE INDEX IF NOT EXISTS idx_workflow_versions_trigger ON workflow_versions(trigger);
51+
`;
52+
53+
/**
54+
* Ensure the workflow_versions table is tenant-scoped. Safe to call on every
55+
* startup. Returns true if a schema change was applied.
56+
*/
57+
export function migrateWorkflowVersionsInstanceId(db: DatabaseAdapter): boolean {
58+
try {
59+
const columns = db.prepare('PRAGMA table_info(workflow_versions)').all() as Array<{ name: string }>;
60+
const tableExists = columns.length > 0;
61+
const hasInstanceId = columns.some((col) => col.name === 'instance_id');
62+
63+
if (tableExists && hasInstanceId) {
64+
// Already migrated.
65+
return false;
66+
}
67+
68+
// Drop (purging legacy, un-tenanted rows) and recreate with the new schema.
69+
db.exec(`
70+
DROP TABLE IF EXISTS workflow_versions;
71+
${CREATE_TABLE}
72+
${CREATE_INDEXES}
73+
`);
74+
75+
logger.info(
76+
tableExists
77+
? 'Migrated workflow_versions: added instance_id tenant scoping (legacy version backups purged)'
78+
: 'Created workflow_versions table with instance_id tenant scoping'
79+
);
80+
return true;
81+
} catch (error) {
82+
// Tolerate read-only databases and other failures: log and continue so a
83+
// read-only deployment still starts. Tenant-scoped queries assume the
84+
// column exists, which holds for any writable versioning database.
85+
logger.warn('Could not apply workflow_versions instance_id migration', { error });
86+
return false;
87+
}
88+
}

src/database/node-repository.ts

Lines changed: 81 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { DatabaseAdapter } from './database-adapter';
22
import { ParsedNode } from '../parsers/node-parser';
33
import { SQLiteStorageService } from '../services/sqlite-storage-service';
44
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
5+
import { logger } from '../utils/logger';
6+
7+
// Default retention window for workflow version backups (days). Configurable
8+
// via WORKFLOW_VERSION_RETENTION_DAYS; set to 0 to disable age-based pruning.
9+
const DEFAULT_WORKFLOW_VERSION_RETENTION_DAYS = 30;
510

611
/**
712
* Community node extension fields
@@ -28,6 +33,31 @@ export class NodeRepository {
2833

2934
this.db = dbOrService;
3035
}
36+
37+
/**
38+
* Age-based housekeeping: remove version backups past the retention window.
39+
* Called once during database initialization. Internal maintenance only —
40+
* not callable by tenants and not tenant-scoped (deterministic retention,
41+
* not selective destruction).
42+
*/
43+
pruneExpiredWorkflowVersions(): void {
44+
const days = parseInt(
45+
process.env.WORKFLOW_VERSION_RETENTION_DAYS || String(DEFAULT_WORKFLOW_VERSION_RETENTION_DAYS),
46+
10
47+
);
48+
if (!Number.isFinite(days) || days <= 0) {
49+
return; // Retention disabled.
50+
}
51+
try {
52+
const cutoffIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
53+
const removed = this.deleteWorkflowVersionsOlderThan(cutoffIso);
54+
if (removed > 0) {
55+
logger.info(`Pruned ${removed} workflow version backup(s) older than ${days} days`);
56+
}
57+
} catch (error) {
58+
logger.warn('Could not prune expired workflow versions', { error });
59+
}
60+
}
3161

3262
/**
3363
* Save node with proper JSON serialization
@@ -1058,10 +1088,16 @@ export class NodeRepository {
10581088
// Workflow Versioning Methods
10591089
// ========================================
10601090

1091+
// All workflow_versions queries are scoped by instance_id to isolate
1092+
// tenants in multi-tenant deployments (GHSA-j6r7-6fhx-77wx). instanceId is
1093+
// a required, derived tenant key (see getInstanceScopeId); '' is the single
1094+
// logical tenant for single-user / stdio deployments.
1095+
10611096
/**
10621097
* Create a new workflow version (backup before modification)
10631098
*/
10641099
createWorkflowVersion(data: {
1100+
instanceId: string;
10651101
workflowId: string;
10661102
versionNumber: number;
10671103
workflowName: string;
@@ -1073,12 +1109,13 @@ export class NodeRepository {
10731109
}): number {
10741110
const stmt = this.db.prepare(`
10751111
INSERT INTO workflow_versions (
1076-
workflow_id, version_number, workflow_name, workflow_snapshot,
1112+
instance_id, workflow_id, version_number, workflow_name, workflow_snapshot,
10771113
trigger, operations, fix_types, metadata
1078-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1114+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
10791115
`);
10801116

10811117
const result = stmt.run(
1118+
data.instanceId,
10821119
data.workflowId,
10831120
data.versionNumber,
10841121
data.workflowName,
@@ -1095,30 +1132,30 @@ export class NodeRepository {
10951132
/**
10961133
* Get workflow versions ordered by version number (newest first)
10971134
*/
1098-
getWorkflowVersions(workflowId: string, limit?: number): any[] {
1135+
getWorkflowVersions(workflowId: string, instanceId: string, limit?: number): any[] {
10991136
let sql = `
11001137
SELECT * FROM workflow_versions
1101-
WHERE workflow_id = ?
1138+
WHERE workflow_id = ? AND instance_id = ?
11021139
ORDER BY version_number DESC
11031140
`;
11041141

11051142
if (limit) {
11061143
sql += ` LIMIT ?`;
1107-
const rows = this.db.prepare(sql).all(workflowId, limit) as any[];
1144+
const rows = this.db.prepare(sql).all(workflowId, instanceId, limit) as any[];
11081145
return rows.map(row => this.parseWorkflowVersionRow(row));
11091146
}
11101147

1111-
const rows = this.db.prepare(sql).all(workflowId) as any[];
1148+
const rows = this.db.prepare(sql).all(workflowId, instanceId) as any[];
11121149
return rows.map(row => this.parseWorkflowVersionRow(row));
11131150
}
11141151

11151152
/**
1116-
* Get a specific workflow version by ID
1153+
* Get a specific workflow version by ID, scoped to the caller's tenant
11171154
*/
1118-
getWorkflowVersion(versionId: number): any | null {
1155+
getWorkflowVersion(versionId: number, instanceId: string): any | null {
11191156
const row = this.db.prepare(`
1120-
SELECT * FROM workflow_versions WHERE id = ?
1121-
`).get(versionId) as any;
1157+
SELECT * FROM workflow_versions WHERE id = ? AND instance_id = ?
1158+
`).get(versionId, instanceId) as any;
11221159

11231160
if (!row) return null;
11241161
return this.parseWorkflowVersionRow(row);
@@ -1127,34 +1164,37 @@ export class NodeRepository {
11271164
/**
11281165
* Get the latest workflow version for a workflow
11291166
*/
1130-
getLatestWorkflowVersion(workflowId: string): any | null {
1167+
getLatestWorkflowVersion(workflowId: string, instanceId: string): any | null {
11311168
const row = this.db.prepare(`
11321169
SELECT * FROM workflow_versions
1133-
WHERE workflow_id = ?
1170+
WHERE workflow_id = ? AND instance_id = ?
11341171
ORDER BY version_number DESC
11351172
LIMIT 1
1136-
`).get(workflowId) as any;
1173+
`).get(workflowId, instanceId) as any;
11371174

11381175
if (!row) return null;
11391176
return this.parseWorkflowVersionRow(row);
11401177
}
11411178

11421179
/**
1143-
* Delete a specific workflow version
1180+
* Delete a specific workflow version, scoped to the caller's tenant.
1181+
* Returns the number of rows deleted (0 if not owned by this tenant).
11441182
*/
1145-
deleteWorkflowVersion(versionId: number): void {
1146-
this.db.prepare(`
1147-
DELETE FROM workflow_versions WHERE id = ?
1148-
`).run(versionId);
1183+
deleteWorkflowVersion(versionId: number, instanceId: string): number {
1184+
const result = this.db.prepare(`
1185+
DELETE FROM workflow_versions WHERE id = ? AND instance_id = ?
1186+
`).run(versionId, instanceId);
1187+
1188+
return result.changes;
11491189
}
11501190

11511191
/**
11521192
* Delete all versions for a specific workflow
11531193
*/
1154-
deleteWorkflowVersionsByWorkflowId(workflowId: string): number {
1194+
deleteWorkflowVersionsByWorkflowId(workflowId: string, instanceId: string): number {
11551195
const result = this.db.prepare(`
1156-
DELETE FROM workflow_versions WHERE workflow_id = ?
1157-
`).run(workflowId);
1196+
DELETE FROM workflow_versions WHERE workflow_id = ? AND instance_id = ?
1197+
`).run(workflowId, instanceId);
11581198

11591199
return result.changes;
11601200
}
@@ -1163,13 +1203,13 @@ export class NodeRepository {
11631203
* Prune old workflow versions, keeping only the most recent N versions
11641204
* Returns number of versions deleted
11651205
*/
1166-
pruneWorkflowVersions(workflowId: string, keepCount: number): number {
1206+
pruneWorkflowVersions(workflowId: string, keepCount: number, instanceId: string): number {
11671207
// Get all versions ordered by version_number DESC
11681208
const versions = this.db.prepare(`
11691209
SELECT id FROM workflow_versions
1170-
WHERE workflow_id = ?
1210+
WHERE workflow_id = ? AND instance_id = ?
11711211
ORDER BY version_number DESC
1172-
`).all(workflowId) as any[];
1212+
`).all(workflowId, instanceId) as any[];
11731213

11741214
// If we have fewer versions than keepCount, no pruning needed
11751215
if (versions.length <= keepCount) {
@@ -1193,41 +1233,42 @@ export class NodeRepository {
11931233
}
11941234

11951235
/**
1196-
* Truncate the entire workflow_versions table
1197-
* Returns number of rows deleted
1236+
* Delete all version backups older than the given ISO timestamp, across all
1237+
* tenants. Internal age-based retention sweep — deterministic housekeeping
1238+
* that exposes no data and is not callable by tenants. Returns rows deleted.
11981239
*/
1199-
truncateWorkflowVersions(): number {
1240+
deleteWorkflowVersionsOlderThan(cutoffIso: string): number {
12001241
const result = this.db.prepare(`
1201-
DELETE FROM workflow_versions
1202-
`).run();
1242+
DELETE FROM workflow_versions WHERE created_at < ?
1243+
`).run(cutoffIso);
12031244

12041245
return result.changes;
12051246
}
12061247

12071248
/**
12081249
* Get count of versions for a specific workflow
12091250
*/
1210-
getWorkflowVersionCount(workflowId: string): number {
1251+
getWorkflowVersionCount(workflowId: string, instanceId: string): number {
12111252
const result = this.db.prepare(`
1212-
SELECT COUNT(*) as count FROM workflow_versions WHERE workflow_id = ?
1213-
`).get(workflowId) as any;
1253+
SELECT COUNT(*) as count FROM workflow_versions WHERE workflow_id = ? AND instance_id = ?
1254+
`).get(workflowId, instanceId) as any;
12141255

12151256
return result.count;
12161257
}
12171258

12181259
/**
1219-
* Get storage statistics for workflow versions
1260+
* Get storage statistics for workflow versions, scoped to the caller's tenant
12201261
*/
1221-
getVersionStorageStats(): any {
1262+
getVersionStorageStats(instanceId: string): any {
12221263
// Total versions
12231264
const totalResult = this.db.prepare(`
1224-
SELECT COUNT(*) as count FROM workflow_versions
1225-
`).get() as any;
1265+
SELECT COUNT(*) as count FROM workflow_versions WHERE instance_id = ?
1266+
`).get(instanceId) as any;
12261267

12271268
// Total size (approximate - sum of JSON lengths)
12281269
const sizeResult = this.db.prepare(`
1229-
SELECT SUM(LENGTH(workflow_snapshot)) as total_size FROM workflow_versions
1230-
`).get() as any;
1270+
SELECT SUM(LENGTH(workflow_snapshot)) as total_size FROM workflow_versions WHERE instance_id = ?
1271+
`).get(instanceId) as any;
12311272

12321273
// Per-workflow breakdown
12331274
const byWorkflow = this.db.prepare(`
@@ -1238,9 +1279,10 @@ export class NodeRepository {
12381279
SUM(LENGTH(workflow_snapshot)) as total_size,
12391280
MAX(created_at) as last_backup
12401281
FROM workflow_versions
1282+
WHERE instance_id = ?
12411283
GROUP BY workflow_id
12421284
ORDER BY version_count DESC
1243-
`).all() as any[];
1285+
`).all(instanceId) as any[];
12441286

12451287
return {
12461288
totalVersions: totalResult.count,

0 commit comments

Comments
 (0)