VirtualHost dla Joomla — konfiguracja Apache z twardym zabezpieczeniem
Domyślny VirtualHost dla Joomla ma jedną wspólną wadę: uruchamia PHP wszędzie, gdzie akurat wyląduje plik, i dzieli tego samego użytkownika systemowego między wszystkie strony na serwerze. Wystarczy jedno dziurawe rozszerzenie, żeby webshell z jednej witryny przeczytał configuration.php sąsiada. Ten artykuł pokazuje kompletną konfigurację VirtualHost dla Joomla: osobną pulę PHP-FPM na stronę, reguły Apache blokujące wykonanie PHP w każdym katalogu zapisywalnym Joomli i te sztuczki z rozszerzeniami plików, na które hardening „na pół gwizdka" po prostu nie jest odporny. Na końcu masz konfigurację, którą wklejasz, testujesz kilkoma poleceniami curl i której możesz zaufać.
Generator VirtualHost dla Joomla — co robi i komu się przyda
Generator VirtualHost dla Joomla na vps-web.com zamienia sześć pól formularza w gotowy do wklejenia config Apache: domenę, opcjonalny alias subdomeny, wersję PHP-FPM, dedykowaną nazwę użytkownika systemowego, opcję certyfikatu Let's Encrypt oraz osobne logi per strona zamiast wspólnego loga Apache. Po wysłaniu formularza dostajesz cały plik sites-available/*.conf razem z poleceniami tworzącymi użytkownika systemowego, pulę PHP-FPM i zestaw komend weryfikujących — wszystko to, co przy ręcznej konfiguracji zwykle rozsiane jest po czterech czy pięciu różnych poradnikach.
Generator jest pomyślany dla adminów stawiających Joomlę na własnym VPS albo dedykowanym serwerze, nie na współdzielonym hostingu z cPanelem — dla kogoś, kto sam odpala apache2ctl configtest i nie chce, żeby cała izolacja opierała się wyłącznie na .htaccess samej Joomli. Jeśli utrzymujesz więcej niż jedną instalację Joomla na tym samym serwerze, to dokładnie ten scenariusz, pod który generator jest zbudowany — każda strona dostaje własnego użytkownika linuksowego, własny socket PHP-FPM i własny open_basedir, więc przejęte rozszerzenie na jednej stronie nie czyta plików sąsiedniej.
Wypełnienie formularza i wklejenie outputu jest szybsze niż składanie tej samej konfiguracji ręcznie z kilku odpowiedzi na Stack Exchange — i oszczędza dwóch błędów, które regularnie wracają w realnych poradnikach hardeningu Joomli: zapomnienia o zablokowaniu configuration.php po nazwie oraz zostawienia katalogów zapisywalnych (images, media, tmp, cache) zdolnych do wykonania PHP.
Praktyka — zabezpieczenie VirtualHost dla Joomla krok po kroku
Konfiguracja poniżej zakłada Ubuntu lub Debian z Apache 2.4, PHP-FPM i instalacją Joomla 5 lub 6 pod /var/www/. Każdy krok ma znaczenie — pomiń bloki FilesMatch na poziomie katalogów, a izolacja PHP-FPM przestaje cokolwiek znaczyć.
Krok 1 — dedykowany użytkownik systemowy dla strony
Joomla, jak każdy CMS w PHP, jest izolowana dokładnie na tyle, na ile izolowany jest proces, który ją uruchamia. Jeśli każda strona na serwerze dzieli tę samą pulę FPM www-data, jedno podatne rozszerzenie daje atakującemu dostęp do odczytu configuration.php każdej innej strony — nie tylko tej, do której się włamał. Rozwiązaniem jest dedykowane konto systemowe dla każdej domeny.
# tworzymy konto systemowe bez powłoki i bez zawartości katalogu domowego
useradd -r -M -d /var/www/example.com -s /usr/sbin/nologin exampleuser
install -d -o exampleuser -g exampleuser /var/www/example.com/tmp
chown -R exampleuser:exampleuser /var/www/example.com/public_html
-r oznacza konto systemowe, -M pomija tworzenie katalogu domowego, bo Joomla go nie potrzebuje, a /usr/sbin/nologin sprawia, że nikt się tym userem nie zaloguje przez SSH, nawet gdyby hasło kiedyś wyciekło. Pomiń useradd, jeśli świadomie uruchamiasz stronę pod istniejącym www-data — ale miej świadomość, że ten wybór cofa Cię do modelu wspólnej puli, od którego cały ten artykuł stara się odejść.
Krok 2 — pula PHP-FPM dedykowana dla strony
; /etc/php/8.5/fpm/pool.d/example.com.conf
[example.com]
user = exampleuser
group = exampleuser
listen = /run/php/example.com.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
; zamyka PHP w katalogu tej strony - webshell tutaj nie przeczyta
; configuration.php sasiedniej witryny
php_admin_value[open_basedir] = /var/www/example.com/:/tmp/
php_admin_value[upload_tmp_dir] = /var/www/example.com/tmp
php_admin_value[session.save_path] = /var/www/example.com/tmp
; neutralizuje .user.ini jako wektor persystencji - podrzucony
; do katalogu zapisywalnego plik nie zostanie wczytany przez PHP
php_admin_value[user_ini.filename] =
; scina funkcje, na ktorych opiera sie wiekszosc webshelli
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,proc_nice,proc_terminate,pcntl_exec,dl
php_admin_flag[allow_url_include] = off
open_basedir odwala tu największą robotę — dokumentacja bezpieczeństwa Joomli zaleca ją wprost, i to jedno ustawienie zamienia „jedno przejęte rozszerzenie" w „jedną przejętą stronę", a nie cały serwer. disable_functions zdejmuje prymitywy do uruchamiania powłoki, które zamieniają dowolny upload w zdalne wykonanie kodu; jeśli konkretne rozszerzenie naprawdę potrzebuje jednej z tych funkcji — rzadkość, i warto to zakwestionować — poluzuj tylko tę jedną funkcję zamiast zdejmować całą listę. allow_url_fopen zostaw włączone, bo update checker Joomli i kilka podstawowych rozszerzeń tego wymagają — ruch wychodzący kontroluj raczej na firewallu, jeśli martwi Cię SSRF.
systemctl reload php8.5-fpm
Krok 3 — sam VirtualHost
To plik, który spina socket PHP-FPM z domeną i dokłada na to właściwy hardening po stronie Apache.
# /etc/apache2/sites-available/example.com.conf
<VirtualHost *:80>
ServerName example.com
Alias /.well-known/acme-challenge/ /var/lib/letsencrypt/.well-known/acme-challenge/
<Directory "/var/lib/letsencrypt/">
Require all granted
</Directory>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
RewriteRule ^ https://example.com%{REQUEST_URI} [L,R=301]
</VirtualHost>
<VirtualHost *:443>
ServerAdmin root@example.com
ServerName example.com
Protocols h2 http/1.1
DocumentRoot /var/www/example.com/public_html
<Directory "/var/www/example.com/public_html">
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# ============================ HARDENING ============================
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/example.com.sock|fcgi://localhost"
</FilesMatch>
<FilesMatch "\.(phtml|pht|phps|phar|php[0-9])$">
Require all denied
</FilesMatch>
<FilesMatch "(^\.ht|^\.user\.ini$|configuration\.php|\.(sql|sqlite|log|bak|swp|inc)$)">
Require all denied
</FilesMatch>
<DirectoryMatch "/\.(git|svn)(/|$)">
Require all denied
</DirectoryMatch>
<Directory "/var/www/example.com/public_html/images">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/media">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/tmp">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/cache">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/files">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/administrator/cache">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/administrator/logs">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
# ==================================================================
ErrorLog ${APACHE_LOG_DIR}/example.com-error.log
CustomLog ${APACHE_LOG_DIR}/example.com-access.log combined
SSLEngine On
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLHonorCipherOrder off
</VirtualHost>
Kilka dyrektyw zasługuje na osobne słowo. SetHandler "proxy:unix:...|fcgi://localhost" kieruje żądania .php do dedykowanego socketu FPM tej strony zamiast do wspólnej puli — pomyłka w ścieżce socketu oznacza, że PHP po prostu nie wykona się wcale, co jest bezpieczniejszym trybem awarii niż przypadkowe trafienie w cudzą pulę. Drugi blok FilesMatch zamyka klasyczny obchód: atakujący, który nie może wgrać shell.php, bo jest zablokowany gdzie indziej, często próbuje shell.phtml albo shell.php5, licząc na config, który pomyślał tylko o jednym rozszerzeniu. configuration\.php w bloku plików wrażliwych to joomlowy odpowiednik wp-config.php z WordPressa — trzyma dane do bazy w czystym tekście i wymaga tego samego traktowania. DirectoryMatch na .git i .svn istnieje, bo wdrożenie przez git clone albo git pull bezpośrednio do public_html zdarza się na tyle często, że zostawienie metadanych repozytorium dostępnych publicznie to nie wyjątek, tylko stała pozycja na liście błędów — atakujący dostaje pełną historię commitów razem z każdym sekretem, który kiedykolwiek został commitnięty i „usunięty".
Potem jest siedem bloków <Directory> — images, media, tmp, cache, files, administrator/cache, administrator/logs. To nie są przypadkowe ścieżki — to katalogi, do których Joomla sama zapisuje, a według oficjalnej dokumentacji Joomli katalogów images i media praktycznie nie da się przenieść, bo zbyt wiele rozszerzeń trzecich ma tę ścieżkę wpisaną na sztywno. Skoro nie można ich wynieść poza katalog webowy, jedyną realną obroną jest upewnienie się, że nic w środku nie wykona się jako PHP — nałożone na to AllowOverride None oznacza, że przejęty .htaccess podrzucony do jednego z tych folderów nie włączy z powrotem tego, co blok wyżej właśnie zablokował.
Krok 4 — SSL przez Let's Encrypt
Jeśli terminujesz TLS bezpośrednio na tym Apache, a nie za reverse proxy, poproś o certyfikat pluginem webroot certbota, zanim wejdzie blok :443 — challenge musi najpierw przejść po zwykłym HTTP.
certbot certonly --agree-tos --email root@example.com --webroot -w /var/lib/letsencrypt/ \
-d example.com
certonly wydaje certyfikat bez pozwalania certbotowi automatycznie przepisać Twój VirtualHost — przydatne, gdy masz już ręcznie złożony, zahardenowany config, którego nie chcesz, żeby wtyczka po cichu modyfikowała. --webroot obsługuje wyzwanie ACME HTTP-01 przez blok Alias zdefiniowany w VirtualHost na porcie 80 wyżej, i właśnie dlatego ten alias musi być osiągalny, zanim zadziała reguła przekierowania.
Krok 5 — włączenie strony i weryfikacja
a2ensite example.com
apache2ctl configtest && systemctl reload apache2
a2ensite tworzy dowiązanie symboliczne z sites-available do sites-enabled. configtest łapie błędy składni, zanim padnie na nich cały vhost, a reload — nie restart — wczytuje config na nowo bez zrywania trwających połączeń.
# --- testy hardeningu: kazdy ma zwrocic 403 ---
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/images/x.php # 403 (brak PHP w images)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/x.phtml # 403 (alt-rozszerzenie)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/configuration.php # 403 (plik wrazliwy)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.git/HEAD # 403 (brak dostepu do vcs)
curl -sI http://example.com/ | grep -i location # 301 -> https
# --- strona zyje, a PHP-FPM dziala jako dedykowany user ---
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/ # 200
ps -o user=,args= -C php-fpm8.5 | grep -v grep | grep exampleuser # worker jako exampleuser
Jeśli którykolwiek z czterech testów hardeningu zwróci coś innego niż 403 — a zwłaszcza 200 — zatrzymaj się i wróć do odpowiedniego bloku FilesMatch albo Directory, zanim uznasz stronę za gotową do produkcji. 404 zamiast 403 na configuration.php zwykle oznacza tylko, że Joomla stoi w innej ścieżce niż testowałeś — to nie jest zaliczony test.
Częste błędy i pułapki
Jednoliniowy <FilesMatch> wywala apache2ctl configtest
Część starszych poradników hardeningu Joomli pokazuje <FilesMatch "..."> Require all denied </FilesMatch> zapisane w jednej linii wewnątrz bloku <Directory>. Na aktualnych buildach Apache to rzuca błędem składni podczas apache2ctl configtest, zamiast po cichu zadziałać. Zawsze używaj formy trzylinijkowej — tag otwierający, dyrektywa, tag zamykający — wewnątrz bloków <Directory>.
Niezablokowany configuration.php
Łatwo skopiować poradnik hardeningu pisany pod WordPressa i zapomnieć, że wrażliwy plik Joomli nie nazywa się wp-config.php. Jeśli wzorzec plików wrażliwych w FilesMatch nadal odwołuje się do wp-config\.php zamiast configuration\.php, dane do bazy Joomli są o jedno zapytanie curl od każdego, kto wie, że ten plik istnieje.
Wspólny www-data dla kilku instalacji Joomla
Uruchamianie PHP każdej strony pod tą samą pulą FPM www-data to pojedyncza rzecz, która najczęściej zamienia incydent na jednej stronie w incydent na całym serwerze. Jeśli webshell wyląduje na instalacji Joomla działającej jako www-data, może potencjalnie czytać każdą inną stronę na serwerze, która też jest skonfigurowana pod www-data — open_basedir per-site nic nie da, jeśli sama pula nie jest dedykowana.
Brak AllowOverride None na katalogach zapisywalnych
Ustawienie AllowOverride All w katalogu głównym, którego wymaga Joomla dla adresów SEF opartych na .htaccess, i zatrzymanie się na tym, zostawia każdy podkatalog dziedziczący tę samą swobodę. Jeśli atakującemu uda się zapisać .htaccess w images/ albo tmp/, może potencjalnie z powrotem włączyć wykonanie PHP, które górny blok FilesMatch właśnie zablokował. Ustaw AllowOverride None jawnie na każdym katalogu zapisywalnym.
Wyzwanie ACME zablokowane, zanim powstanie certyfikat
Zbudowanie najpierw bloku :443 i reguł hardeningu, a dopiero potem próba odpalenia certbota, to częsta pomyłka w kolejności. Jeśli VirtualHost na porcie 80 z aliasem .well-known/acme-challenge nie jest jeszcze aktywny i osiągalny, wyzwanie HTTP-01 pada, a certbot się poddaje. Najpierw uruchom zwykły vhost HTTP, zdobądź certyfikat, potem dopisz dyrektywy SSL.
Zbyt agresywny disable_functions psuje działające rozszerzenie
Lista funkcji nastawionych na RCE wyżej — exec, shell_exec, proc_open i podobne — jest bezpieczna dla standardowej instalacji Joomli, ale niektóre rozszerzenia, zwłaszcza te wywołujące ImageMagick, wkhtmltopdf albo narzędzie do backupu, celowo korzystają z jednej z nich. Jeśli rozszerzenie sypie niejasnym PHP Fatal error: Uncaught Error: Call to undefined function, sprawdź, czy nie wywołuje właśnie zablokowanej funkcji, zanim uznasz je za zepsute.
Zapomniany reload PHP-FPM po zmianie w puli
Edycja pliku puli w pool.d/ i przeładowanie Apache nic nie robi — Apache nigdy nie czyta tego pliku bezpośrednio. Potrzebny jest systemctl reload php8.5-fpm (z poprawnym numerem wersji), żeby nowe ustawienia puli zaczęły działać — inaczej debugujesz config, który „powinien" działać.
Katalog .git wdrożony wprost do public_html
Jeśli proces wdrożenia to git clone albo git pull bezpośrednio w katalogu webowym zamiast kroku build-and-copy, katalog .git jedzie razem z resztą. Bez bloku DirectoryMatch wyżej jest w pełni pobieralny, co ujawnia historię commitów i wszystko, co kiedykolwiek do niej trafiło.
FAQ — najczęściej zadawane pytania
Czy Joomla potrzebuje własnego, zahardenowanego VirtualHost, czy domyślny Apache wystarczy?
Domyślny VirtualHost Apache uruchamia PHP wszędzie, gdzie wyląduje plik .php, i dzieli jedną pulę FPM między wszystkie strony na serwerze. Powierzchnia ataku samej Joomli — rozszerzenia trzecie o bardzo różnej jakości kodu — sprawia, że to połączenie robi się ryzykowne, gdy tylko hostujesz więcej niż jedną zaufaną stronę albo jakąkolwiek stronę na serwerze współdzielonym z innymi.
Czemu Joomla używa configuration.php zamiast pliku .env?
Joomla powstała przed konwencją .env, którą później spopularyzowały frameworki typu Laravel, i zachowała configuration.php dla kompatybilności wstecznej między wersjami głównymi. To zwykła klasa PHP zawierająca dane do bazy w czystym tekście, więc wymaga takiej samej ochrony jak każdy plik z sekretami: blokady na poziomie Apache, restrykcyjnych uprawnień i, jeśli hosting na to pozwala, przeniesienia poza katalog webowy.
Czy mogę uruchomić Joomlę pod www-data zamiast dedykowanego usera?
Można, i sporo małych, jednostronicowych instalacji robi dokładnie tak bez konsekwencji. Kompromis dotyczy izolacji: na serwerze hostującym tylko jedną stronę wybór usera niewiele zmienia, ale w momencie, gdy druga strona zaczyna dzielić tę samą pulę www-data, luka w jednej z nich naraża obie.
Czy potrzebuję open_basedir, skoro używam już disable_functions?
Obie dyrektywy pokrywają inne ścieżki ataku. disable_functions blokuje webshellowi wywołanie systemu operacyjnego; open_basedir blokuje PHP odczyt albo zapis plików poza katalogiem własnej strony, nawet bez wywołania powłoki. Warto stosować obie — nie są redundantne.
Jaka jest różnica między blokiem alternatywnych rozszerzeń a blokiem plików wrażliwych?
Blok alternatywnych rozszerzeń (.phtml, .pht, .phar, .php5) istnieje, bo niektóre serwery historycznie wykonywały te rozszerzenia jako PHP, więc atakujący zablokowany na uploadzie .php próbuje rozszerzenia podszywającego się pod inne. Blok plików wrażliwych celuje w konkretne nazwy i wzorce — configuration.php, zrzuty .sql, pliki .log — niezależnie od tego, czy w ogóle by się wykonały; tu chodzi wyłącznie o to, żeby były nieczytelne.
Czemu blokować akurat katalogi .git i .svn?
Oba przechowują pełną historię projektu, razem z plikami i liniami, które zostały później usunięte z roboczej kopii. Jeśli którykolwiek z tych katalogów jest dostępny publicznie, każdy z narzędziem do listowania katalogów może ściągnąć całą historię commitów, dane, które kiedykolwiek zostały do niej commitnięte i „usunięte", oraz dokładne ścieżki kodu, na których działa Twoja strona.
Mój apache2ctl configtest wywala się na bloku <FilesMatch> — czemu?
Prawie zawsze to jednoliniowy <FilesMatch>...</FilesMatch> zapisany wewnątrz bloku <Directory>. Część buildów Apache parsuje to bez problemu, inne odrzucają. Rozbij na trzy linie — tag otwierający, Require all denied w osobnej linii, tag zamykający — i configtest przechodzi.
Czy nadal potrzebuję bloków dla katalogów zapisywalnych, skoro już blokuję upload PHP gdzie indziej?
Tak — to sama Joomla, nie atakujący, zapisuje do images, media, tmp, cache, files i obu podkatalogów administrator w trakcie normalnej pracy. Nie można po prostu zablokować zapisu do nich — trzeba pozwolić na zapis, ale zablokować konkretnie wykonanie PHP, i dokładnie to robi połączenie AllowOverride None z FilesMatch.
Następne kroki
Wygeneruj pełny VirtualHost, pulę PHP-FPM i skrypt weryfikacyjny z własną domeną i userem w generatorze VirtualHost dla Joomla na vps-web.com — wypełnienie formularza jest szybsze niż składanie tej samej konfiguracji z pięciu różnych poradników.
Zahardenowany vhost to tylko jedna warstwa. Kilka powiązanych generatorów dopełnia konfigurację:
- Konfiguracja VirtualHost Apache dla Ubuntu/Debian — ogólna wersja tego generatora, przydatna, gdy kolejna strona nie stoi na Joomli.
- Konfiguracja strefy DNS — skieruj domenę na właściwy serwer, zanim poprosisz o certyfikat.
- mod_remoteip Apache za Nginx Proxy Manager — jeśli TLS terminujesz na reverse proxy przed Apache, a nie bezpośrednio na vhoście, to ta konfiguracja sprawia, że logi dostępu pokazują prawdziwe IP odwiedzających, a nie adres proxy.
Jeśli wolisz oglądać niż czytać, tematy administracji serwerami i hardeningu jak ten omawiam na moim kanale YouTube.