Logo And Courses Broken On PHP FPM - edmozley/freeitsm GitHub Wiki

Logo and courses broke on Apache with PHP-FPM

Fixed after 3.1.0 ยท Reported in #115 by @mbsouth ยท Present in every release from 1.0.0 to 3.1.0 ยท Modules: System (branding), Self-Service, LMS, every upload folder


The short version

If you run FreeITSM on Apache with PHP-FPM (the usual production setup on Debian and Ubuntu), the logo you uploaded showed as a broken image, and so did the self-service portal's logo. LMS courses would not open either. Everything else worked.

The cause was one duplicated line in the small .htaccess settings files that sit in each upload folder. Apache with PHP-FPM does not understand that line, and when Apache meets a line it does not understand in one of these files, it refuses to serve anything in that folder.

The line has been removed. The protection it was meant to give is still there, written in a way every Apache setup understands. Your upload folders are no less safe than before, on any server.

If you run Apache with mod_php (WAMP, XAMPP, most LAMP installs, the official Docker image), nginx or IIS, you were never affected, and nothing changes for you.


What you saw

  • System โ†’ Branding: you uploaded a logo, it said it saved, and the preview turned into a broken image the moment you pressed Save. The header and the sign-in page showed a broken image too.
  • Self-service portal: its own logo did the same.
  • LMS: a course would not play.
  • Opening the broken image in its own tab gave Internal Server Error.

Apache's error log named the cause, once per request:

/var/www/html/system/uploads/branding/.htaccess: Invalid command 'php_flag',
perhaps misspelled or defined by a module not included in the server configuration

What was wrong, in plain English

Each upload folder has a hidden file called .htaccess: a short note to Apache with rules for that folder. The most important rule is "never run anything in here as a program". If someone ever managed to sneak a malicious file into an upload folder, it still couldn't run.

The note said the same thing twice:

php_flag engine off              # โ† said plainly
...
<IfModule mod_php.c>
  php_flag engine off            # โ† said again, "only if you understand this"
</IfModule>
  • The second copy is wrapped in <IfModule mod_php.c>, which means "only if you have mod_php loaded". A server that understands it obeys it; any other server skips it.
  • The first copy was not wrapped. A server running PHP through PHP-FPM doesn't have mod_php, so it doesn't understand php_flag, and Apache treats a command it doesn't know in an .htaccess as a broken configuration. Its answer to every request for every file in that folder, and every folder below it, is a 500.

So the line that was meant as extra protection did no protecting at all on PHP-FPM; it switched the folder off.

What was wrong, for people who know PHP-FPM

php_flag, php_value, php_admin_flag and php_admin_value are directives registered by mod_php (php_module, source file mod_php.c in PHP 8, mod_php7.c in PHP 7). With PHP-FPM behind mod_proxy_fcgi (or mod_fcgid, or plain CGI) mod_php is not loaded, so the directive is unknown. With AllowOverride permitting the file to be read, an unknown directive in an .htaccess is a fatal per-directory configuration error, logged at core:alert and answered with a 500. It applies to the whole directory subtree, so even a 404 becomes a 500.

The application root .htaccess already handled this correctly. Its php_value session.* lines are inside <IfModule mod_php.c>, with a comment saying exactly this, and FreeITSM ships a .user.ini for the FPM case. The upload folders never got the same treatment.

RemoveHandler / RemoveType are not the barrier under PHP-FPM, as the reporter rightly pointed out. On Debian/Ubuntu, PHP-FPM is wired in by conf-available/php8.x-fpm.conf as a SetHandler "proxy:unix:/run/php/php8.x-fpm.sock|fcgi://localhost" inside <FilesMatch ".+\.ph(ar|p|tml)$">. RemoveHandler only undoes AddHandler extension mappings, so it does not remove that. What actually stops a script running in an upload folder on every Apache is:

  1. The upload rules themselves. Uploads are checked against an extension and content-type allow-list (the branding logo accepts PNG and JPG only; SVG was removed), and stored under a random name with an extension FreeITSM chooses. A .php file does not get in.
  2. <FilesMatch "\.(php|phtml|php[0-9]|...)$"> Require all denied in every upload folder. Access control runs before Apache hands the request to a handler, so a script-like name is refused with a 403 before PHP-FPM is ever asked. This works the same under mod_php and PHP-FPM, and it is untouched by this fix.
  3. php_flag engine off, now only inside <IfModule mod_php.c> and <IfModule mod_php7.c>. A third layer for mod_php servers, where it means something.

Why not .user.ini with engine = Off, as the issue mentions? .user.ini is read for a script PHP-FPM has already been asked to run, and layer 2 already ensures it is never asked for anything in these folders. It would add a file without adding a defence.

It was 12 places and a repair, not one file

The issue named the branding folder. The same unguarded line was in nine shipped .htaccess files and in three templates that write new ones while FreeITSM runs:

Where Served to the browser directly? Effect on PHP-FPM
system/uploads/branding/ Yes, the logo ๐Ÿ”ด broken logo everywhere
system/uploads/branding/portal/ (written at run time) Yes, the portal logo ๐Ÿ”ด broken portal logo
lms/content/ Yes, SCORM course files ๐Ÿ”ด courses would not play
tickets/attachments/, change-management/attachments/, war-room/attachments/, uploads/documents/, uploads/asset-imports/, forms/images/, tests/ No: these are read through a PHP endpoint that checks permissions 500 instead of 403. Still refused, so no visible effect

The run-time copies matter for upgrades. Updating FreeITSM replaces the files it ships, but not the ones it wrote: one per ticket attachment folder, the portal logo's folder and per-form image folders. Those stay as they were written until something rewrites them. So the fix comes in two parts: the shipped files and templates are corrected, and System โ†’ Verify database repairs the older copies on disk.

The fix

  • The nine shipped files: the unguarded php_flag engine off is removed. Each already had the guarded copies, so mod_php servers behave exactly as before. lms/content/.htaccess had only the unguarded line, so it is wrapped rather than removed, and mod_php still switches PHP off there.
  • The three templates (uploadPrepareDir(), uploadPrepareWebServableDir(), lmsHardenContentDir()): the same change, so new folders are written correctly.
  • A repair for existing installs: htaccessGuardPhpDirectives() finds every PHP directive that is not inside an <IfModule> for mod_php and wraps it in <IfModule mod_php.c> and <IfModule mod_php7.c>. It wraps rather than deletes, so it can never change what a mod_php server does, even on a file somebody edited by hand. Verify database runs it over every upload folder and reports "Repaired N .htaccess file(s)". The two upload-folder helpers also run it on their own folder each time they are used. It is idempotent: a second run changes nothing.
  • A TRAP: line in every one of these files and templates, so nobody adds the line back to "tidy up". See Code traps.

How this differs from the reporter's edit. @mbsouth's version removed every php_flag from the branding file, including the guarded ones. That works on PHP-FPM, but takes the mod_php layer away from mod_php servers. This fix keeps it there and only removes the copy that was outside a guard. Storing the logo in the database as Base64, also suggested, would have fixed the logo alone, at the cost of either inlining it into every page or adding an endpoint to serve it; the portal logo and LMS courses had the same fault and needed this fix regardless.

# TRAP: php_flag / php_value only ever inside <IfModule mod_php*.c>. Unguarded,
# Apache running PHP through PHP-FPM does not know the command and refuses EVERY
# file in this folder with a 500 (GH #115).
<IfModule mod_php.c>
  php_flag engine off
</IfModule>
<IfModule mod_php7.c>
  php_flag engine off
</IfModule>

Files

File Change
๐ŸŸฅ system/uploads/branding/.htaccess and 7 more folders' .htaccess unguarded line removed, TRAP: added
๐ŸŸฅ lms/content/.htaccess unguarded line wrapped in <IfModule>
๐ŸŸฆ includes/uploads.php both templates fixed; new htaccessUnguardedPhpDirectives(), htaccessGuardPhpDirectives(), uploadRepairHtaccessFile(), uploadRepairHtaccessTree()
๐ŸŸฆ includes/lms_package.php template fixed; repairs an existing file
๐ŸŸฉ api/system/db_verify.php, includes/db_verify_modules.php Verify database repairs the upload folders, reported under System
๐ŸŸจ tests/htaccess-php-guard.php, .github/workflows/web-exposure.yml new test, run on every push

How it was proved

Two throwaway servers in Docker, each running the same FreeITSM code side by side:

  • PHP-FPM: Debian 12, Apache 2.4 with proxy_fcgi, php8.2-fpm wired in by Debian's own php8.2-fpm.conf, AllowOverride All, a real database, set up as a fresh install. This is the reporter's setup.
  • mod_php: the official php:8.4-apache image, as a control.

A logo was uploaded through System โ†’ Branding in the browser and was seen to break. Then the folders an older release writes at run time (the portal logo folder, a ticket attachment folder) were created by the 3.1.0 code itself, and the fix was applied over the top the way an update would be.

Request PHP-FPM before PHP-FPM after update after Verify database mod_php before / after
The uploaded logo 500 200 200 200 / 200
Portal logo (folder written at run time) 500 500 200 200 / 200
A planted shell.php in the branding folder 500 403, not run 403 403 / 403
The folder's .htaccess itself 500 403 403 403 / 403
A ticket attachment at its direct URL 500 500 403 403 / 403
A logo uploaded into a new folder (new template) n/a 200 200 n/a

The middle column is the reason for the repair: updating alone fixes the main logo, but not the portal logo. Verify database reported "Repaired 2 .htaccess file(s)", and a second run reported nothing. The mod_php column is the same before and after, which is the evidence that nobody else is affected.

The guarded copy really is in force on mod_php, which matters because it is now the only copy. Two checks on the PHP 8.4 mod_php server:

  • Which name the PHP 8 module answers to: <IfModule mod_php.c> and <IfModule php_module> both match; <IfModule mod_php8.c> does not, although Debian's own php8.x-fpm.conf tests for that name. (So a guard written as mod_php8.c would silently never apply.)
  • A folder holding only the guarded php_flag engine off served a .php file as plain text, not run. The same file in a folder without it printed PHP RAN. That is the control.

tests/htaccess-php-guard.php (53 checks) covers the scanner, every shipped .htaccess, what each template actually writes (including that PHP is still switched off under mod_php and script names are still refused), and the repair. Negative control: pointed at the files as they were in 3.1.0, it flags exactly the nine files on exactly the lines above, and none of the others.

Not tested: Apache 2.2, mod_fcgid, suPHP or plain CGI (each lacks mod_php, so each should have the same fault and get the same fix), and LiteSpeed, which reads .htaccess in its own way. nginx and IIS never read .htaccess, so they were never affected.

What this means for you

  • On Apache with PHP-FPM: update FreeITSM, then run System โ†’ Verify database. Your logo, the portal logo and LMS courses load again. If you edited an .htaccess by hand to work around this (as the reporter did), git pull may refuse to overwrite it: put it back with git checkout -- system/uploads/branding/.htaccess first. The fix makes your edit unnecessary.
  • On anything else: nothing changes. Verify database may still report repaired files: it wraps the old lines in a guard, which changes nothing on mod_php.

Related: Running on nginx ยท Installation ยท Code traps ยท Bugs resolved

โš ๏ธ **GitHub.com Fallback** โš ๏ธ