mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f77d333ca | ||
|
|
9966c024cb | ||
|
|
ea937d1009 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actual-app/api",
|
"name": "@actual-app/api",
|
||||||
"version": "26.2.0",
|
"version": "26.2.1",
|
||||||
"description": "An API for Actual",
|
"description": "An API for Actual",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actual-app/web",
|
"name": "@actual-app/web",
|
||||||
"version": "26.2.0",
|
"version": "26.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"files": [
|
"files": [
|
||||||
"build"
|
"build"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "desktop-electron",
|
"name": "desktop-electron",
|
||||||
"version": "26.2.0",
|
"version": "26.2.1",
|
||||||
"description": "A simple and powerful personal finance system",
|
"description": "A simple and powerful personal finance system",
|
||||||
"author": "Actual",
|
"author": "Actual",
|
||||||
"main": "build/desktop-electron/index.js",
|
"main": "build/desktop-electron/index.js",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getAccountDb } from '../src/account-db';
|
||||||
|
|
||||||
|
export const up = async function () {
|
||||||
|
const accountDb = getAccountDb();
|
||||||
|
|
||||||
|
const admin = accountDb.first(
|
||||||
|
'SELECT id FROM users WHERE role = ? ORDER BY id LIMIT 1',
|
||||||
|
['ADMIN'],
|
||||||
|
);
|
||||||
|
if (admin) {
|
||||||
|
accountDb.mutate('UPDATE files SET owner = ? WHERE owner IS NULL', [
|
||||||
|
admin.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = async function () {
|
||||||
|
// Cannot reliably restore NULL owner for backfilled rows; no-op.
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actual-app/sync-server",
|
"name": "@actual-app/sync-server",
|
||||||
"version": "26.2.0",
|
"version": "26.2.1",
|
||||||
"description": "actual syncing server",
|
"description": "actual syncing server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import express from 'express';
|
|||||||
|
|
||||||
import { handleError } from '../app-gocardless/util/handle-error';
|
import { handleError } from '../app-gocardless/util/handle-error';
|
||||||
import { SecretName, secretsService } from '../services/secrets-service';
|
import { SecretName, secretsService } from '../services/secrets-service';
|
||||||
import { requestLoggerMiddleware } from '../util/middlewares';
|
import {
|
||||||
|
requestLoggerMiddleware,
|
||||||
|
validateSessionMiddleware,
|
||||||
|
} from '../util/middlewares';
|
||||||
|
|
||||||
import { pluggyaiService } from './pluggyai-service';
|
import { pluggyaiService } from './pluggyai-service';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
export { app as handlers };
|
export { app as handlers };
|
||||||
app.use(express.json());
|
|
||||||
app.use(requestLoggerMiddleware);
|
app.use(requestLoggerMiddleware);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(validateSessionMiddleware);
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/status',
|
'/status',
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import express from 'express';
|
|||||||
|
|
||||||
import { handleError } from '../app-gocardless/util/handle-error';
|
import { handleError } from '../app-gocardless/util/handle-error';
|
||||||
import { SecretName, secretsService } from '../services/secrets-service';
|
import { SecretName, secretsService } from '../services/secrets-service';
|
||||||
import { requestLoggerMiddleware } from '../util/middlewares';
|
import {
|
||||||
|
requestLoggerMiddleware,
|
||||||
|
validateSessionMiddleware,
|
||||||
|
} from '../util/middlewares';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
export { app as handlers };
|
export { app as handlers };
|
||||||
app.use(express.json());
|
|
||||||
app.use(requestLoggerMiddleware);
|
app.use(requestLoggerMiddleware);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(validateSessionMiddleware);
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/status',
|
'/status',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { handlers as app } from './app-sync';
|
|||||||
import { getPathForUserFile } from './util/paths';
|
import { getPathForUserFile } from './util/paths';
|
||||||
|
|
||||||
const ADMIN_ROLE = 'ADMIN';
|
const ADMIN_ROLE = 'ADMIN';
|
||||||
|
const OTHER_USER_ID = 'otherUser';
|
||||||
|
|
||||||
const createUser = (userId, userName, role, owner = 0, enabled = 1) => {
|
const createUser = (userId, userName, role, owner = 0, enabled = 1) => {
|
||||||
getAccountDb().mutate(
|
getAccountDb().mutate(
|
||||||
@@ -70,6 +71,48 @@ describe('/user-get-key', () => {
|
|||||||
expect(res.statusCode).toEqual(400);
|
expect(res.statusCode).toEqual(400);
|
||||||
expect(res.text).toBe('file-not-found');
|
expect(res.text).toBe('file-not-found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 403 when non-owner gets encryption key', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[fileId, 'salt', 'key-id', 'test', OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/user-get-key')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.send({ fileId });
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to get encryption key for another user's file", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const encrypt_salt = 'salt';
|
||||||
|
const encrypt_keyid = 'key-id';
|
||||||
|
const encrypt_test = 'test';
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[fileId, encrypt_salt, encrypt_keyid, encrypt_test, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/user-get-key')
|
||||||
|
.set('x-actual-token', 'valid-token-admin')
|
||||||
|
.send({ fileId });
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
data: {
|
||||||
|
id: encrypt_keyid,
|
||||||
|
salt: encrypt_salt,
|
||||||
|
test: encrypt_test,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/user-create-key', () => {
|
describe('/user-create-key', () => {
|
||||||
@@ -91,7 +134,69 @@ describe('/user-create-key', () => {
|
|||||||
.send({ fileId: 'non-existent-file-id' });
|
.send({ fileId: 'non-existent-file-id' });
|
||||||
|
|
||||||
expect(res.statusCode).toEqual(400);
|
expect(res.statusCode).toEqual(400);
|
||||||
expect(res.text).toBe('file not found');
|
expect(res.text).toBe('file-not-found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when non-owner creates encryption key', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[fileId, 'old-salt', 'old-key', 'old-test', OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/user-create-key')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.send({
|
||||||
|
fileId,
|
||||||
|
keyId: 'new-key',
|
||||||
|
keySalt: 'new-salt',
|
||||||
|
testContent: 'new-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to create encryption key for another user's file", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const old_encrypt_salt = 'old-salt';
|
||||||
|
const old_encrypt_keyid = 'old-key';
|
||||||
|
const old_encrypt_test = 'old-test';
|
||||||
|
const encrypt_salt = 'new-salt';
|
||||||
|
const encrypt_keyid = 'new-key-id';
|
||||||
|
const encrypt_test = 'new-encrypt-test';
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
fileId,
|
||||||
|
old_encrypt_salt,
|
||||||
|
old_encrypt_keyid,
|
||||||
|
old_encrypt_test,
|
||||||
|
OTHER_USER_ID,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/user-create-key')
|
||||||
|
.set('x-actual-token', 'valid-token-admin')
|
||||||
|
.send({
|
||||||
|
fileId,
|
||||||
|
keyId: encrypt_keyid,
|
||||||
|
keySalt: encrypt_salt,
|
||||||
|
testContent: encrypt_test,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.body).toEqual({ status: 'ok' });
|
||||||
|
|
||||||
|
const rows = getAccountDb().all(
|
||||||
|
'SELECT encrypt_salt, encrypt_keyid, encrypt_test FROM files WHERE id = ?',
|
||||||
|
[fileId],
|
||||||
|
);
|
||||||
|
expect(rows[0].encrypt_salt).toEqual(encrypt_salt);
|
||||||
|
expect(rows[0].encrypt_keyid).toEqual(encrypt_keyid);
|
||||||
|
expect(rows[0].encrypt_test).toEqual(encrypt_test);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new encryption key for the file', async () => {
|
it('creates a new encryption key for the file', async () => {
|
||||||
@@ -185,6 +290,44 @@ describe('/reset-user-file', () => {
|
|||||||
expect(res.statusCode).toEqual(400);
|
expect(res.statusCode).toEqual(400);
|
||||||
expect(res.text).toBe('User or file not found');
|
expect(res.text).toBe('User or file not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 403 when non-owner resets another user file', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT OR IGNORE INTO files (id, deleted, owner) VALUES (?, FALSE, ?)',
|
||||||
|
[fileId, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/reset-user-file')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.send({ fileId });
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to reset another user's file", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const groupId = 'admin-reset-group-id';
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, group_id, deleted, owner) VALUES (?, ?, FALSE, ?)',
|
||||||
|
[fileId, groupId, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/reset-user-file')
|
||||||
|
.set('x-actual-token', 'valid-token-admin')
|
||||||
|
.send({ fileId });
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.body).toEqual({ status: 'ok' });
|
||||||
|
|
||||||
|
const rows = getAccountDb().all('SELECT group_id FROM files WHERE id = ?', [
|
||||||
|
fileId,
|
||||||
|
]);
|
||||||
|
expect(rows[0].group_id).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/upload-user-file', () => {
|
describe('/upload-user-file', () => {
|
||||||
@@ -230,6 +373,11 @@ describe('/upload-user-file', () => {
|
|||||||
const fileContentBuffer = Buffer.from(fileContent);
|
const fileContentBuffer = Buffer.from(fileContent);
|
||||||
const syncVersion = 2;
|
const syncVersion = 2;
|
||||||
const encryptMeta = JSON.stringify({ keyId: 'key-id' });
|
const encryptMeta = JSON.stringify({ keyId: 'key-id' });
|
||||||
|
onTestFinished(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getPathForUserFile(fileId));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
// Verify that the file does not exist before upload
|
// Verify that the file does not exist before upload
|
||||||
const rowsBefore = getAccountDb().all('SELECT * FROM files WHERE id = ?', [
|
const rowsBefore = getAccountDb().all('SELECT * FROM files WHERE id = ?', [
|
||||||
@@ -267,9 +415,6 @@ describe('/upload-user-file', () => {
|
|||||||
const filePath = getPathForUserFile(fileId);
|
const filePath = getPathForUserFile(fileId);
|
||||||
const writtenContent = await fs.promises.readFile(filePath, 'utf8');
|
const writtenContent = await fs.promises.readFile(filePath, 'utf8');
|
||||||
expect(writtenContent).toEqual(fileContent);
|
expect(writtenContent).toEqual(fileContent);
|
||||||
|
|
||||||
// Clean up the file
|
|
||||||
await fs.promises.unlink(filePath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uploads and updates an existing file successfully', async () => {
|
it('uploads and updates an existing file successfully', async () => {
|
||||||
@@ -287,6 +432,11 @@ describe('/upload-user-file', () => {
|
|||||||
keyId: oldKeyId,
|
keyId: oldKeyId,
|
||||||
sentinelValue: 1,
|
sentinelValue: 1,
|
||||||
}); //keep the same key, but change other things
|
}); //keep the same key, but change other things
|
||||||
|
onTestFinished(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getPathForUserFile(fileId));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
// Create the old file version
|
// Create the old file version
|
||||||
getAccountDb().mutate(
|
getAccountDb().mutate(
|
||||||
@@ -335,9 +485,6 @@ describe('/upload-user-file', () => {
|
|||||||
const filePath = getPathForUserFile(fileId);
|
const filePath = getPathForUserFile(fileId);
|
||||||
const writtenContent = await fs.promises.readFile(filePath, 'utf8');
|
const writtenContent = await fs.promises.readFile(filePath, 'utf8');
|
||||||
expect(writtenContent).toEqual(newFileContent);
|
expect(writtenContent).toEqual(newFileContent);
|
||||||
|
|
||||||
// Clean up the file
|
|
||||||
await fs.promises.unlink(filePath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 if the file is part of an old group', async () => {
|
it('returns 400 if the file is part of an old group', async () => {
|
||||||
@@ -397,6 +544,94 @@ describe('/upload-user-file', () => {
|
|||||||
expect(res.statusCode).toEqual(400);
|
expect(res.statusCode).toEqual(400);
|
||||||
expect(res.text).toEqual('file-has-new-key');
|
expect(res.text).toEqual('file-has-new-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 403 when non-owner overwrites another user file', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const groupId = 'group-id';
|
||||||
|
const keyId = 'key-id';
|
||||||
|
const syncVersion = 2;
|
||||||
|
fs.writeFileSync(getPathForUserFile(fileId), 'existing content');
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid, deleted, owner) VALUES (?, ?, ?, ?, ?, ?, 0, ?)',
|
||||||
|
[
|
||||||
|
fileId,
|
||||||
|
groupId,
|
||||||
|
syncVersion,
|
||||||
|
'existing.txt',
|
||||||
|
JSON.stringify({ keyId }),
|
||||||
|
keyId,
|
||||||
|
OTHER_USER_ID,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
onTestFinished(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getPathForUserFile(fileId));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/upload-user-file')
|
||||||
|
.set('Content-Type', 'application/encrypted-file')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.set('x-actual-file-id', fileId)
|
||||||
|
.set('x-actual-name', 'hacked.txt')
|
||||||
|
.set('x-actual-group-id', groupId)
|
||||||
|
.set('x-actual-format', syncVersion.toString())
|
||||||
|
.set('x-actual-encrypt-meta', JSON.stringify({ keyId }))
|
||||||
|
.send(Buffer.from('overwrite content'));
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to overwrite another user's file", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const groupId = 'admin-upload-group-id';
|
||||||
|
const keyId = 'key-id';
|
||||||
|
const syncVersion = 2;
|
||||||
|
const existingContent = 'existing content';
|
||||||
|
const newContent = 'admin overwrite content';
|
||||||
|
fs.writeFileSync(getPathForUserFile(fileId), existingContent);
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_keyid, deleted, owner) VALUES (?, ?, ?, ?, ?, ?, 0, ?)',
|
||||||
|
[
|
||||||
|
fileId,
|
||||||
|
groupId,
|
||||||
|
syncVersion,
|
||||||
|
'existing.txt',
|
||||||
|
JSON.stringify({ keyId }),
|
||||||
|
keyId,
|
||||||
|
OTHER_USER_ID,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
onTestFinished(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getPathForUserFile(fileId));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/upload-user-file')
|
||||||
|
.set('Content-Type', 'application/encrypted-file')
|
||||||
|
.set('x-actual-token', 'valid-token-admin')
|
||||||
|
.set('x-actual-file-id', fileId)
|
||||||
|
.set('x-actual-name', 'admin-renamed.txt')
|
||||||
|
.set('x-actual-group-id', groupId)
|
||||||
|
.set('x-actual-format', syncVersion.toString())
|
||||||
|
.set('x-actual-encrypt-meta', JSON.stringify({ keyId }))
|
||||||
|
.send(Buffer.from(newContent));
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.body).toEqual({ status: 'ok', groupId });
|
||||||
|
|
||||||
|
expect(fs.readFileSync(getPathForUserFile(fileId), 'utf8')).toEqual(
|
||||||
|
newContent,
|
||||||
|
);
|
||||||
|
const rows = getAccountDb().all('SELECT name FROM files WHERE id = ?', [
|
||||||
|
fileId,
|
||||||
|
]);
|
||||||
|
expect(rows[0].name).toEqual('admin-renamed.txt');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/download-user-file', () => {
|
describe('/download-user-file', () => {
|
||||||
@@ -473,6 +708,91 @@ describe('/download-user-file', () => {
|
|||||||
expect(res.body).toBeInstanceOf(Buffer);
|
expect(res.body).toBeInstanceOf(Buffer);
|
||||||
expect(res.body.toString('utf8')).toEqual(fileContent);
|
expect(res.body.toString('utf8')).toEqual(fileContent);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('access control', () => {
|
||||||
|
it('returns 403 when non-owner downloads another user file', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const fileContent = 'sensitive content';
|
||||||
|
fs.writeFileSync(getPathForUserFile(fileId), fileContent);
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)',
|
||||||
|
[fileId, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
onTestFinished(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getPathForUserFile(fileId));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/download-user-file')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.set('x-actual-file-id', fileId);
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to download another user's file", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const fileContent = 'admin-downloaded content';
|
||||||
|
fs.writeFileSync(getPathForUserFile(fileId), fileContent);
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)',
|
||||||
|
[fileId, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
onTestFinished(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getPathForUserFile(fileId));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/download-user-file')
|
||||||
|
.set('x-actual-token', 'valid-token-admin')
|
||||||
|
.set('x-actual-file-id', fileId);
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.body).toBeInstanceOf(Buffer);
|
||||||
|
expect(res.body.toString('utf8')).toEqual(fileContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows non-owner with user_access to download via requireFileAccess (UserService.countUserAccess > 0)', async () => {
|
||||||
|
// File owned by another user; access granted only via user_access row, not owner/admin.
|
||||||
|
// This exercises the requireFileAccess branch that uses UserService.countUserAccess.
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const fileContent = 'shared-user content';
|
||||||
|
fs.writeFileSync(getPathForUserFile(fileId), fileContent);
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, deleted, owner) VALUES (?, FALSE, ?)',
|
||||||
|
[fileId, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO user_access (file_id, user_id) VALUES (?, ?)',
|
||||||
|
[fileId, 'genericUser'],
|
||||||
|
);
|
||||||
|
onTestFinished(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getPathForUserFile(fileId));
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/download-user-file')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.set('x-actual-file-id', fileId);
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.headers).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
'content-disposition': `attachment;filename=${fileId}`,
|
||||||
|
'content-type': 'application/octet-stream',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.body).toBeInstanceOf(Buffer);
|
||||||
|
expect(res.body.toString('utf8')).toEqual(fileContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -495,7 +815,7 @@ describe('/update-user-filename', () => {
|
|||||||
.send({ fileId: 'non-existent-file-id', name: 'new-filename' });
|
.send({ fileId: 'non-existent-file-id', name: 'new-filename' });
|
||||||
|
|
||||||
expect(res.statusCode).toEqual(400);
|
expect(res.statusCode).toEqual(400);
|
||||||
expect(res.text).toBe('file not found');
|
expect(res.text).toBe('file-not-found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('successfully updates the filename', async () => {
|
it('successfully updates the filename', async () => {
|
||||||
@@ -524,6 +844,45 @@ describe('/update-user-filename', () => {
|
|||||||
|
|
||||||
expect(rows[0].name).toEqual(newName);
|
expect(rows[0].name).toEqual(newName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 403 when non-owner renames another user file', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)',
|
||||||
|
[fileId, 'original-name', OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/update-user-filename')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.send({ fileId, name: 'stolen' });
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to rename another user's file", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const originalName = 'original-name';
|
||||||
|
const newName = 'admin-renamed-file';
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)',
|
||||||
|
[fileId, originalName, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/update-user-filename')
|
||||||
|
.set('x-actual-token', 'valid-token-admin')
|
||||||
|
.send({ fileId, name: newName });
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.body).toEqual({ status: 'ok' });
|
||||||
|
|
||||||
|
const rows = getAccountDb().all('SELECT name FROM files WHERE id = ?', [
|
||||||
|
fileId,
|
||||||
|
]);
|
||||||
|
expect(rows[0].name).toEqual(newName);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/list-user-files', () => {
|
describe('/list-user-files', () => {
|
||||||
@@ -653,6 +1012,51 @@ describe('/get-user-file-info', () => {
|
|||||||
details: 'token-not-found',
|
details: 'token-not-found',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 403 when non-owner gets another user file info', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, group_id, name, deleted, owner) VALUES (?, ?, ?, FALSE, ?)',
|
||||||
|
[fileId, 'group-id', 'budget', OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/get-user-file-info')
|
||||||
|
.set('x-actual-token', 'valid-token-user')
|
||||||
|
.set('x-actual-file-id', fileId);
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to get another user's file info", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const groupId = 'admin-file-info-group';
|
||||||
|
const name = 'admin-info-file';
|
||||||
|
const encrypt_meta = JSON.stringify({ key: 'value' });
|
||||||
|
getAccountDb().mutate(
|
||||||
|
'INSERT INTO files (id, group_id, name, encrypt_meta, deleted, owner) VALUES (?, ?, ?, ?, 0, ?)',
|
||||||
|
[fileId, groupId, name, encrypt_meta, OTHER_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/get-user-file-info')
|
||||||
|
.set('x-actual-token', 'valid-token-admin')
|
||||||
|
.set('x-actual-file-id', fileId);
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
data: {
|
||||||
|
deleted: 0,
|
||||||
|
fileId,
|
||||||
|
groupId,
|
||||||
|
name,
|
||||||
|
encryptMeta: { key: 'value' },
|
||||||
|
usersWithAccess: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/delete-user-file', () => {
|
describe('/delete-user-file', () => {
|
||||||
@@ -733,11 +1137,7 @@ describe('/delete-user-file', () => {
|
|||||||
.send({ fileId });
|
.send({ fileId });
|
||||||
|
|
||||||
expect(res.statusCode).toEqual(403);
|
expect(res.statusCode).toEqual(403);
|
||||||
expect(res.body).toEqual({
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
status: 'error',
|
|
||||||
reason: 'forbidden',
|
|
||||||
details: 'file-delete-not-allowed',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify that the file is NOT deleted
|
// Verify that the file is NOT deleted
|
||||||
const rows = accountDb.all('SELECT deleted FROM files WHERE id = ?', [
|
const rows = accountDb.all('SELECT deleted FROM files WHERE id = ?', [
|
||||||
@@ -933,12 +1333,64 @@ describe('/sync', () => {
|
|||||||
expect(res.statusCode).toEqual(400);
|
expect(res.statusCode).toEqual(400);
|
||||||
expect(res.text).toEqual('file-has-new-key');
|
expect(res.text).toEqual('file-has-new-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 403 when non-owner syncs another user file', async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const groupId = 'group-id';
|
||||||
|
const keyId = 'key-id';
|
||||||
|
const syncVersion = 2;
|
||||||
|
const encryptMeta = JSON.stringify({ keyId });
|
||||||
|
addMockFile(
|
||||||
|
fileId,
|
||||||
|
groupId,
|
||||||
|
keyId,
|
||||||
|
encryptMeta,
|
||||||
|
syncVersion,
|
||||||
|
OTHER_USER_ID,
|
||||||
|
);
|
||||||
|
const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId);
|
||||||
|
|
||||||
|
const res = await sendSyncRequest(syncRequest, 'valid-token-user');
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(403);
|
||||||
|
expect(res.text).toEqual('file-access-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to sync another user's file", async () => {
|
||||||
|
const fileId = crypto.randomBytes(16).toString('hex');
|
||||||
|
const groupId = 'group-id';
|
||||||
|
const keyId = 'key-id';
|
||||||
|
const syncVersion = 2;
|
||||||
|
const encryptMeta = JSON.stringify({ keyId });
|
||||||
|
addMockFile(
|
||||||
|
fileId,
|
||||||
|
groupId,
|
||||||
|
keyId,
|
||||||
|
encryptMeta,
|
||||||
|
syncVersion,
|
||||||
|
OTHER_USER_ID,
|
||||||
|
);
|
||||||
|
const syncRequest = createMinimalSyncRequest(fileId, groupId, keyId);
|
||||||
|
|
||||||
|
const res = await sendSyncRequest(syncRequest, 'valid-token-admin');
|
||||||
|
|
||||||
|
expect(res.statusCode).toEqual(200);
|
||||||
|
expect(res.headers['content-type']).toEqual('application/actual-sync');
|
||||||
|
expect(res.headers['x-actual-sync-method']).toEqual('simple');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion) {
|
function addMockFile(
|
||||||
|
fileId: string,
|
||||||
|
groupId: string | null,
|
||||||
|
keyId: string,
|
||||||
|
encryptMeta: string,
|
||||||
|
syncVersion: number,
|
||||||
|
owner: string = 'genericAdmin',
|
||||||
|
) {
|
||||||
getAccountDb().mutate(
|
getAccountDb().mutate(
|
||||||
'INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version, owner) VALUES (?, ?, ?,?, ?, ?)',
|
'INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version, owner) VALUES (?, ?, ?,?, ?, ?)',
|
||||||
[fileId, groupId, keyId, encryptMeta, syncVersion, 'genericAdmin'],
|
[fileId, groupId, keyId, encryptMeta, syncVersion, owner],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,14 +1404,14 @@ function createMinimalSyncRequest(fileId, groupId, keyId) {
|
|||||||
return syncRequest;
|
return syncRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendSyncRequest(syncRequest) {
|
async function sendSyncRequest(syncRequest, token = 'valid-token') {
|
||||||
const serializedRequest = syncRequest.serializeBinary();
|
const serializedRequest = syncRequest.serializeBinary();
|
||||||
// Convert Uint8Array to Buffer
|
// Convert Uint8Array to Buffer
|
||||||
const bufferRequest = Buffer.from(serializedRequest);
|
const bufferRequest = Buffer.from(serializedRequest);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/sync')
|
.post('/sync')
|
||||||
.set('x-actual-token', 'valid-token')
|
.set('x-actual-token', token)
|
||||||
.set('Content-Type', 'application/actual-sync')
|
.set('Content-Type', 'application/actual-sync')
|
||||||
.send(bufferRequest);
|
.send(bufferRequest);
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
validateUploadedFile,
|
validateUploadedFile,
|
||||||
} from './app-sync/validation';
|
} from './app-sync/validation';
|
||||||
import { config } from './load-config';
|
import { config } from './load-config';
|
||||||
|
import * as UserService from './services/user-service';
|
||||||
import * as simpleSync from './sync-simple';
|
import * as simpleSync from './sync-simple';
|
||||||
import {
|
import {
|
||||||
errorMiddleware,
|
errorMiddleware,
|
||||||
@@ -68,6 +69,18 @@ const verifyFileExists = (fileId, filesService, res, errorObject) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function requireFileAccess(file: File, userId: string) {
|
||||||
|
const isOwner = file.owner === userId;
|
||||||
|
const isServerAdmin = isAdmin(userId);
|
||||||
|
if (isOwner || isServerAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (UserService.countUserAccess(file.id, userId) > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return 'file-access-not-allowed';
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/sync', async (req, res): Promise<void> => {
|
app.post('/sync', async (req, res): Promise<void> => {
|
||||||
let requestPb;
|
let requestPb;
|
||||||
try {
|
try {
|
||||||
@@ -107,6 +120,13 @@ app.post('/sync', async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileAccessError = requireFileAccess(currentFile, res.locals.user_id);
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = validateSyncedFile(groupId, keyId, currentFile);
|
const errorMessage = validateSyncedFile(groupId, keyId, currentFile);
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
@@ -138,6 +158,13 @@ app.post('/user-get-key', (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.send({
|
res.send({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
data: {
|
data: {
|
||||||
@@ -152,8 +179,16 @@ app.post('/user-create-key', (req, res) => {
|
|||||||
const { fileId, keyId, keySalt, testContent } = req.body || {};
|
const { fileId, keyId, keySalt, testContent } = req.body || {};
|
||||||
|
|
||||||
const filesService = new FilesService(getAccountDb());
|
const filesService = new FilesService(getAccountDb());
|
||||||
|
const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
|
||||||
|
|
||||||
if (!verifyFileExists(fileId, filesService, res, 'file not found')) {
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +219,13 @@ app.post('/reset-user-file', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const groupId = file.groupId;
|
const groupId = file.groupId;
|
||||||
|
|
||||||
filesService.update(fileId, new FileUpdate({ groupId: null }));
|
filesService.update(fileId, new FileUpdate({ groupId: null }));
|
||||||
@@ -237,6 +279,15 @@ app.post('/upload-user-file', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileAccessError = currentFile
|
||||||
|
? requireFileAccess(currentFile, res.locals.user_id)
|
||||||
|
: null;
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = validateUploadedFile(groupId, keyId, currentFile);
|
const errorMessage = validateUploadedFile(groupId, keyId, currentFile);
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
res.status(400).send(errorMessage);
|
res.status(400).send(errorMessage);
|
||||||
@@ -303,7 +354,21 @@ app.get('/download-user-file', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filesService = new FilesService(getAccountDb());
|
const filesService = new FilesService(getAccountDb());
|
||||||
if (!verifyFileExists(fileId, filesService, res, 'User or file not found')) {
|
const file = verifyFileExists(
|
||||||
|
fileId,
|
||||||
|
filesService,
|
||||||
|
res,
|
||||||
|
'User or file not found',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,8 +388,16 @@ app.post('/update-user-filename', (req, res) => {
|
|||||||
const { fileId, name } = req.body || {};
|
const { fileId, name } = req.body || {};
|
||||||
|
|
||||||
const filesService = new FilesService(getAccountDb());
|
const filesService = new FilesService(getAccountDb());
|
||||||
|
const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
|
||||||
|
|
||||||
if (!verifyFileExists(fileId, filesService, res, 'file not found')) {
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +448,13 @@ app.get('/get-user-file-info', (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
||||||
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
|
res.send(fileAccessError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.send({
|
res.send({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
data: {
|
data: {
|
||||||
@@ -409,18 +489,10 @@ app.post('/delete-user-file', (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has permission to delete the file
|
const fileAccessError = requireFileAccess(file, res.locals.user_id);
|
||||||
const { user_id: userId } = res.locals;
|
if (fileAccessError) {
|
||||||
|
res.status(403);
|
||||||
const isOwner = file.owner === userId;
|
res.send(fileAccessError);
|
||||||
const isServerAdmin = isAdmin(userId);
|
|
||||||
|
|
||||||
if (!isOwner && !isServerAdmin) {
|
|
||||||
res.status(403).send({
|
|
||||||
status: 'error',
|
|
||||||
reason: 'forbidden',
|
|
||||||
details: 'file-delete-not-allowed',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
upcoming-release-notes/7034.md
Normal file
6
upcoming-release-notes/7034.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Bugfixes
|
||||||
|
authors: [MatissJanis]
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix: simplefin and pluggy not requiring auth
|
||||||
6
upcoming-release-notes/7040.md
Normal file
6
upcoming-release-notes/7040.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Bugfixes
|
||||||
|
authors: [MatissJanis]
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix unauthorized file access in multi-user setups
|
||||||
Reference in New Issue
Block a user