skip to Main Content

This came up from time here and there but no question properly covers this use case.

The relevant section of http:

    map $http_origin $origin_with_default {
        default '*';
        ~. $http_origin;
    }
    map $request_method $es_target {
        default '';
        POST 'search';
        GET 'search';
        HEAD 'search';
        OPTIONS 'options';
    }
    root         /app;

The relevant section of server:

server {
  location ~* /(.*)/_search {
    limit_except OPTIONS {
      auth_basic "Read Users";
      auth_basic_user_file /etc/nginx/htpasswd_read;
    }
    rewrite ^ /internal/$es_target;
  }
  location /internal {
    return 405;
  }
  location /internal/search/ {
    internal;
    proxy_pass http://elasticsearch/;
    proxy_http_version 1.1;
    proxy_set_header Connection "Keep-Alive";
    proxy_set_header Proxy-Connection "Keep-Alive";

    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Credentials;
    proxy_hide_header Access-Control-Allow-Headers;
    proxy_hide_header Access-Control-Allow-Credentials;

    include "cors.headers";
  }
  location /internal/options {
    internal;
    add_header 'Content-Type' 'text/plain; charset=utf-8';
    add_header 'Content-Length' 0;
    add_header 'Access-Control-Max-Age' 1728000;
    include "cors.headers";
    return 204;
  }
}

Finally, the cors.headers file:

add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Origin $origin_with_default always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
  1. POST https://example.com/index_name/_search gives 401. This is as expected.
  2. OPTIONS https://example.com/index_name/_search returns the options headers. This is also as expected.
  3. However, POST https://u:[email protected]/index_name/_search gives a 404 and the server log contains open() "/app/index_name/_search" failed (2: No such file or directory),. Before I added the rewrite ^ /internal/$es_target; and the location /internal/search/ section, when the proxy_pass was just after limit_except inside location ~* /(.*)/_search { it did work. Because of 1) and 2) I believe the rewrite and the location matching works. But why does it try to serve a file instead of doing a proxy pass?

2

Answers


  1. Chosen as BEST ANSWER

    Here's a working config. It needed limit_except removed, every location switched to regex matching consuming everything -- and the last location being last is important to be so otherwise it gets into a rewrite loop. The "If the proxy_pass directive is specified with a URI, then when a request is passed to the server, the part of a normalized request URI matching the location is replaced by a URI specified in the directive" part of the rewrite module is not something I was able to get working.

    We still need the http section:

        map $http_origin $origin_with_default {
            default '*';
            ~. $http_origin;
        }
        map $request_method $es_target {
            default 'invalid';
            POST 'search';
            GET 'search';
            HEAD 'search';
            OPTIONS 'options';
        }
    

    And then comes server

    server {
      location ~ /internal/search/(?<search>.*) {
        internal;
        auth_basic "Read Users";
        auth_basic_user_file /etc/nginx/htpasswd_read;
        proxy_pass http://elasticsearch/$search;
        proxy_http_version 1.1;
        proxy_set_header Connection "Keep-Alive";
        proxy_set_header Proxy-Connection "Keep-Alive";
    
        proxy_hide_header Access-Control-Allow-Origin;
        proxy_hide_header Access-Control-Allow-Credentials;
        proxy_hide_header Access-Control-Allow-Headers;
        proxy_hide_header Access-Control-Allow-Credentials;
    
        include "cors.headers";
      }
      location ~ /internal/options {
        internal;
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        add_header 'Access-Control-Max-Age' 1728000;
        include "cors.headers";
        return 204;
      }
      location ~ /internal/invalid {
        return 405;
      }
      location ~* /_search$ {
        rewrite (.*) /internal/$es_target$1;
      }
    }
    

  2. The following answer will require the minimal understanding of two nginx base concepts – the request processing phases (described in the development guide) and the location content handler (which can be either local, when the request is served using some local file contents, or not, when the response is coming from some kind of upstream – an HTTP proxy, FastCGI or uWSGI daemon, etc.).

    Despite the fact that I have quite extensive experience in configuring nginx, limit_except isn’t a directive I’d used to use frequently. To understand its behavior I did a couple of tests. Here is the list of nginx directives I’m going to use and the request processing phases where they are registering their handlers, in order of execution:

    • rewriteNGX_HTTP_REWRITE_PHASE
    • auth_basicNGX_HTTP_ACCESS_PHASE
    • try_filesNGX_HTTP_PRECONTENT_PHASE
    • proxy_passNGX_HTTP_CONTENT_PHASE

    From all the above directives only auth_basic and proxy_pass are allowed to use inside the limit_except block. The try_files "" <location> trick I’m going to use described in this answer at ServerFault, so I would skip its detailed description here.

    TL;DR The solution will be provided at the next part of the answer; the limit_except directive cannot be used to solve the problem.


    The limit_except directive behavior analysis

    I will use the following config to analyze the limit_except directive behavior:

    server {
        listen 8080;
        return 200 "upstream: request URI is "$request_uri", request method is $request_method";
    }
    server {
        listen 80;
        root /var/www/html;
        index index.html;
        ... locations will vary during the tests
    }
    

    Under the /var/www/html directory I’ll place a single index.html file with the single text line index.

    Here we go.

    location / {
        limit_except GET {
            proxy_pass http://127.0.0.1:8080;
        }
    }
    
    > curl http://127.0.0.1/
    index
    > curl -X POST http://127.0.0.1/
    upstream: request URI is "/", request method is POST
    

    For the GET request nginx uses the local content handler. For the POST request nginx uses the http_proxy_module content handler.

    location / {
        limit_except GET {}
        proxy_pass http://127.0.0.1:8080;
    }
    
    > curl http://127.0.0.1/
    upstream: request URI is "/", request method is GET
    > curl -X POST http://127.0.0.1/
    upstream: request URI is "/", request method is POST
    

    Here nginx uses the defined http_proxy_module content handler for both requests. We didn’t find anything we can’t be expect yet. Lets go further.

    location / {
        rewrite ^ /internal;
        limit_except GET {}
        proxy_pass http://127.0.0.1:8080;
    }
    location /internal {
        return 200 internal;
    }
    
    > curl http://127.0.0.1/
    internal
    > curl -X POST http://127.0.0.1/
    upstream: request URI is "/", request method is POST
    

    The rewrite rules are completely ignored if the request falls under the limit_except condition. This looks like something we did not expect. However a quick search gives us the nginx trac ticket referring the following comment:

    The problem that rewrite module directives (set, if) are not inherited into the limit_except block, and not executed there.

    This behaviour is basically identical to a nested location block.

    Now let’s check the try_files directive behavior. To do it we will add the following map block

    map $request_method $loc_name {
        POST    pst;
        default def;
    }
    

    and two named locations

    location @def { return 200 def; }
    location @pst { return 200 pst; }
    

    to our configuration.

    location / {
        try_files "" @$loc_name;
    }
    
    > curl http://127.0.0.1/
    def
    > curl -X POST http://127.0.0.1/
    pst
    

    The unconditional jump to the named location works as expected.

    location / {
        proxy_pass http://127.0.0.1:8080;
        try_files "" @$loc_name;
    }
    
    > curl http://127.0.0.1/
    def
    > curl -X POST http://127.0.0.1/
    pst
    

    This is also expected, since the NGX_HTTP_PRECONTENT_PHASE where try_files attaches its handler is executed before the NGX_HTTP_CONTENT_PHASE one.

    location / {
        limit_except GET {}
        try_files "" @$loc_name;
    }
    
    > curl http://127.0.0.1/
    def
    > curl -X POST http://127.0.0.1/
    (HTTP 405 Not Allowed)
    

    Looks like the nginx tries to use local content handler for the POST request.

    location / {
        limit_except GET {}
        proxy_pass http://127.0.0.1:8080;
        try_files "" @$loc_name;
    }
    
    > curl http://127.0.0.1/
    def
    > curl -X POST http://127.0.0.1/
    upstream: request URI is "/", request method is POST
    

    Bad news. The NGX_HTTP_PRECONTENT_PHASE handler defined in the main location did not get executed if the request falls under the limit_except condition. This looks similar to the nested locations behavior, although in contradistinction to the nested location we can’t use the try_files directive inside the limit_except block.

    Looks like the limit_except directive has some kind of limited use cases. Does it mean the question problem is not solvable? No. It means the limit_except directive cannot be used to solve it and we need to find some other way. Never give up 🙂


    Solution

    You can optionally enable/disable basic authentication using the technique I just described here. Add the additional map block to your configuration:

    map $es_target $realm {
        search    "Read Users";
        default   off;
    }
    

    Now you can enable conditional basic auth in a following way:

    location ~ /_search$ {
        auth_basic $realm;
        auth_basic_user_file /etc/nginx/htpasswd_read;
    }
    

    However you can’t use the rewrite directive in this block since that directive is executed during the NGX_HTTP_REWRITE_PHASE, and auth_basic directive register its handler at the later NGX_HTTP_ACCESS_PHASE. While for the regular allow/deny directives there is a way to do all the checks using only the rewrite module directives (generic example is here), there is no such a way for basic auth. Fortunately we still can use our try_files trick (which will be executed at the later NGX_HTTP_PRECONTENT_PHASE). If by chance you are using OpenResty bundle or lua-nginx-module, you have an additional options described in the aforementioned answer.

    I can see you already faced the problems with the correct URI that should be passed to the upstream. Your original proxy_pass http://elasticsearch/; will pass the / for every request, and the proxy_pass http://elasticsearch; will pass the rewritten URI. While your original request URI is always available via the $request_uri variable (which does not get changed with the rewrite directive unlike the $uri one), and something like proxy_pass http://elasticsearch$request_uri; should work too, we will use the named locations (we are not limited to, but that way we should prevent any URI changes at all). Here is the whole solution (I slightly optimize your first map block to prevent (some kind of expensive) regex library call):

    map $http_origin $origin_with_default {
        ''      '*';
        default $http_origin;
    }
    map $request_method $es_target {
        POST 'search';
        GET 'search';
        HEAD 'search';
        OPTIONS 'options';
        default 'wrong';
    }
    map $es_target $realm {
        search    "Read Users";
        default   off;
    }
    
    location ~ /_search$ {
        auth_basic $realm;
        auth_basic_user_file /etc/nginx/htpasswd_read;
        try_files "" @$es_target;
    }
    location @search {
        proxy_pass http://elasticsearch;
        proxy_http_version 1.1;
        proxy_set_header Connection "Keep-Alive";
        proxy_set_header Proxy-Connection "Keep-Alive";
    
        proxy_hide_header Access-Control-Allow-Origin;
        proxy_hide_header Access-Control-Allow-Credentials;
        proxy_hide_header Access-Control-Allow-Headers;
        proxy_hide_header Access-Control-Allow-Credentials;
    
        include "cors.headers";
    }
    location @options {
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        add_header 'Access-Control-Max-Age' 1728000;
        include "cors.headers";
        return 204;
    }
    location @wrong {
        return 405;
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search