@actual-app/sync-server 26.2.0 → 26.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getAccountDb } from '../src/account-db.js';
|
|
2
|
+
export const up = async function () {
|
|
3
|
+
const accountDb = getAccountDb();
|
|
4
|
+
const admin = accountDb.first('SELECT id FROM users WHERE role = ? ORDER BY id LIMIT 1', ['ADMIN']);
|
|
5
|
+
if (admin) {
|
|
6
|
+
accountDb.mutate('UPDATE files SET owner = ? WHERE owner IS NULL', [
|
|
7
|
+
admin.id,
|
|
8
|
+
]);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
export const down = async function () {
|
|
12
|
+
// Cannot reliably restore NULL owner for backfilled rows; no-op.
|
|
13
|
+
};
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { handleError } from '../app-gocardless/util/handle-error.js';
|
|
3
3
|
import { SecretName, secretsService } from '../services/secrets-service.js';
|
|
4
|
-
import { requestLoggerMiddleware } from '../util/middlewares.js';
|
|
4
|
+
import { requestLoggerMiddleware, validateSessionMiddleware, } from '../util/middlewares.js';
|
|
5
5
|
import { pluggyaiService } from './pluggyai-service.js';
|
|
6
6
|
const app = express();
|
|
7
7
|
export { app as handlers };
|
|
8
|
-
app.use(express.json());
|
|
9
8
|
app.use(requestLoggerMiddleware);
|
|
9
|
+
app.use(express.json());
|
|
10
|
+
app.use(validateSessionMiddleware);
|
|
10
11
|
app.post('/status', handleError(async (req, res) => {
|
|
11
12
|
const clientId = secretsService.get(SecretName.pluggyai_clientId);
|
|
12
13
|
const configured = clientId != null;
|
|
@@ -2,11 +2,12 @@ import https from 'https';
|
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { handleError } from '../app-gocardless/util/handle-error.js';
|
|
4
4
|
import { SecretName, secretsService } from '../services/secrets-service.js';
|
|
5
|
-
import { requestLoggerMiddleware } from '../util/middlewares.js';
|
|
5
|
+
import { requestLoggerMiddleware, validateSessionMiddleware, } from '../util/middlewares.js';
|
|
6
6
|
const app = express();
|
|
7
7
|
export { app as handlers };
|
|
8
|
-
app.use(express.json());
|
|
9
8
|
app.use(requestLoggerMiddleware);
|
|
9
|
+
app.use(express.json());
|
|
10
|
+
app.use(validateSessionMiddleware);
|
|
10
11
|
app.post('/status', handleError(async (req, res) => {
|
|
11
12
|
const token = secretsService.get(SecretName.simplefin_token);
|
|
12
13
|
const configured = token != null && token !== 'Forbidden';
|
package/build/src/app-sync.js
CHANGED
|
@@ -10,6 +10,7 @@ import { FileNotFound } from './app-sync/errors.js';
|
|
|
10
10
|
import { File, FilesService, FileUpdate, } from './app-sync/services/files-service.js';
|
|
11
11
|
import { validateSyncedFile, validateUploadedFile, } from './app-sync/validation.js';
|
|
12
12
|
import { config } from './load-config.js';
|
|
13
|
+
import * as UserService from './services/user-service.js';
|
|
13
14
|
import * as simpleSync from './sync-simple.js';
|
|
14
15
|
import { errorMiddleware, requestLoggerMiddleware, validateSessionMiddleware, } from './util/middlewares.js';
|
|
15
16
|
import { getPathForGroupFile, getPathForUserFile } from './util/paths.js';
|
|
@@ -46,6 +47,17 @@ const verifyFileExists = (fileId, filesService, res, errorObject) => {
|
|
|
46
47
|
throw e;
|
|
47
48
|
}
|
|
48
49
|
};
|
|
50
|
+
function requireFileAccess(file, userId) {
|
|
51
|
+
const isOwner = file.owner === userId;
|
|
52
|
+
const isServerAdmin = isAdmin(userId);
|
|
53
|
+
if (isOwner || isServerAdmin) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (UserService.countUserAccess(file.id, userId) > 0) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return 'file-access-not-allowed';
|
|
60
|
+
}
|
|
49
61
|
app.post('/sync', async (req, res) => {
|
|
50
62
|
let requestPb;
|
|
51
63
|
try {
|
|
@@ -75,6 +87,12 @@ app.post('/sync', async (req, res) => {
|
|
|
75
87
|
if (!currentFile) {
|
|
76
88
|
return;
|
|
77
89
|
}
|
|
90
|
+
const fileAccessError = requireFileAccess(currentFile, res.locals.user_id);
|
|
91
|
+
if (fileAccessError) {
|
|
92
|
+
res.status(403);
|
|
93
|
+
res.send(fileAccessError);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
78
96
|
const errorMessage = validateSyncedFile(groupId, keyId, currentFile);
|
|
79
97
|
if (errorMessage) {
|
|
80
98
|
res.status(400);
|
|
@@ -99,6 +117,12 @@ app.post('/user-get-key', (req, res) => {
|
|
|
99
117
|
if (!file) {
|
|
100
118
|
return;
|
|
101
119
|
}
|
|
120
|
+
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
|
121
|
+
if (fileAccessError) {
|
|
122
|
+
res.status(403);
|
|
123
|
+
res.send(fileAccessError);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
102
126
|
res.send({
|
|
103
127
|
status: 'ok',
|
|
104
128
|
data: {
|
|
@@ -111,7 +135,14 @@ app.post('/user-get-key', (req, res) => {
|
|
|
111
135
|
app.post('/user-create-key', (req, res) => {
|
|
112
136
|
const { fileId, keyId, keySalt, testContent } = req.body || {};
|
|
113
137
|
const filesService = new FilesService(getAccountDb());
|
|
114
|
-
|
|
138
|
+
const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
|
|
139
|
+
if (!file) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
|
143
|
+
if (fileAccessError) {
|
|
144
|
+
res.status(403);
|
|
145
|
+
res.send(fileAccessError);
|
|
115
146
|
return;
|
|
116
147
|
}
|
|
117
148
|
filesService.update(fileId, new FileUpdate({
|
|
@@ -128,6 +159,12 @@ app.post('/reset-user-file', async (req, res) => {
|
|
|
128
159
|
if (!file) {
|
|
129
160
|
return;
|
|
130
161
|
}
|
|
162
|
+
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
|
163
|
+
if (fileAccessError) {
|
|
164
|
+
res.status(403);
|
|
165
|
+
res.send(fileAccessError);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
131
168
|
const groupId = file.groupId;
|
|
132
169
|
filesService.update(fileId, new FileUpdate({ groupId: null }));
|
|
133
170
|
if (groupId) {
|
|
@@ -172,6 +209,14 @@ app.post('/upload-user-file', async (req, res) => {
|
|
|
172
209
|
throw e;
|
|
173
210
|
}
|
|
174
211
|
}
|
|
212
|
+
const fileAccessError = currentFile
|
|
213
|
+
? requireFileAccess(currentFile, res.locals.user_id)
|
|
214
|
+
: null;
|
|
215
|
+
if (fileAccessError) {
|
|
216
|
+
res.status(403);
|
|
217
|
+
res.send(fileAccessError);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
175
220
|
const errorMessage = validateUploadedFile(groupId, keyId, currentFile);
|
|
176
221
|
if (errorMessage) {
|
|
177
222
|
res.status(400).send(errorMessage);
|
|
@@ -224,7 +269,14 @@ app.get('/download-user-file', async (req, res) => {
|
|
|
224
269
|
return;
|
|
225
270
|
}
|
|
226
271
|
const filesService = new FilesService(getAccountDb());
|
|
227
|
-
|
|
272
|
+
const file = verifyFileExists(fileId, filesService, res, 'User or file not found');
|
|
273
|
+
if (!file) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
|
277
|
+
if (fileAccessError) {
|
|
278
|
+
res.status(403);
|
|
279
|
+
res.send(fileAccessError);
|
|
228
280
|
return;
|
|
229
281
|
}
|
|
230
282
|
const path = getPathForUserFile(fileId);
|
|
@@ -239,7 +291,14 @@ app.get('/download-user-file', async (req, res) => {
|
|
|
239
291
|
app.post('/update-user-filename', (req, res) => {
|
|
240
292
|
const { fileId, name } = req.body || {};
|
|
241
293
|
const filesService = new FilesService(getAccountDb());
|
|
242
|
-
|
|
294
|
+
const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
|
|
295
|
+
if (!file) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
|
299
|
+
if (fileAccessError) {
|
|
300
|
+
res.status(403);
|
|
301
|
+
res.send(fileAccessError);
|
|
243
302
|
return;
|
|
244
303
|
}
|
|
245
304
|
filesService.update(fileId, new FileUpdate({ name }));
|
|
@@ -282,6 +341,12 @@ app.get('/get-user-file-info', (req, res) => {
|
|
|
282
341
|
if (!file) {
|
|
283
342
|
return;
|
|
284
343
|
}
|
|
344
|
+
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
|
345
|
+
if (fileAccessError) {
|
|
346
|
+
res.status(403);
|
|
347
|
+
res.send(fileAccessError);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
285
350
|
res.send({
|
|
286
351
|
status: 'ok',
|
|
287
352
|
data: {
|
|
@@ -312,16 +377,10 @@ app.post('/delete-user-file', (req, res) => {
|
|
|
312
377
|
if (!file) {
|
|
313
378
|
return;
|
|
314
379
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (!isOwner && !isServerAdmin) {
|
|
320
|
-
res.status(403).send({
|
|
321
|
-
status: 'error',
|
|
322
|
-
reason: 'forbidden',
|
|
323
|
-
details: 'file-delete-not-allowed',
|
|
324
|
-
});
|
|
380
|
+
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
|
381
|
+
if (fileAccessError) {
|
|
382
|
+
res.status(403);
|
|
383
|
+
res.send(fileAccessError);
|
|
325
384
|
return;
|
|
326
385
|
}
|
|
327
386
|
filesService.update(fileId, new FileUpdate({ deleted: true }));
|
|
@@ -7,6 +7,7 @@ import { getAccountDb } from './account-db.js';
|
|
|
7
7
|
import { handlers as app } from './app-sync.js';
|
|
8
8
|
import { getPathForUserFile } from './util/paths.js';
|
|
9
9
|
const ADMIN_ROLE = 'ADMIN';
|
|
10
|
+
const OTHER_USER_ID = 'otherUser';
|
|
10
11
|
const createUser = (userId, userName, role, owner = 0, enabled = 1) => {
|
|
11
12
|
getAccountDb().mutate('INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)', [userId, userName, `${userName} display`, enabled, owner, role]);
|
|
12
13
|
};
|
|
@@ -51,6 +52,36 @@ describe('/user-get-key', () => {
|
|
|
51
52
|
expect(res.statusCode).toEqual(400);
|
|
52
53
|
expect(res.text).toBe('file-not-found');
|
|
53
54
|
});
|
|
55
|
+
it('returns 403 when non-owner gets encryption key', async () => {
|
|
56
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
57
|
+
getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [fileId, 'salt', 'key-id', 'test', OTHER_USER_ID]);
|
|
58
|
+
const res = await request(app)
|
|
59
|
+
.post('/user-get-key')
|
|
60
|
+
.set('x-actual-token', 'valid-token-user')
|
|
61
|
+
.send({ fileId });
|
|
62
|
+
expect(res.statusCode).toEqual(403);
|
|
63
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
64
|
+
});
|
|
65
|
+
it("allows an admin to get encryption key for another user's file", async () => {
|
|
66
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
67
|
+
const encrypt_salt = 'salt';
|
|
68
|
+
const encrypt_keyid = 'key-id';
|
|
69
|
+
const encrypt_test = 'test';
|
|
70
|
+
getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [fileId, encrypt_salt, encrypt_keyid, encrypt_test, OTHER_USER_ID]);
|
|
71
|
+
const res = await request(app)
|
|
72
|
+
.post('/user-get-key')
|
|
73
|
+
.set('x-actual-token', 'valid-token-admin')
|
|
74
|
+
.send({ fileId });
|
|
75
|
+
expect(res.statusCode).toEqual(200);
|
|
76
|
+
expect(res.body).toEqual({
|
|
77
|
+
status: 'ok',
|
|
78
|
+
data: {
|
|
79
|
+
id: encrypt_keyid,
|
|
80
|
+
salt: encrypt_salt,
|
|
81
|
+
test: encrypt_test,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
});
|
|
54
85
|
});
|
|
55
86
|
describe('/user-create-key', () => {
|
|
56
87
|
it('returns 401 if the user is not authenticated', async () => {
|
|
@@ -68,7 +99,53 @@ describe('/user-create-key', () => {
|
|
|
68
99
|
.set('x-actual-token', 'valid-token')
|
|
69
100
|
.send({ fileId: 'non-existent-file-id' });
|
|
70
101
|
expect(res.statusCode).toEqual(400);
|
|
71
|
-
expect(res.text).toBe('file
|
|
102
|
+
expect(res.text).toBe('file-not-found');
|
|
103
|
+
});
|
|
104
|
+
it('returns 403 when non-owner creates encryption key', async () => {
|
|
105
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
106
|
+
getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [fileId, 'old-salt', 'old-key', 'old-test', OTHER_USER_ID]);
|
|
107
|
+
const res = await request(app)
|
|
108
|
+
.post('/user-create-key')
|
|
109
|
+
.set('x-actual-token', 'valid-token-user')
|
|
110
|
+
.send({
|
|
111
|
+
fileId,
|
|
112
|
+
keyId: 'new-key',
|
|
113
|
+
keySalt: 'new-salt',
|
|
114
|
+
testContent: 'new-test',
|
|
115
|
+
});
|
|
116
|
+
expect(res.statusCode).toEqual(403);
|
|
117
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
118
|
+
});
|
|
119
|
+
it("allows an admin to create encryption key for another user's file", async () => {
|
|
120
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
121
|
+
const old_encrypt_salt = 'old-salt';
|
|
122
|
+
const old_encrypt_keyid = 'old-key';
|
|
123
|
+
const old_encrypt_test = 'old-test';
|
|
124
|
+
const encrypt_salt = 'new-salt';
|
|
125
|
+
const encrypt_keyid = 'new-key-id';
|
|
126
|
+
const encrypt_test = 'new-encrypt-test';
|
|
127
|
+
getAccountDb().mutate('INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', [
|
|
128
|
+
fileId,
|
|
129
|
+
old_encrypt_salt,
|
|
130
|
+
old_encrypt_keyid,
|
|
131
|
+
old_encrypt_test,
|
|
132
|
+
OTHER_USER_ID,
|
|
133
|
+
]);
|
|
134
|
+
const res = await request(app)
|
|
135
|
+
.post('/user-create-key')
|
|
136
|
+
.set('x-actual-token', 'valid-token-admin')
|
|
137
|
+
.send({
|
|
138
|
+
fileId,
|
|
139
|
+
keyId: encrypt_keyid,
|
|
140
|
+
keySalt: encrypt_salt,
|
|
141
|
+
testContent: encrypt_test,
|
|
142
|
+
});
|
|
143
|
+
expect(res.statusCode).toEqual(200);
|
|
144
|
+
expect(res.body).toEqual({ status: 'ok' });
|
|
145
|
+
const rows = getAccountDb().all('SELECT encrypt_salt, encrypt_keyid, encrypt_test FROM files WHERE id = ?', [fileId]);
|
|
146
|
+
expect(rows[0].encrypt_salt).toEqual(encrypt_salt);
|
|
147
|
+
expect(rows[0].encrypt_keyid).toEqual(encrypt_keyid);
|
|
148
|
+
expect(rows[0].encrypt_test).toEqual(encrypt_test);
|
|
72
149
|
});
|
|
73
150
|
it('creates a new encryption key for the file', async () => {
|
|
74
151
|
const fileId = crypto.randomBytes(16).toString('hex');
|
|
@@ -132,6 +209,31 @@ describe('/reset-user-file', () => {
|
|
|
132
209
|
expect(res.statusCode).toEqual(400);
|
|
133
210
|
expect(res.text).toBe('User or file not found');
|
|
134
211
|
});
|
|
212
|
+
it('returns 403 when non-owner resets another user file', async () => {
|
|
213
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
214
|
+
getAccountDb().mutate('INSERT OR IGNORE INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
|
|
215
|
+
const res = await request(app)
|
|
216
|
+
.post('/reset-user-file')
|
|
217
|
+
.set('x-actual-token', 'valid-token-user')
|
|
218
|
+
.send({ fileId });
|
|
219
|
+
expect(res.statusCode).toEqual(403);
|
|
220
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
221
|
+
});
|
|
222
|
+
it("allows an admin to reset another user's file", async () => {
|
|
223
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
224
|
+
const groupId = 'admin-reset-group-id';
|
|
225
|
+
getAccountDb().mutate('INSERT INTO files (id, group_id, deleted, owner) VALUES (?, ?, FALSE, ?)', [fileId, groupId, OTHER_USER_ID]);
|
|
226
|
+
const res = await request(app)
|
|
227
|
+
.post('/reset-user-file')
|
|
228
|
+
.set('x-actual-token', 'valid-token-admin')
|
|
229
|
+
.send({ fileId });
|
|
230
|
+
expect(res.statusCode).toEqual(200);
|
|
231
|
+
expect(res.body).toEqual({ status: 'ok' });
|
|
232
|
+
const rows = getAccountDb().all('SELECT group_id FROM files WHERE id = ?', [
|
|
233
|
+
fileId,
|
|
234
|
+
]);
|
|
235
|
+
expect(rows[0].group_id).toBeNull();
|
|
236
|
+
});
|
|
135
237
|
});
|
|
136
238
|
describe('/upload-user-file', () => {
|
|
137
239
|
it('returns 401 if the user is not authenticated', async () => {
|
|
@@ -170,6 +272,12 @@ describe('/upload-user-file', () => {
|
|
|
170
272
|
const fileContentBuffer = Buffer.from(fileContent);
|
|
171
273
|
const syncVersion = 2;
|
|
172
274
|
const encryptMeta = JSON.stringify({ keyId: 'key-id' });
|
|
275
|
+
onTestFinished(() => {
|
|
276
|
+
try {
|
|
277
|
+
fs.unlinkSync(getPathForUserFile(fileId));
|
|
278
|
+
}
|
|
279
|
+
catch { }
|
|
280
|
+
});
|
|
173
281
|
// Verify that the file does not exist before upload
|
|
174
282
|
const rowsBefore = getAccountDb().all('SELECT * FROM files WHERE id = ?', [
|
|
175
283
|
fileId,
|
|
@@ -201,8 +309,6 @@ describe('/upload-user-file', () => {
|
|
|
201
309
|
const filePath = getPathForUserFile(fileId);
|
|
202
310
|
const writtenContent = await fs.promises.readFile(filePath, 'utf8');
|
|
203
311
|
expect(writtenContent).toEqual(fileContent);
|
|
204
|
-
// Clean up the file
|
|
205
|
-
await fs.promises.unlink(filePath);
|
|
206
312
|
});
|
|
207
313
|
it('uploads and updates an existing file successfully', async () => {
|
|
208
314
|
const fileId = crypto.randomBytes(16).toString('hex');
|
|
@@ -219,6 +325,12 @@ describe('/upload-user-file', () => {
|
|
|
219
325
|
keyId: oldKeyId,
|
|
220
326
|
sentinelValue: 1,
|
|
221
327
|
}); //keep the same key, but change other things
|
|
328
|
+
onTestFinished(() => {
|
|
329
|
+
try {
|
|
330
|
+
fs.unlinkSync(getPathForUserFile(fileId));
|
|
331
|
+
}
|
|
332
|
+
catch { }
|
|
333
|
+
});
|
|
222
334
|
// Create the old file version
|
|
223
335
|
getAccountDb().mutate('INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid) VALUES (?, ?, ?, ?, ?, ?)', [
|
|
224
336
|
fileId,
|
|
@@ -258,8 +370,6 @@ describe('/upload-user-file', () => {
|
|
|
258
370
|
const filePath = getPathForUserFile(fileId);
|
|
259
371
|
const writtenContent = await fs.promises.readFile(filePath, 'utf8');
|
|
260
372
|
expect(writtenContent).toEqual(newFileContent);
|
|
261
|
-
// Clean up the file
|
|
262
|
-
await fs.promises.unlink(filePath);
|
|
263
373
|
});
|
|
264
374
|
it('returns 400 if the file is part of an old group', async () => {
|
|
265
375
|
const fileId = crypto.randomBytes(16).toString('hex');
|
|
@@ -299,6 +409,81 @@ describe('/upload-user-file', () => {
|
|
|
299
409
|
expect(res.statusCode).toEqual(400);
|
|
300
410
|
expect(res.text).toEqual('file-has-new-key');
|
|
301
411
|
});
|
|
412
|
+
it('returns 403 when non-owner overwrites another user file', async () => {
|
|
413
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
414
|
+
const groupId = 'group-id';
|
|
415
|
+
const keyId = 'key-id';
|
|
416
|
+
const syncVersion = 2;
|
|
417
|
+
fs.writeFileSync(getPathForUserFile(fileId), 'existing content');
|
|
418
|
+
getAccountDb().mutate('INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid, deleted, owner) VALUES (?, ?, ?, ?, ?, ?, 0, ?)', [
|
|
419
|
+
fileId,
|
|
420
|
+
groupId,
|
|
421
|
+
syncVersion,
|
|
422
|
+
'existing.txt',
|
|
423
|
+
JSON.stringify({ keyId }),
|
|
424
|
+
keyId,
|
|
425
|
+
OTHER_USER_ID,
|
|
426
|
+
]);
|
|
427
|
+
onTestFinished(() => {
|
|
428
|
+
try {
|
|
429
|
+
fs.unlinkSync(getPathForUserFile(fileId));
|
|
430
|
+
}
|
|
431
|
+
catch { }
|
|
432
|
+
});
|
|
433
|
+
const res = await request(app)
|
|
434
|
+
.post('/upload-user-file')
|
|
435
|
+
.set('Content-Type', 'application/encrypted-file')
|
|
436
|
+
.set('x-actual-token', 'valid-token-user')
|
|
437
|
+
.set('x-actual-file-id', fileId)
|
|
438
|
+
.set('x-actual-name', 'hacked.txt')
|
|
439
|
+
.set('x-actual-group-id', groupId)
|
|
440
|
+
.set('x-actual-format', syncVersion.toString())
|
|
441
|
+
.set('x-actual-encrypt-meta', JSON.stringify({ keyId }))
|
|
442
|
+
.send(Buffer.from('overwrite content'));
|
|
443
|
+
expect(res.statusCode).toEqual(403);
|
|
444
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
445
|
+
});
|
|
446
|
+
it("allows an admin to overwrite another user's file", async () => {
|
|
447
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
448
|
+
const groupId = 'admin-upload-group-id';
|
|
449
|
+
const keyId = 'key-id';
|
|
450
|
+
const syncVersion = 2;
|
|
451
|
+
const existingContent = 'existing content';
|
|
452
|
+
const newContent = 'admin overwrite content';
|
|
453
|
+
fs.writeFileSync(getPathForUserFile(fileId), existingContent);
|
|
454
|
+
getAccountDb().mutate('INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid, deleted, owner) VALUES (?, ?, ?, ?, ?, ?, 0, ?)', [
|
|
455
|
+
fileId,
|
|
456
|
+
groupId,
|
|
457
|
+
syncVersion,
|
|
458
|
+
'existing.txt',
|
|
459
|
+
JSON.stringify({ keyId }),
|
|
460
|
+
keyId,
|
|
461
|
+
OTHER_USER_ID,
|
|
462
|
+
]);
|
|
463
|
+
onTestFinished(() => {
|
|
464
|
+
try {
|
|
465
|
+
fs.unlinkSync(getPathForUserFile(fileId));
|
|
466
|
+
}
|
|
467
|
+
catch { }
|
|
468
|
+
});
|
|
469
|
+
const res = await request(app)
|
|
470
|
+
.post('/upload-user-file')
|
|
471
|
+
.set('Content-Type', 'application/encrypted-file')
|
|
472
|
+
.set('x-actual-token', 'valid-token-admin')
|
|
473
|
+
.set('x-actual-file-id', fileId)
|
|
474
|
+
.set('x-actual-name', 'admin-renamed.txt')
|
|
475
|
+
.set('x-actual-group-id', groupId)
|
|
476
|
+
.set('x-actual-format', syncVersion.toString())
|
|
477
|
+
.set('x-actual-encrypt-meta', JSON.stringify({ keyId }))
|
|
478
|
+
.send(Buffer.from(newContent));
|
|
479
|
+
expect(res.statusCode).toEqual(200);
|
|
480
|
+
expect(res.body).toEqual({ status: 'ok', groupId });
|
|
481
|
+
expect(fs.readFileSync(getPathForUserFile(fileId), 'utf8')).toEqual(newContent);
|
|
482
|
+
const rows = getAccountDb().all('SELECT name FROM files WHERE id = ?', [
|
|
483
|
+
fileId,
|
|
484
|
+
]);
|
|
485
|
+
expect(rows[0].name).toEqual('admin-renamed.txt');
|
|
486
|
+
});
|
|
302
487
|
});
|
|
303
488
|
describe('/download-user-file', () => {
|
|
304
489
|
describe('default version', () => {
|
|
@@ -354,6 +539,71 @@ describe('/download-user-file', () => {
|
|
|
354
539
|
expect(res.body).toBeInstanceOf(Buffer);
|
|
355
540
|
expect(res.body.toString('utf8')).toEqual(fileContent);
|
|
356
541
|
});
|
|
542
|
+
describe('access control', () => {
|
|
543
|
+
it('returns 403 when non-owner downloads another user file', async () => {
|
|
544
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
545
|
+
const fileContent = 'sensitive content';
|
|
546
|
+
fs.writeFileSync(getPathForUserFile(fileId), fileContent);
|
|
547
|
+
getAccountDb().mutate('INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
|
|
548
|
+
onTestFinished(() => {
|
|
549
|
+
try {
|
|
550
|
+
fs.unlinkSync(getPathForUserFile(fileId));
|
|
551
|
+
}
|
|
552
|
+
catch { }
|
|
553
|
+
});
|
|
554
|
+
const res = await request(app)
|
|
555
|
+
.get('/download-user-file')
|
|
556
|
+
.set('x-actual-token', 'valid-token-user')
|
|
557
|
+
.set('x-actual-file-id', fileId);
|
|
558
|
+
expect(res.statusCode).toEqual(403);
|
|
559
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
560
|
+
});
|
|
561
|
+
it("allows an admin to download another user's file", async () => {
|
|
562
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
563
|
+
const fileContent = 'admin-downloaded content';
|
|
564
|
+
fs.writeFileSync(getPathForUserFile(fileId), fileContent);
|
|
565
|
+
getAccountDb().mutate('INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
|
|
566
|
+
onTestFinished(() => {
|
|
567
|
+
try {
|
|
568
|
+
fs.unlinkSync(getPathForUserFile(fileId));
|
|
569
|
+
}
|
|
570
|
+
catch { }
|
|
571
|
+
});
|
|
572
|
+
const res = await request(app)
|
|
573
|
+
.get('/download-user-file')
|
|
574
|
+
.set('x-actual-token', 'valid-token-admin')
|
|
575
|
+
.set('x-actual-file-id', fileId);
|
|
576
|
+
expect(res.statusCode).toEqual(200);
|
|
577
|
+
expect(res.body).toBeInstanceOf(Buffer);
|
|
578
|
+
expect(res.body.toString('utf8')).toEqual(fileContent);
|
|
579
|
+
});
|
|
580
|
+
it('allows non-owner with user_access to download via requireFileAccess (UserService.countUserAccess > 0)', async () => {
|
|
581
|
+
// File owned by another user; access granted only via user_access row, not owner/admin.
|
|
582
|
+
// This exercises the requireFileAccess branch that uses UserService.countUserAccess.
|
|
583
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
584
|
+
const fileContent = 'shared-user content';
|
|
585
|
+
fs.writeFileSync(getPathForUserFile(fileId), fileContent);
|
|
586
|
+
getAccountDb().mutate('INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)', [fileId, OTHER_USER_ID]);
|
|
587
|
+
getAccountDb().mutate('INSERT INTO user_access (file_id, user_id) VALUES (?, ?)', [fileId, 'genericUser']);
|
|
588
|
+
onTestFinished(() => {
|
|
589
|
+
try {
|
|
590
|
+
fs.unlinkSync(getPathForUserFile(fileId));
|
|
591
|
+
}
|
|
592
|
+
catch { }
|
|
593
|
+
});
|
|
594
|
+
const res = await request(app)
|
|
595
|
+
.get('/download-user-file')
|
|
596
|
+
.set('x-actual-token', 'valid-token-user')
|
|
597
|
+
.set('x-actual-file-id', fileId);
|
|
598
|
+
expect(res.statusCode).toEqual(200);
|
|
599
|
+
expect(res.headers).toEqual(expect.objectContaining({
|
|
600
|
+
'content-disposition': `attachment;filename=${fileId}`,
|
|
601
|
+
'content-type': 'application/octet-stream',
|
|
602
|
+
}));
|
|
603
|
+
expect(res.body).toBeInstanceOf(Buffer);
|
|
604
|
+
expect(res.body.toString('utf8')).toEqual(fileContent);
|
|
605
|
+
});
|
|
606
|
+
});
|
|
357
607
|
});
|
|
358
608
|
});
|
|
359
609
|
describe('/update-user-filename', () => {
|
|
@@ -372,7 +622,7 @@ describe('/update-user-filename', () => {
|
|
|
372
622
|
.set('x-actual-token', 'valid-token')
|
|
373
623
|
.send({ fileId: 'non-existent-file-id', name: 'new-filename' });
|
|
374
624
|
expect(res.statusCode).toEqual(400);
|
|
375
|
-
expect(res.text).toBe('file
|
|
625
|
+
expect(res.text).toBe('file-not-found');
|
|
376
626
|
});
|
|
377
627
|
it('successfully updates the filename', async () => {
|
|
378
628
|
const fileId = crypto.randomBytes(16).toString('hex');
|
|
@@ -392,6 +642,32 @@ describe('/update-user-filename', () => {
|
|
|
392
642
|
]);
|
|
393
643
|
expect(rows[0].name).toEqual(newName);
|
|
394
644
|
});
|
|
645
|
+
it('returns 403 when non-owner renames another user file', async () => {
|
|
646
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
647
|
+
getAccountDb().mutate('INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)', [fileId, 'original-name', OTHER_USER_ID]);
|
|
648
|
+
const res = await request(app)
|
|
649
|
+
.post('/update-user-filename')
|
|
650
|
+
.set('x-actual-token', 'valid-token-user')
|
|
651
|
+
.send({ fileId, name: 'stolen' });
|
|
652
|
+
expect(res.statusCode).toEqual(403);
|
|
653
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
654
|
+
});
|
|
655
|
+
it("allows an admin to rename another user's file", async () => {
|
|
656
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
657
|
+
const originalName = 'original-name';
|
|
658
|
+
const newName = 'admin-renamed-file';
|
|
659
|
+
getAccountDb().mutate('INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)', [fileId, originalName, OTHER_USER_ID]);
|
|
660
|
+
const res = await request(app)
|
|
661
|
+
.post('/update-user-filename')
|
|
662
|
+
.set('x-actual-token', 'valid-token-admin')
|
|
663
|
+
.send({ fileId, name: newName });
|
|
664
|
+
expect(res.statusCode).toEqual(200);
|
|
665
|
+
expect(res.body).toEqual({ status: 'ok' });
|
|
666
|
+
const rows = getAccountDb().all('SELECT name FROM files WHERE id = ?', [
|
|
667
|
+
fileId,
|
|
668
|
+
]);
|
|
669
|
+
expect(rows[0].name).toEqual(newName);
|
|
670
|
+
});
|
|
395
671
|
});
|
|
396
672
|
describe('/list-user-files', () => {
|
|
397
673
|
it('returns 401 if the user is not authenticated', async () => {
|
|
@@ -493,6 +769,39 @@ describe('/get-user-file-info', () => {
|
|
|
493
769
|
details: 'token-not-found',
|
|
494
770
|
});
|
|
495
771
|
});
|
|
772
|
+
it('returns 403 when non-owner gets another user file info', async () => {
|
|
773
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
774
|
+
getAccountDb().mutate('INSERT INTO files (id, group_id, name, deleted, owner) VALUES (?, ?, ?, FALSE, ?)', [fileId, 'group-id', 'budget', OTHER_USER_ID]);
|
|
775
|
+
const res = await request(app)
|
|
776
|
+
.get('/get-user-file-info')
|
|
777
|
+
.set('x-actual-token', 'valid-token-user')
|
|
778
|
+
.set('x-actual-file-id', fileId);
|
|
779
|
+
expect(res.statusCode).toEqual(403);
|
|
780
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
781
|
+
});
|
|
782
|
+
it("allows an admin to get another user's file info", async () => {
|
|
783
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
784
|
+
const groupId = 'admin-file-info-group';
|
|
785
|
+
const name = 'admin-info-file';
|
|
786
|
+
const encrypt_meta = JSON.stringify({ key: 'value' });
|
|
787
|
+
getAccountDb().mutate('INSERT INTO files (id, group_id, name, encrypt_meta, deleted, owner) VALUES (?, ?, ?, ?, 0, ?)', [fileId, groupId, name, encrypt_meta, OTHER_USER_ID]);
|
|
788
|
+
const res = await request(app)
|
|
789
|
+
.get('/get-user-file-info')
|
|
790
|
+
.set('x-actual-token', 'valid-token-admin')
|
|
791
|
+
.set('x-actual-file-id', fileId);
|
|
792
|
+
expect(res.statusCode).toEqual(200);
|
|
793
|
+
expect(res.body).toEqual({
|
|
794
|
+
status: 'ok',
|
|
795
|
+
data: {
|
|
796
|
+
deleted: 0,
|
|
797
|
+
fileId,
|
|
798
|
+
groupId,
|
|
799
|
+
name,
|
|
800
|
+
encryptMeta: { key: 'value' },
|
|
801
|
+
usersWithAccess: [],
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
});
|
|
496
805
|
});
|
|
497
806
|
describe('/delete-user-file', () => {
|
|
498
807
|
it('returns 401 if the user is not authenticated', async () => {
|
|
@@ -552,11 +861,7 @@ describe('/delete-user-file', () => {
|
|
|
552
861
|
.set('x-actual-token', 'valid-token-user')
|
|
553
862
|
.send({ fileId });
|
|
554
863
|
expect(res.statusCode).toEqual(403);
|
|
555
|
-
expect(res.
|
|
556
|
-
status: 'error',
|
|
557
|
-
reason: 'forbidden',
|
|
558
|
-
details: 'file-delete-not-allowed',
|
|
559
|
-
});
|
|
864
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
560
865
|
// Verify that the file is NOT deleted
|
|
561
866
|
const rows = accountDb.all('SELECT deleted FROM files WHERE id = ?', [
|
|
562
867
|
fileId,
|
|
@@ -691,9 +996,34 @@ describe('/sync', () => {
|
|
|
691
996
|
expect(res.statusCode).toEqual(400);
|
|
692
997
|
expect(res.text).toEqual('file-has-new-key');
|
|
693
998
|
});
|
|
999
|
+
it('returns 403 when non-owner syncs another user file', async () => {
|
|
1000
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
1001
|
+
const groupId = 'group-id';
|
|
1002
|
+
const keyId = 'key-id';
|
|
1003
|
+
const syncVersion = 2;
|
|
1004
|
+
const encryptMeta = JSON.stringify({ keyId });
|
|
1005
|
+
addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion, OTHER_USER_ID);
|
|
1006
|
+
const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId);
|
|
1007
|
+
const res = await sendSyncRequest(syncRequest, 'valid-token-user');
|
|
1008
|
+
expect(res.statusCode).toEqual(403);
|
|
1009
|
+
expect(res.text).toEqual('file-access-not-allowed');
|
|
1010
|
+
});
|
|
1011
|
+
it("allows an admin to sync another user's file", async () => {
|
|
1012
|
+
const fileId = crypto.randomBytes(16).toString('hex');
|
|
1013
|
+
const groupId = 'group-id';
|
|
1014
|
+
const keyId = 'key-id';
|
|
1015
|
+
const syncVersion = 2;
|
|
1016
|
+
const encryptMeta = JSON.stringify({ keyId });
|
|
1017
|
+
addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion, OTHER_USER_ID);
|
|
1018
|
+
const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId);
|
|
1019
|
+
const res = await sendSyncRequest(syncRequest, 'valid-token-admin');
|
|
1020
|
+
expect(res.statusCode).toEqual(200);
|
|
1021
|
+
expect(res.headers['content-type']).toEqual('application/actual-sync');
|
|
1022
|
+
expect(res.headers['x-actual-sync-method']).toEqual('simple');
|
|
1023
|
+
});
|
|
694
1024
|
});
|
|
695
|
-
function addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion) {
|
|
696
|
-
getAccountDb().mutate('INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version, owner) VALUES (?, ?, ?,?, ?, ?)', [fileId, groupId, keyId, encryptMeta, syncVersion,
|
|
1025
|
+
function addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion, owner = 'genericAdmin') {
|
|
1026
|
+
getAccountDb().mutate('INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version, owner) VALUES (?, ?, ?,?, ?, ?)', [fileId, groupId, keyId, encryptMeta, syncVersion, owner]);
|
|
697
1027
|
}
|
|
698
1028
|
function createMinimalSyncRequest(fileId, groupId, keyId) {
|
|
699
1029
|
const syncRequest = new SyncProtoBuf.SyncRequest();
|
|
@@ -704,13 +1034,13 @@ function createMinimalSyncRequest(fileId, groupId, keyId) {
|
|
|
704
1034
|
syncRequest.setMessagesList([]);
|
|
705
1035
|
return syncRequest;
|
|
706
1036
|
}
|
|
707
|
-
async function sendSyncRequest(syncRequest) {
|
|
1037
|
+
async function sendSyncRequest(syncRequest, token = 'valid-token') {
|
|
708
1038
|
const serializedRequest = syncRequest.serializeBinary();
|
|
709
1039
|
// Convert Uint8Array to Buffer
|
|
710
1040
|
const bufferRequest = Buffer.from(serializedRequest);
|
|
711
1041
|
const res = await request(app)
|
|
712
1042
|
.post('/sync')
|
|
713
|
-
.set('x-actual-token',
|
|
1043
|
+
.set('x-actual-token', token)
|
|
714
1044
|
.set('Content-Type', 'application/actual-sync')
|
|
715
1045
|
.send(bufferRequest);
|
|
716
1046
|
return res;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actual-app/sync-server",
|
|
3
|
-
"version": "26.2.
|
|
3
|
+
"version": "26.2.1",
|
|
4
4
|
"description": "actual syncing server",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@actual-app/crdt": "2.1.0",
|
|
32
|
-
"@actual-app/web": "26.2.
|
|
32
|
+
"@actual-app/web": "26.2.1",
|
|
33
33
|
"bcrypt": "^6.0.0",
|
|
34
34
|
"better-sqlite3": "^12.5.0",
|
|
35
35
|
"convict": "^6.2.4",
|