Короткий ответ

403 — сервер понял URL и отказал в доступе. Это не 404 (нет объекта) и не 401 (нужна аутентификация). На site.example с корнем /var/www/site.example чаще всего: нет index.php/index.html при выключенном autoindex, каталог не исполняемый для www-data, location ~ /\. { deny all; } режет легитимный путь, Apache Require all denied. Ubuntu по умолчанию без SELinux; если он есть — смотрите audit, не setenforce 0 навсегда. Не делайте chmod -R 777 /var/www/site.example.

Сначала error.log: directory index of "/var/www/site.example/" is forbidden vs open() failed (13: Permission denied).

Симптомы и как отличить

  • Браузер: 403 Forbidden, страница nginx/Apache, не приложения.
  • curl -I → 403, тело короткое.
  • Соседний URL /robots.txt может быть 200.
ЛогСмысл
directory index … forbiddenнет index, autoindex off
Permission denied open()POSIX-права или ACL
access forbidden by ruledeny / Require
404 после переносадругой корень
401 WWW-Authenticateauth, не 403

Возможные причины

  1. index не содержит index.php, файл не залит после rsync.
  2. Каталоги 700, владелец root, nginx worker www-data не проходит x на пути /var/www/site.example.
  3. location ^~ /wp-admin/ { deny all; } после копипасты hardening.
  4. Apache 2.4: забыли Require all granted в <Directory>.
  5. AppArmor nginx/apache2 (Ubuntu) — DENIED в dmesg.
  6. SELinux httpd_sys_content_t не на webroot (CentOS-привычка на Ubuntu-хосте с включённым SELinux).
  7. try_files $uri $uri/ =403 вместо =404 в чужом примере.
  8. Листинг запрещён, а запрос идёт на каталог без слэша/index.

Диагностика

1. Код и какой сервер ответил

curl -sI --resolve site.example:443:10.0.20.40 https://site.example/ | head
curl -sI --resolve site.example:443:10.0.20.40 https://site.example/index.php | head
sudo tail -n 30 /var/log/nginx/error.log
sudo tail -n 30 /var/log/apache2/site.example-error.log 2>/dev/null

Заголовок Server: подскажет фронт.

2. POSIX-путь целиком

namei -l /var/www/site.example/index.php
ps -o user,group,cmd -C nginx
ps -o user,group,cmd -C apache2

Каждый каталог на пути должен давать x для www-data. Файл — r. Типичный слом: /var/www 700 root после «укрепления».

3. Index и root

sudo nginx -T 2>/dev/null | grep -E 'server_name site.example|root |index |autoindex'
ls -la /var/www/site.example/

Пустой каталог + index index.html без файла = 403 на /.

4. Правила deny/allow

sudo grep -nE 'deny|allow|Satisfy|Require |chmod' /etc/nginx/sites-enabled/* /etc/apache2/sites-enabled/* 2>/dev/null

5. MAC (AppArmor / SELinux)

sudo aa-status 2>/dev/null | head
sudo dmesg | grep -iE 'apparmor|DENIED' | tail
getenforce 2>/dev/null
sudo ausearch -m avc -ts recent 2>/dev/null | tail

Решение

Сценарий A. Нет index

Положите index.php или поправьте:

index index.php index.html;
root /var/www/site.example;
location / {
    try_files $uri $uri/ /index.php?$args;
}

Для статики без PHP достаточно index.html. Не включайте autoindex on на проде как «лечение 403».

Сценарий B. Права

sudo chown -R www-data:www-data /var/www/site.example
sudo find /var/www/site.example -type d -exec chmod 755 {} \;
sudo find /var/www/site.example -type f -exec chmod 644 {} \;

wp-config.php можно 640, владелец www-data или деплой-пользователь в группе. Каталоги записи (wp-content/uploads) — 775 той же группе, не 777.

Проверьте родителей:

sudo chmod 755 /var /var/www

Сценарий C. nginx deny задел лишнее

Сузьте правило. Блокировать скрытые файлы:

location ~ /\.(?!well-known).* {
    deny all;
}

Не режьте /.well-known/acme-challenge/ — сломаете certbot.

Сценарий D. Apache Require

<Directory /var/www/site.example>
    Options -Indexes +FollowSymLinks
    AllowOverride FileInfo
    Require all granted
</Directory>

-Indexes предотвращает листинг, но / с index будет 200. Require ip 10.0.20.0/24 на всём DocumentRoot закроет интернет — тогда 403 ожидаем.

Сценарий E. AppArmor/SELinux

Ubuntu: профиль nginx в complain для проверки — лучше точечный tweak профиля, не aa-complain навсегда. SELinux: restorecon -Rv /var/www/site.example, контекст httpd_sys_content_t. Не оставляйте Permissive.

Сценарий F. PHP-файл есть, но location ~ \.php$ { deny all; }

После «hardening» сниппета PHP в корне запрещён, а index.php лежит в /var/www/site.example. Тогда / → directory index / try_files на php → 403, не 404. Сверьте порядок location: deny на \.php$ не должен покрывать фронтконтроллер. Запрещайте PHP только в uploads, cache, backup.

На Ubuntu AppArmor профиль nginx редко мешает чтению /var/www; чаще мешает кастомный root /home/deploy/site.example без разрешения профиля. Смотрите dmesg | grep apparmor, переносите сайт в /var/www, не aa-disable nginx навсегда.

Как проверить, что проблема устранена

sudo -u www-data test -r /var/www/site.example/index.php && echo readable
curl -sI --resolve site.example:443:10.0.20.40 https://site.example/ | head
curl -sI --resolve site.example:443:10.0.20.40 https://site.example/no-such | head

/ — не 403. Несуществующий путь — 404, не 403 (иначе try_files/error_page перепутаны). PHP может дать 502 — FPM.

Если не помогло

  • 403 только на POST: limit_except GET в location.
  • 403 с CDN WAF — смотрите ответ CF-RAY/WAF, не только origin.
  • Симлинк за пределы root: nginx disable_symlinks if_not_owner.
  • PHP-файл открывается как статика 403 из‑за location ~ \.php$ { deny all; } в чужом сниппете.

Профилактика

  • Деплой выставляет владельца и режим, не ручной chmod после каждого rsync.
  • Запрет листинга, явный index.
  • Hardening allow/deny — в git с ревью, не «скрипт с форума» на весь server.
  • Мониторинг всплеска 403: сканер или сломанный деплой.

FAQ

chmod 777 «на час»?

Нет. Это оставляет окно записи. Чините владельца и биты x на каталогах.

Почему ls под root видит файлы, а сайт 403?

Root обходит DAC. Смотрите sudo -u www-data.

SELinux на Ubuntu 24.04?

Пакет не включён по умолчанию. Если getenforce есть и Enforcing — работайте политикой, не отключайте навсегда.

403 на certbot /.well-known

Исключение location для ACME, иначе продление сломается.

Apache AH01630 client denied by server configuration

Это 403 от Require. Сверьте Directory/Location с DocumentRoot после переноса.