The Usual Situation
Many open-source PHP platforms like Wordpress handle user-submitted content very straightforwardly. If you upload a file, it typically gets saved in some publicly accessible URL on your site, such as https://example.com/wp-uploads/.../my-file.png
– so anyone can load it and view it.
While that may be desirable for images on a public blog, we here are Qbix are building community apps that allow people to manage and collaborate on content, and some of it can be private, accessible to specific users, or roles in a community. In fact, Qbix has a very robust system of access control with different levels for read
, write
and admin
, as well as support for custom roles and permissions
on a very granular level.
Access Control for Data
Clients make an HTTP GET web request to a web server like NGINX, which in turn forwards the request to the PHP application using proxy_pass
or something similar. The PHP application then looks into the database (e.g. MySQL) and finds the data. It then calculates the access control (e.g. using Streams::calculateAccess($asUserId, $publisherId, $streams)
) and applies the rules before returning the data. A similar procedure is done for PUT, POST, DELETE and other HTTP methods when modifying a resource. Here is a broader view of how a typical Qbix server setup processes requests:
The response is typically some HTML or JSON containing the data requested, after applying the access rules. Thus, for example, rendering an avatar may return only the firstName
but not the lastName
. Visiting a user’s profile and clicking Add to Contacts may generate a vCard
with their emailAddress
and phoneNumber
, or it may not, depending on whether they have chosen to share this information with the currently logged-in user.
Access Control for Files
Files are a bit different, because they are stored in the file system. One could, of course, store files in a MySQL database as BLOBs, and then have the PHP app try to stream the information from MySQL to the webserver, and from there to the browser. Perhaps storing files in MySQL databases instead of a filesystem like ext4
might even lead to more efficient lookups. However, this needlessly ties up the application into doing a lot of file I/O. In the case of PHP, it’s even worse because PHP with the typical fastcgi process manager
(FPM) setup just spawns a limited number of preforked threads that can only handle one request at a time. So every thread that’s spending time piping data from MySQL to NGINX or Apache is a thread that can’t be handling new web requests.
There is a second approach, however, that involves the PHP code doing what it’s designed to do: checking the access control, and then returning an “internal redirect” header to the web server, telling it where to find the file, or whether to serve some generic content with an 40x series HTTP code:
-
401 - Not Authorized
- please log in -
402 - Payment Required
- of some kind -
403 - Forbidden
- not enough privileges 404 - Not Found
- …
or whether to serve the actual content with a 200 Success
result. In the latter case, the PHP server would tell NGINX where to find the file to serve. Like this:
header('X-Accel-Redirect: /srv/hidden-files/` . $path);
Set up NGINX configuration file
You can read more about internal redirects in NGINXhttps://shortcut.com/developer-how-to/how-to-use-internal-redirects-in-nginx, here is an example:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location / {
proxy_pass http://127.0.0.1:8000/;
}
location ~ \.php$ {
fastcgi_param MOD_X_ACCEL_REDIRECT_ENABLED on;
}
location /hidden-files/ {
internal; # This tells nginx it's not accessible from the outside
alias /srv/hidden-files/;
}
}
Alternative Approach
Qbix is designed to work with many environments, not just NGINX. So if it uses a different web server which doesn’t even support internal redirects, as a fallback approach, the platform could try to store the uploaded file inside a folder with a long and unguessable name, inside the usual uploads location, like this:
MyApp/web/Q/uploads/abchjkerkjervkjwnvjkr/uploaded-file.png
This location, though, would never be revealed by PHP to browsers. Instead, if a user is authorized to access the file, Qbix Platform would call Q_Utils::symlink()
to create a temporary symlink in a folder with a name like. So for example:
MyApp/web/Q/uploads/1669355665/kerhvjrpvekrnvqa/uploaded-file.png
{{timestamp}}/{{randomString}}/{{filename}}
Rather than requesting the actual file, the browser could request a PHP-powered URL that would respond with HTTP code 302 (Temporary Redirect) to the actual file’s location, causing the browser to request it via the webserver and not tying up PHP to do I/O piping. The URL could look like this:
file.php/uploaded-file.png
If PHP is going to be generating the page in which the file is embedded or linked to, then it can already generate the symlinks ahead of time, something like this, so the browser will load that file without invoking another PHP process just to return a 302:
Q_Html::img('Q/uploads/1669355665/kerhvjrpvekrnvqa/uploaded-file.png');
This same system can work not just for individual files, but for entire directories of files that reference each other by relative pathnames.
Finally, a periodic cron
job would run another PHP script that would go through Q/uploads
and remove all timestamps that are in the past. By default, the system saves symlinks in directory names with timestamps 5 minutes into the future, which is plenty for a turnaround time from the browser. If multiple users access a file, each user would get their own symlink to the file, so they can be deleted independently.
This alternative approach does have a weakness that the user could theoretically right-click and share the image with someone else within that 5 minute window that it’s still available. But it’s still more secure than leaving that file there indefinitely, allowing any recipients of the URL as much time as they want to access it also. That’s how sensitive data can fall into the wrong hands.