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

  1. Fix the userFiles config so it points at the directory that actually contains the stored files, and restart the server.
  2. Ensure the fileId used is one stored under the current userFiles root (list files via the API to confirm).
  3. Avoid symlinks or relative paths in the userFiles config; use an absolute, canonical directory.
  4. 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

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.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/ef255a7244fddb7b. Report an issue: GitHub.