{"record":{"id":"ef255a7244fddb7b","repo":"actualbudget/actual","slug":"access-denied","errorCode":null,"errorMessage":"Access denied","messagePattern":"Access denied","errorType":"http","errorClass":null,"httpStatus":403,"severity":"error","filePath":"packages/sync-server/src/app-sync.ts","lineNumber":449,"sourceCode":"    'User or file not found',\n  );\n\n  if (!file) {\n    return;\n  }\n\n  const fileAccessError = requireFileAccess(file, res.locals.user_id);\n  if (fileAccessError) {\n    res.status(403);\n    res.send(fileAccessError);\n    return;\n  }\n\n  const path = getPathForUserFile(fileId);\n\n  if (!path.startsWith(resolve(config.get('userFiles')))) {\n    //Ensure the user doesn't try to access files outside of the user files directory\n    res.status(403).send('Access denied');\n    return;\n  }\n\n  res.setHeader('Content-Disposition', `attachment;filename=${fileId}`);\n  res.sendFile(path, { dotfiles: 'allow' });\n});\n\napp.post('/update-user-filename', (req, res) => {\n  const { fileId, name } = req.body || {};\n\n  const filesService = new FilesService(getAccountDb());\n  const file = verifyFileExists(fileId, filesService, res, 'file-not-found');\n\n  if (!file) {\n    return;\n  }\n\n  const fileAccessError = requireFileAccess(file, res.locals.user_id);","sourceCodeStart":431,"sourceCodeEnd":467,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/sync-server/src/app-sync.ts#L431-L467","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nuserFiles = \"user-files\"; // relative; resolves against CWD\n// after\nuserFiles = \"/data/actual/user-files\"; // absolute, matches where files live","handlingStrategy":"validation","validationCode":"import { resolve } from 'path';\nconst root = resolve(configUserFiles);\nconst target = resolve(root, 'files', fileId);\nif (!target.startsWith(root)) throw new Error('fileId resolves outside userFiles root');","typeGuard":"function isInsideUserFiles(fileId: string, root: string): boolean {\n  const resolved = resolve(root, fileId);\n  return resolved.startsWith(root + (root.endsWith('/') ? '' : '/'));\n}","tryCatchPattern":"const res = await downloadUserFile(fileId);\nif (res.status === 403) {\n  throw new Error('path outside userFiles: check userFiles config and fileId provenance');\n}","preventionTips":["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."],"tags":["security","path-traversal","configuration","http"],"backgroundTag":"path-traversal-blocked","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}