How to use the response header from an upstream host but also provide a default value? - nginx

I have an nginx conf which I pair with various applications to act as a reverse proxy.
I would like to enforce some specific response headers but allow the upstream service the freedom to override them.
If the inheritance rules for nginx were different, I would do something like
if ($sent_http_x_content_length = '') {
add_header X-Content-Length "nosniff" always;
}
if ($sent_http_content_type = '') {
add_header Content-Type "text/html" always;
}
But of course, since it's inside the if, the previous if blocks are overwritten.
What is a good workaround that doesn't involve installing the headers-more module?

Ultimately I went with the nginx-plus officially sanctioned more_set_headers community module.
map $sent_http_x_powered_by $resp_x_powered_by {
default $sent_http_x_powered_by;
"" "x powered by is empty";
}
map $sent_http_x_foo $resp_x_foo {
default "x foo by is set";
"" "x foo by is empty";
}
server {
add_header "SomeHeader" "SomeValue";
...
location / {
more_set_headers "X-Powered-By: $resp_x_powered_by";
more_set_headers X-Foo $resp_x_foo;
}
How this works:
more_set_headers will overwrite the headers that you specify with the value provided, e.g. $resp_x_powered_by
The map block will evaluate $resp_x_powered_by by looking at the string in $sent_http_x_powered_by.
If the string is "" then we use the default value of "x powered by is empty". Otherwise, we use the value from the upstream server.
Why I did this:
Initially I tried to use add_headers with map as Raul suggested, but the issue lies in header duplication. If the header is set, then add_header will simply add another line to define the header again. A common workaround is to use proxy_hide_header but that blanks out the value so when we hit the map block, it will always evaluate to "".
As you can see, there is an add_header block outside of the location block which would be overridden by scope inheritance. There are ways to get around it but more_set_headers solves that issue.

Related

How do I set default headers for upstream responses in nginx?

I want to make sure that at least the default header value is always returned from upstream. Even if upstream is unavailable, which causes error 5xx.
Now I have tried these nginx config options:
server {
...
#add_header "Access-Control-Allow-Origin" "*"; №0
#add_header "Access-Control-Allow-Origin" "*" always; №1
#more_set_headers "Access-Control-Allow-Origin: *"; №2
#more_set_headers -s '403 500 502 504 503' "Access-Control-Allow-Origin: *"; №3
location /upstream {
proxy_pass http://localhost:1234;
}
...
}
There are problems with all the options:
№0: Duplicates the header, and in the case of 5xx will not return any.
№1: Duplicates the header
№2: Overrides the upstream header
№3: If the upstream ended with a good http code, but did not return a header, it will not add a header.
I think I'm close to the right solution, but I can't find it.
The map below uses a regex, /.+/, to check if the Access-Control-Allow-Origin header is defined. If so, it assigns its value to the $acao custom variable. Otherwise, it assigns the default value * to $acao;
To avoid duplications, use proxy_hide_header
Finally, add the header using the $acao variable content.
http {
map $upstream_http_access_control_allow_origin $acao {
~.+ $upstream_http_access_control_allow_origin;
default '*';
}
server {
#…
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin $acao always;
location /upstream {
proxy_pass http://localhost:1234;
}
}

Regex returning wrong string/variable?

I'm trying to secure a HLS stream using the secure_link module in nginx. I'm doing this in two parts:
First the URL is a "SEO" friendly version, this gets rewritten to the secure_link URL which has the ?token=xxx&?expires=000.
This gets processed in the next block, and here I ONLY want to test a part of the URL (stream name in this case) and not the whole URI. I'm using a regex to get the part of the URL I need to test in a variable. But for some reason every time I execute a request I get the wrong variable here (as outputted in the debug return line) if I use the "SEO" friendly URL that is rewritten, I get the $1 from the original url, and not the $1 of the "new* (re-written) URL.
If I submit the manually rewritten request I get the correct output for that variable. So this is only happening when I'm using the first rewritten URL.
Can someone point me as to what I going on here and how I can fix this? I'm out of ideas.
Demo URL:
https://myserver.com/live/hls/PSBOBrr5bVsd3wPLMMQE7Q/1658978846/AdminTest_1080/index.m3u8
Nginx config:
location /live {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length' always;
rewrite /hls/([a-zA-Z0-9_\-]*)/([0-9]*)/([a-zA-Z0-9_\-]*)/(.*)\.(ts|key|m3u8)$ /hls/$3/$4.$5?token=$1&expires=$2;
root /mnt/not-exist;
}
location ~ "/hls/([a-zA-Z0-9_\-]*)/" {
# location ~ /hls/(?<alias>[a-zA-Z0-9_]+)/ { #TRIED BOTH REGEX HERE, BOTH SAME RESULT
#internal;
secure_link $arg_token,$arg_expires;
secure_link_md5 "$host $1 $secure_link_expires $remote_addr secret";
# if ($secure_link = "") { return 403; }
# if ($secure_link = "0") { return 410; }
add_header Content-Type text/plain;
return 200 "secure_link: $host $1 $secure_link_expires $remote_addr "; # FOR DEBUGGING
}
When submitting the above example to the first location block I get the following output:
secure_link: myserver.com PSBOBrr5bVsd3wPLMMQE7Q 127.0.0.1
Which is clearly incorrectly returning another part of the URL ($1 from the first location block, not the current location block)
When using a manually crafted URL:
https://myserver.com/hls/AdminTest_1080/index.m3u8?token=token&expires=0000
I get the following (correct output):
secure_link: myserver.com AdminTest_1080 127.0.0.1
At this point I'm even considering that this might be a bug? Hoping for valuable input from the community!

Add any arbitrary header to nginx

Let's say that you want to add a custom response header in nginx, but only on certain conditions.
If you search around, you'll find this recipe (for adding a Retry-After header while rate limiting)
map $status $my_response {
default "";
"429" "5";
}
server {
...
add_header Retry-After $my_response always;
...
}
But what if you want the header on some other condition?
It turns out that map is very, very powerful.
Let's say that you want something more granular? Or to use one of the other embedded variables. The limit_conn module has one such var, $limit_conn_status.
map $limit_conn_status $conn_response {
"PASSED" "";
"REJECTED" "Too many concurrent connections";
"REJECTED_DRY_RUN" "Too many concurrent connections (dry run)";
}
limit_conn_zone $binary_remote_addr zone=one:1m;
server {
...
limit_conn one 1;
add_header X-Custom-Header $conn_response always;
...
}
This is independent of the $status code! And when things are "passing" the header value is blank, so the header is not returned in the response.

nginx config to enable CORS with origin matching

I've tried to use a very popular config for nginx, which enables CORS and supports origin matching using regular expressions.
Here's my config:
server {
listen 80 default_server;
root /var/www;
location / {
if ($http_origin ~ '^http://(www\.)?example.com$') {
add_header Access-Control-Allow-Origin "$http_origin";
}
# Handling preflight requests
if ($request_method = OPTIONS) {
add_header Content-Type text/plain;
add_header Content-Length 0;
return 204;
}
}
}
However, this config must use two conditions: one to match the origin domain name and another one to capture preflight requests. So when the second condition is matched, the headers from the first conditions are not added to the response.
According to the If Is Evil official article, this is an expected behavior for nginx.
If If Is Evil how do I enable CORS in nginx then? Or maybe there is a way to overcome this limitation somehow?
You can try to use map istead of the first if block:
map $http_origin $allow_origin {
~^http://(www\.)?example.com$ $http_origin;
}
map $http_origin $allow_methods {
~^http://(www\.)?example.com$ "OPTIONS, HEAD, GET";
}
server {
listen 80 default_server;
root /var/www;
location / {
add_header Access-Control-Allow-Origin $allow_origin;
add_header Access-Control-Allow-Methods $allow_methods;
# Handling preflight requests
if ($request_method = OPTIONS) {
add_header Content-Type text/plain;
add_header Content-Length 0;
return 204;
}
}
}
nginx will refuse to add an empty HTTP headers, so they will be added only if Origin header is present in request and matched this regex.
The only solution I've found so far is a hack to use a variable to aggregate multiple conditions and then match it with only a single if statement, therefore duplicating some directives:
server {
listen 80 default_server;
root /var/www;
location / {
set $cors '';
set $cors_allowed_methods 'OPTIONS, HEAD, GET';
if ($http_origin ~ '^https?://(www\.)?example.com$') {
set $cors 'origin_matched';
}
# Preflight requests
if ($request_method = OPTIONS) {
set $cors '${cors} & preflight';
}
if ($cors = 'origin_matched') {
add_header Access-Control-Allow-Origin $http_origin;
}
if ($cors = 'origin_matched & preflight') {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods $cors_allowed_methods;
add_header Content-Type text/plain;
add_header Content-Length 0;
return 204;
}
}
}
A more compliant solution is a bit more involved but does de-duplicate the regex for domain matching, and can be placed into snippets.
I created the file /etc/nginx/snippets/cors-maps.conf which must be included inside the http { ... } block. It contains rules like so:
# always set value to append to Vary if Origin is set
map $http_origin $cors_site_v
{
~. 'Origin';
}
# set site-specific origin header if it matches our domain
map $http_origin $cors_site_origin
{
'~^https://(?:[-a-z\d]+\.)+example\.com$' $http_origin;
}
# validate the options only if domain matched
map '$request_method#$cors_site_origin#$http_access_control_request_method' $cors_site_options
{
# is an allowed method
'~^OPTIONS#.+#(?:GET|HEAD|POST|OPTIONS)$' okay;
# requested an unknown/disallowed method
'~^OPTIONS#.' nope;
}
# set value of Access-Control-Allow-Origin only if domain matched
map '$request_method#$cors_site_origin' $cors_site_acao
{
'~^(?:GET|HEAD|POST)#.' $cors_site_origin;
}
# set value of Access-Control-Allow-Credentials only if Origin was allowed
map $cors_site_acao $cors_site_acac
{
~. 'true';
}
Then /etc/nginx/snippets/cors-site.conf which can be included inside multiple location { ... } blocks:
# only using "if" safely with a "return" as explained in https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/
# return early without access headers for invalid pre-flight, because origin matched domain
if ($cors_site_options = nope)
{
add_header Vary $cors_site_v;
return 204 '';
}
# return early with access headers for valid pre-flight
if ($cors_site_options = okay)
{
add_header Access-Control-Allow-Origin $cors_site_origin;
add_header Access-Control-Allow-Credentials $cors_site_acac;
add_header Vary $cors_site_v;
add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS';
# probably overkill, gleaned from others' examples
add_header Access-Control-Allow-Headers 'Accept, Accept-Language, Authorization, Cache-Control, Content-Language, Content-Type, Cookie, DNT, If-Modified-Since, Keep-Alive, Origin, User-Agent, X-Mx-ReqToken, X-Requested-With';
add_header Access-Control-Max-Age 1728000;
return 204 '';
}
# conditionally set headers on actual requests, without "if", because directive ignored when values are empty strings ("map" default)
add_header Access-Control-Allow-Origin $cors_site_acao;
add_header Access-Control-Allow-Credentials $cors_site_acac;
add_header Vary $cors_site_v;
The # in the values to match aren't special, they simply serve as separators to allow tests with multiple input variables. Extra domains can be added to the map for $cors_site_origin, but would need a bit of tweaking to support domains with different allowed options/headers.
Without getting into the details of your nginx setup, it's not going to work anyway, because the CORS header's you're returning are incorrect...
Specifically:
For preflight (OPTIONS) requests, the following are the only meaningful CORS response headers: Access-Control-Allow Origin, (required), Access-Control-Allow Credentials (optional), Access-Control-Allow-Methods, (required), Access-Control-Allow-Headers, (required) and Access-Control-Max-Age, (optional). Any others are ignored.
For regular (non-OPTIONS) requests, the following are the only meaningful CORS response headers: Access-Control-Allow Origin (required), Access-Control-Allow Credentials (optional) and Access-Control-Expose-Headers (optional). Any others are ignored.
Note those required headers for pre-flight requests - currently you're only passing two of them... Also, note that you don't need to return Access-Control-Allow-Methods for a non-OPTIONS request - it's not 'valid', so will be ignored.
As far as your specific nginx issue goes, I think #Slava Fomin II has the correct-est answer...

How to add headers in nginx only sometimes

I have a nginx proxy to a API server. The API sometimes sets the cache control header. If the API hasnt set the cache control I want nginx to override it.
How do I do that?
I think I want to do something like this, but it doesnt work.
location /api {
if ($sent_http_cache_control !~* "max-age=90") {
add_header Cache-Control no-store;
add_header Cache-Control no-cache;
add_header Cache-Control private;
}
proxy_pass $apiPath;
}
You cannot use if here, because if, being a part of the rewrite module, is evaluated at a very early stage of the request processing, way before proxy_pass is called and the header is returned from the upstream server.
One way to solve your problem is to use map directive. Variables defined with map are evaluated only when they are used, which is exactly what you need here. Sketchily, your configuration in this case would look like this:
# When the $custom_cache_control variable is being addressed
# look up the value of the Cache-Control header held in
# the $upstream_http_cache_control variable
map $upstream_http_cache_control $custom_cache_control {
# Set the $custom_cache_control variable with the original
# response header from the upstream server if it consists
# of at least one character (. is a regular expression)
"~." $upstream_http_cache_control;
# Otherwise set it with this value
default "no-store, no-cache, private";
}
server {
...
location /api {
proxy_pass $apiPath;
# Prevent sending the original response header to the client
# in order to avoid unnecessary duplication
proxy_hide_header Cache-Control;
# Evaluate and send the right header
add_header Cache-Control $custom_cache_control;
}
...
}
Awswer from Ivan Tsirulev is correct but you don't have to use regex.
Nginx uses the first parameter of map as default value automatically so you don't have to add that either.
# Get value from Http-Cache-Control header but override it when it's empty
map $upstream_http_cache_control $custom_cache_control {
'' "no-store, no-cache, private";
}
server {
...
location /api {
# Use the value from map
add_header Cache-Control $custom_cache_control;
}
...
}

Resources