Nginx, php-fpm and SELinux enforcing on CentOS 7

Updated at by

I'll cover some basic setup needed to get your own php application, installed underneath /opt, running on CentOS 7. Nginx is on the frontline passing requests to php 7.1's fpm (php-fpm). Application will have it's own dedicated php-fpm worker pool and user and the app's directories will have proper SELinux labels.

Upgrading to php 7

CentOS 7 ships with venerable php 5.4 so in order to get your sockets on php 7 add Remi's RPM repository or IUS. They both, Remi and IUS, depend on EPEL. Remi will be used in this this example.

yum install epel-release nginx
yum install http://rpms.famillecollet.com/enterprise/remi-release-7.rpm

Install php-cli and php-fpm, opcache is optional but the ~30% speed boost speaks for itself. Again php-cli is optional but as lot of application ship with console commands then whatteheck.

yum install php71-php-fpm php71-php-cli php71-php-opcache 

Optional stuff like mcrypt, bcmath and mysql related.

yum install php71-php-mcrypt php71-php-bcmath
yum install php71-php-pdo php71-php-mysqlnd 

Add user for your application

Having a user for the application (not nginx nor apache as php-fpm worker user) makes it alot easier to secure stuff. Pros include:

  • nginx only needs read access to document root which served via httpd anyway.
  • nginx doesn't necessarily need read access to any php files given you have a single (or just a few) entry points for your application and they are located outside of document root.
  • your app won't have any access to nginx's files.
  • consistent ownership of application files, sessions, tmp and uploads etc.
  • sudoers for dishing out permissions to run application cli commands is easier.
  • after initial 'extra work' it simplifies spawning new apps and keeps them properly separated at DAC level.

I'll add two users. dbut shall be the application 'owner' and his buddy in the dbut group will dbutter, the application user.

useradd -r dbut
useradd -r -g dbut dbutter

Application layout

Example application directory structure shoved under /opt/dbut

.
├── application         <---- application (r) readable
│   ├── app.php         
│   ├── config.xml      <---- sorry :)
│   ├── vendor    
│   └── view
├── document_root       <---- nginx (r) readable static and user content
│   ├── css
│   │   └── common.css
│   ├── image
│   │   └── sprite.png
│   ├── js
│   │   └── vanilla.js
│   └── user_content    <---- application (rw) and nginx (r)
│       └── user_1.png
├── log                 <---- application (a) logs
└── session             <---- application (rw) session.save_path

DAC permissions

Strip permissions from others to sensitive directories (originally created with umask 0022).

cd /opt/dbut
chmod o-rx -R log session application

And grant write access for the application user by making rw directories group writable

chmod g+rwx log session document_root/user_content

Nginx configuration

Following will just smack 1 week expiration for the regex matched directories. As for other request Nginx will try to serve a file under root, if no file matches the request, php-fpm will handle it with a hard-coded SCRIPT_FILENAME. Only minimal amount of $_SERVER variables are available for the application itself. If you need more e.g. REMOTE_ADDR see /etc/nginx/fastcgi_params.

server {
  listen 80;
  listen [::]:80;

  server_name dbut.local;

  root /opt/dbut/document_root;

  location ~ ^/(css|js|image|user_content)/ {
    expires 1w;
  }

  try_files $uri @php;

  location @php {
    fastcgi_pass  unix:/var/run/php-fpm-dbut.socket;
    fastcgi_param REQUEST_METHOD  $request_method;
    fastcgi_param REQUEST_URI     $request_uri;
    fastcgi_param SCRIPT_FILENAME /opt/dbut/application/app.php;
  }
}

php-fpm configuration

A skeleton config for socket. Only nginx user can access the socket. catch_workers_output and error_log directives try to ensure errors pop up somewhere before app's own error handler lands on the stage.

[dbut]
user = dbutter
group = dbut

listen = /var/run/php-fpm-dbut.socket
listen.owner = nginx
listen.group = nginx
listen.mode = 0600

pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 100

catch_workers_output = yes
security.limit_extensions = .php

php_admin_value[error_log] = /opt/dbut/log/php-fpm-error.log
php_admin_flag[log_errors] = on

[global]
process_control_timeout = 5s

SELinux context

First I'd recommend installing this handy package for managing SELinux filesystem path contexts and port lists:

yum install policycoreutils-python

Php-fpm process runs as httpd_t (same label as Apache and Nginx) so I'll use the following labels for filesystem:

  • httpd_sys_content_t for read-only
  • httpd_log_t for append only
  • httpd_sys_rw_content_t for read-write

As SELinux matches rules in order they created the most generic has to be created first. Making the whole application directory read-only:

semanage fcontext -a -t httpd_sys_content_t "/opt/dbut(/.*)?"

Log directory gets httpd_log_t. Functions like fopen() must use mode 'a' or file_put_contents() must be flagged with FILE_APPEND.

semanage fcontext -a -t httpd_log_t "/opt/dbut/log(/.*)?"

Session and user_content directories will be read-write.

semanage fcontext -a -t httpd_sys_rw_content_t "/opt/dbut/session(/.*)?"    
semanage fcontext -a -t httpd_sys_rw_content_t "/opt/dbut/document_root/user_content(/.*)?"

In case of oooops. To remove a rule

semanage fcontext -d "/opt/dbut/document_root/user_content(/.*)?"

Finally restore context restorecon the whole directory recursively

restorecon -r /opt/dbut

and ls -Z should say something like this:

[root@burp]# ls -Z /opt/dbut
drwxr-x---. dbut dbut unconfined_u:object_r:httpd_sys_content_t:s0 application
drwxr-xr-x. dbut dbut unconfined_u:object_r:httpd_sys_content_t:s0 document_root
drwxrwx---. dbut dbut unconfined_u:object_r:httpd_log_t:s0 log
drwxrwx---. dbut dbut unconfined_u:object_r:httpd_sys_rw_content_t:s0 session

[root@burp]# ls -Z /opt/dbut/document_root
drwxr-xr-x. dbut dbut unconfined_u:object_r:httpd_sys_content_t:s0 css
drwxr-xr-x. dbut dbut unconfined_u:object_r:httpd_sys_content_t:s0 image
drwxr-xr-x. dbut dbut unconfined_u:object_r:httpd_sys_content_t:s0 js
drwxrwxr-x. dbut dbut unconfined_u:object_r:httpd_sys_rw_content_t:s0 user_content

app.php of awesomium

<?php
ini_set('session.save_path', '/opt/dbut/session/');
session_start();

file_put_contents("/opt/dbut/log/dbut.log", date('c') . " appending log\n", FILE_APPEND);
file_put_contents("/opt/dbut/document_root/user_content/user_2.txt", "user writing some text");

$tmp = tmpfile();
fwrite($tmp, "tempfile in depths of /tmp/systemd-private-HASHASH-php71-php-fpm.service");
fclose($tmp);

avc: denied in audit.log and notes

If you want to see selinux denying all the things you can either flip selinux to permissive and restore stock context for the application directories

setenforce 0
chcon -r -t user_t /opt/dbut` 
setenforce 1

or drop all the rules created previously listed below and restorecon -r /opt/dbut

[root@burp]# semanage fcontext -l | grep /opt/dbut
/opt/dbut(/.*)?                                    all files          system_u:object_r:httpd_sys_content_t:s0 
/opt/dbut/log(/.*)?                                all files          system_u:object_r:httpd_log_t:s0 
/opt/dbut/session(/.*)?                            all files          system_u:object_r:httpd_sys_rw_content_t:s0 
/opt/dbut/document_root/user_content(/.*)?         all files          system_u:object_r:httpd_sys_rw_content_t:s0 

Happy enforcing!


Leave a comment