vps·web
PL

Konfiguracja Apache VirtualHost dla Joomla

VirtualHost dla Joomla — zabezpieczenie Apache

Serwery · Konfiguracja Apache VirtualHost dla Joomla

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-dataopen_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ę:

Jeśli wolisz oglądać niż czytać, tematy administracji serwerami i hardeningu jak ten omawiam na moim kanale YouTube.