actualbudget/actual · error
Access denied
Error message
Access denied
What it means
As a defense-in-depth path check, the download endpoint verifies that getPathForUserFile(fileId) resolves inside resolve(config.get('userFiles')). If the resolved path escapes the configured user-files directory, the server returns 403 'Access denied'. It indicates the computed path does not live under the configured root.
Source
Thrown at packages/sync-server/src/app-sync.ts:449
'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;
}
const path = getPathForUserFile(fileId);
if (!path.startsWith(resolve(config.get('userFiles')))) {
//Ensure the user doesn't try to access files outside of the user files directory
res.status(403).send('Access denied');
return;
}
res.setHeader('Content-Disposition', `attachment;filename=${fileId}`);
res.sendFile(path, { dotfiles: 'allow' });
});
app.post('/update-user-filename', (req, res) => {
const { fileId, name } = req.body || {};
const filesService = new FilesService(getAccountDb());
const file = verifyFileExists(fileId, filesService, res, 'file-not-found');
if (!file) {
return;
}
const fileAccessError = requireFileAccess(file, res.locals.user_id);View on GitHub (pinned to d4334cb6e6)
Solutions
- Fix the userFiles config so it points at the directory that actually contains the stored files, and restart the server.
- Ensure the fileId used is one stored under the current userFiles root (list files via the API to confirm).
- Avoid symlinks or relative paths in the userFiles config; use an absolute, canonical directory.
- If you were probing with traversal-style ids, stop — the server intentionally blocks this.
Example fix
// before userFiles = "user-files"; // relative; resolves against CWD // after userFiles = "/data/actual/user-files"; // absolute, matches where files live
Defensive patterns
Strategy: validation
Validate before calling
import { resolve } from 'path';
const root = resolve(configUserFiles);
const target = resolve(root, 'files', fileId);
if (!target.startsWith(root)) throw new Error('fileId resolves outside userFiles root'); Type guard
function isInsideUserFiles(fileId: string, root: string): boolean {
const resolved = resolve(root, fileId);
return resolved.startsWith(root + (root.endsWith('/') ? '' : '/'));
} Try / catch
const res = await downloadUserFile(fileId);
if (res.status === 403) {
throw new Error('path outside userFiles: check userFiles config and fileId provenance');
} Prevention
- Use an absolute canonical path for the userFiles config; avoid relative paths and symlinks.
- After moving storage locations, update the userFiles env/config and restart.
- Never pass user-supplied paths, only server-issued fileIds.
When it happens
Trigger: A fileId that resolves outside the userFiles directory (e.g. path-traversal style id that passed other checks, or an id referencing a legacy/different storage root), or userFiles misconfigured so files resolve elsewhere.
Common situations: Changing/moving the ACTUAL_USER_FILES (userFiles) config after files were stored elsewhere; symlinks or relative-path configs like './user-files' resolving differently than expected; malicious probing of the endpoint.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- Invalid budget id "${id}". Check the id of your budget in th
- Unsafe zip entry name: ${name}
- Error loading data into the spreadsheet.
- Unsupported summary type
- Failed to fetch catalog: ${response.statusText}
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/ef255a7244fddb7b.
Report an issue: GitHub.