mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
* [AI] Fix privilege escalation in sync-server /change-password and getLoginMethod Made-with: Cursor * Update upcoming-release-notes/7155.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix privilege escalation issue in change-password endpoint --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
310 lines
8.9 KiB
JavaScript
310 lines
8.9 KiB
JavaScript
import request from 'supertest';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
import { getAccountDb, getLoginMethod, getServerPrefs } from './account-db';
|
|
import { bootstrapPassword } from './accounts/password';
|
|
import { handlers as app } from './app-account';
|
|
|
|
const ADMIN_ROLE = 'ADMIN';
|
|
const BASIC_ROLE = 'BASIC';
|
|
|
|
// Create user helper function
|
|
const createUser = (userId, userName, role, owner = 0, enabled = 1) => {
|
|
getAccountDb().mutate(
|
|
'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)',
|
|
[userId, userName, `${userName} display`, enabled, owner, role],
|
|
);
|
|
};
|
|
|
|
const deleteUser = userId => {
|
|
getAccountDb().mutate('DELETE FROM user_access WHERE user_id = ?', [userId]);
|
|
getAccountDb().mutate('DELETE FROM users WHERE id = ?', [userId]);
|
|
};
|
|
|
|
const createSession = (userId, sessionToken) => {
|
|
getAccountDb().mutate(
|
|
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
|
|
[sessionToken, userId, Math.floor(Date.now() / 1000) + 60 * 60], // Expire in 1 hour (stored in seconds)
|
|
);
|
|
};
|
|
|
|
const generateSessionToken = () => `token-${uuidv4()}`;
|
|
|
|
const clearServerPrefs = () => {
|
|
getAccountDb().mutate('DELETE FROM server_prefs');
|
|
};
|
|
|
|
const insertAuthRow = (method, active, extraData = null) => {
|
|
getAccountDb().mutate(
|
|
'INSERT INTO auth (method, display_name, extra_data, active) VALUES (?, ?, ?, ?)',
|
|
[method, method, extraData, active],
|
|
);
|
|
};
|
|
|
|
const clearAuth = () => {
|
|
getAccountDb().mutate('DELETE FROM auth');
|
|
};
|
|
|
|
describe('/change-password', () => {
|
|
let userId, sessionToken;
|
|
|
|
beforeEach(() => {
|
|
userId = uuidv4();
|
|
sessionToken = generateSessionToken();
|
|
createUser(userId, 'testuser', ADMIN_ROLE);
|
|
createSession(userId, sessionToken);
|
|
});
|
|
|
|
afterEach(() => {
|
|
deleteUser(userId);
|
|
clearAuth();
|
|
});
|
|
|
|
it('should return 401 if no session token is provided', async () => {
|
|
const res = await request(app).post('/change-password').send({
|
|
password: 'newpassword',
|
|
});
|
|
|
|
expect(res.statusCode).toEqual(401);
|
|
expect(res.body).toHaveProperty('status', 'error');
|
|
expect(res.body).toHaveProperty('reason', 'unauthorized');
|
|
});
|
|
|
|
it('should return 403 when active auth method is openid', async () => {
|
|
insertAuthRow('openid', 1);
|
|
|
|
const res = await request(app)
|
|
.post('/change-password')
|
|
.set('x-actual-token', sessionToken)
|
|
.send({ password: 'newpassword' });
|
|
|
|
expect(res.statusCode).toEqual(403);
|
|
expect(res.body).toEqual({
|
|
status: 'error',
|
|
reason: 'forbidden',
|
|
details: 'password-auth-not-active',
|
|
});
|
|
});
|
|
|
|
it('should return 400 when active method is password but password is empty', async () => {
|
|
bootstrapPassword('oldpassword');
|
|
|
|
const res = await request(app)
|
|
.post('/change-password')
|
|
.set('x-actual-token', sessionToken)
|
|
.send({ password: '' });
|
|
|
|
expect(res.statusCode).toEqual(400);
|
|
expect(res.body).toEqual({ status: 'error', reason: 'invalid-password' });
|
|
});
|
|
|
|
it('should return 200 when active method is password and new password is valid', async () => {
|
|
bootstrapPassword('oldpassword');
|
|
|
|
const res = await request(app)
|
|
.post('/change-password')
|
|
.set('x-actual-token', sessionToken)
|
|
.send({ password: 'newpassword' });
|
|
|
|
expect(res.statusCode).toEqual(200);
|
|
expect(res.body).toEqual({ status: 'ok', data: {} });
|
|
});
|
|
});
|
|
|
|
describe('getLoginMethod()', () => {
|
|
afterEach(() => {
|
|
clearAuth();
|
|
});
|
|
|
|
it('returns the active DB method when no req is provided', () => {
|
|
insertAuthRow('password', 1);
|
|
expect(getLoginMethod(undefined)).toBe('password');
|
|
});
|
|
|
|
it('honors a client-requested method when it is active in DB', () => {
|
|
insertAuthRow('openid', 1);
|
|
const req = { body: { loginMethod: 'openid' } };
|
|
expect(getLoginMethod(req)).toBe('openid');
|
|
});
|
|
|
|
it('ignores a client-requested method that is inactive in DB', () => {
|
|
insertAuthRow('openid', 1);
|
|
insertAuthRow('password', 0);
|
|
const req = { body: { loginMethod: 'password' } };
|
|
expect(getLoginMethod(req)).toBe('openid');
|
|
});
|
|
|
|
it('ignores a client-requested method that is not in DB', () => {
|
|
insertAuthRow('openid', 1);
|
|
const req = { body: { loginMethod: 'password' } };
|
|
expect(getLoginMethod(req)).toBe('openid');
|
|
});
|
|
|
|
it('falls back to config default when auth table is empty and no req', () => {
|
|
// auth table is empty — getActiveLoginMethod() returns undefined
|
|
// config default for loginMethod is 'password'
|
|
expect(getLoginMethod(undefined)).toBe('password');
|
|
});
|
|
});
|
|
|
|
describe('/server-prefs', () => {
|
|
describe('POST /server-prefs', () => {
|
|
let adminUserId, basicUserId, adminSessionToken, basicSessionToken;
|
|
|
|
beforeEach(() => {
|
|
adminUserId = uuidv4();
|
|
basicUserId = uuidv4();
|
|
adminSessionToken = generateSessionToken();
|
|
basicSessionToken = generateSessionToken();
|
|
|
|
createUser(adminUserId, 'admin', ADMIN_ROLE);
|
|
createUser(basicUserId, 'user', BASIC_ROLE);
|
|
createSession(adminUserId, adminSessionToken);
|
|
createSession(basicUserId, basicSessionToken);
|
|
});
|
|
|
|
afterEach(() => {
|
|
deleteUser(adminUserId);
|
|
deleteUser(basicUserId);
|
|
clearServerPrefs();
|
|
});
|
|
|
|
it('should return 401 if no session token is provided', async () => {
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.send({
|
|
prefs: { 'flags.plugins': 'true' },
|
|
});
|
|
|
|
expect(res.statusCode).toEqual(401);
|
|
expect(res.body).toHaveProperty('status', 'error');
|
|
expect(res.body).toHaveProperty('reason', 'unauthorized');
|
|
});
|
|
|
|
it('should return 403 if user is not an admin', async () => {
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.set('x-actual-token', basicSessionToken)
|
|
.send({
|
|
prefs: { 'flags.plugins': 'true' },
|
|
});
|
|
|
|
expect(res.statusCode).toEqual(403);
|
|
expect(res.body).toEqual({
|
|
status: 'error',
|
|
reason: 'forbidden',
|
|
details: 'permission-not-found',
|
|
});
|
|
});
|
|
|
|
it('should return 400 if prefs is not an object', async () => {
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.set('x-actual-token', adminSessionToken)
|
|
.send({
|
|
prefs: 'invalid',
|
|
});
|
|
|
|
expect(res.statusCode).toEqual(400);
|
|
expect(res.body).toEqual({
|
|
status: 'error',
|
|
reason: 'invalid-prefs',
|
|
});
|
|
});
|
|
|
|
it('should return 400 if prefs is missing', async () => {
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.set('x-actual-token', adminSessionToken)
|
|
.send({});
|
|
|
|
expect(res.statusCode).toEqual(400);
|
|
expect(res.body).toEqual({
|
|
status: 'error',
|
|
reason: 'invalid-prefs',
|
|
});
|
|
});
|
|
|
|
it('should return 400 if prefs is null', async () => {
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.set('x-actual-token', adminSessionToken)
|
|
.send({
|
|
prefs: null,
|
|
});
|
|
|
|
expect(res.statusCode).toEqual(400);
|
|
expect(res.body).toEqual({
|
|
status: 'error',
|
|
reason: 'invalid-prefs',
|
|
});
|
|
});
|
|
|
|
it('should return 200 and save server preferences for admin user', async () => {
|
|
const prefs = { 'flags.plugins': 'true' };
|
|
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.set('x-actual-token', adminSessionToken)
|
|
.send({ prefs });
|
|
|
|
expect(res.statusCode).toEqual(200);
|
|
expect(res.body).toEqual({
|
|
status: 'ok',
|
|
data: {},
|
|
});
|
|
|
|
// Verify that preferences were saved
|
|
const savedPrefs = getServerPrefs();
|
|
expect(savedPrefs).toEqual(prefs);
|
|
});
|
|
|
|
it('should update existing server preferences', async () => {
|
|
// First, set initial preferences
|
|
getAccountDb().mutate(
|
|
'INSERT INTO server_prefs (key, value) VALUES (?, ?)',
|
|
['flags.plugins', 'false'],
|
|
);
|
|
|
|
// Update preferences
|
|
const updatedPrefs = { 'flags.plugins': 'true' };
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.set('x-actual-token', adminSessionToken)
|
|
.send({ prefs: updatedPrefs });
|
|
|
|
expect(res.statusCode).toEqual(200);
|
|
expect(res.body).toEqual({
|
|
status: 'ok',
|
|
data: {},
|
|
});
|
|
|
|
// Verify that preferences were updated
|
|
const savedPrefs = getServerPrefs();
|
|
expect(savedPrefs).toEqual(updatedPrefs);
|
|
});
|
|
|
|
it('should save multiple server preferences', async () => {
|
|
const prefs = {
|
|
'flags.plugins': 'true',
|
|
anotherKey: 'anotherValue',
|
|
};
|
|
|
|
const res = await request(app)
|
|
.post('/server-prefs')
|
|
.set('x-actual-token', adminSessionToken)
|
|
.send({ prefs });
|
|
|
|
expect(res.statusCode).toEqual(200);
|
|
expect(res.body).toEqual({
|
|
status: 'ok',
|
|
data: {},
|
|
});
|
|
|
|
// Verify that all preferences were saved
|
|
const savedPrefs = getServerPrefs();
|
|
expect(savedPrefs).toEqual(prefs);
|
|
});
|
|
});
|
|
});
|